作用域闭包

闭包,JavaScript这门语言中近乎神话的一个概念。如果你了解词法作用域的改变,那闭包这个概念几乎是不言自明的。JavaScript中闭包无处不在,你只需要能够给识别并拥抱它。

闭包是基于词法作用域书写代码时所产生的自然结果。当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    bar();
}

foo();

上面这段代码,从技术上来讲,也许是闭包。但根据前面的定义,确切的说并不是。这里更准确的解释是bar()对a的引用,是运用的词法作用域的查找规则,而这些规则只是闭包的一部分。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}

var baz = foo();
baz(); //2

上面这段代码中,我们将bar()函数本身当做一个值类型进行传递,而bar()函数的词法作用域能够访问foo()的内部作用域。

foo()执行后,得到的返回值会赋值给变量baz,并调用baz(),实际上只是通过不同的标识符引用调用了内部函数bar()。

在这个例子中,bar()在自己定义的词法作用域之外被执行,这就是闭包的效果。

在函数执行之后,正常情况下,内部的整个作用域都会被销毁。但在这个例子中不会被销毁,因为bar()本身在使用,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

循环和闭包

for(var i = 1;i <= 5;i++){
    setTimeout(function timer(){
        console.log(i);
    },1000);
}

上面这段代码,我们的预期是分别输出1~5,每秒一次,每次一个。但实际情况是每秒输出一个6。

这个6,是因为循环终止条件是i <= 5,条件首次成立时 i 值是 6。延迟函数的回调函数会在循环结束之后才会运行。

我们试图假设循环中的每个迭代在运行时都会给自己创建一个 i 的副本。但根据作用域工作原理,尽管循环中的五个函数是在各个迭代中分别定义的,但它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i 。因此我们需要更多的闭包作用域。

for(var i = 1;i <= 5;i++){
    (function(){
        setTimeout(function timer(){
            console.log(i);
        },1000);
    })();   
}

但上面的代码还是不行,为什么?因为作用域是空的,仅仅是将它们封闭起来是不够的。它需要一点实质内容才行。

for(var i = 1;i <= 5;i++){
    (function(){
        var j = i;
        setTimeout(function timer(){
            console.log(j);
        },1000);
    })();   
}

这样就可以了。再改进一下。

for(var i = 1;i <= 5;i++){
    (function(j){
        setTimeout(function timer(){
            console.log(j);
        },1000);
    })(i);  
}
块作用域

上面我们用IIFE在每次迭代时都创建一个新的作用域,其实就是每次迭代我们都需要一个块作用域。

for(var i = 1;i <= 5;i++){
    let j = i; //闭包的块作用域
    setTimeout(function timer(){
        console.log(j);
    },1000);
}

本质上这是将一个块转换成一个被关闭的作用域。

for(let i = 1;i <= 5;i++){
    setTimeout(function timer(){
        console.log(i);
    },1000);
}

在for循环头部用let声明 i 会有一个特殊行为,每次迭代都会声明一次。随后的每次迭代都会使用上一次迭代结束时的值来初始化这个变量。

模块

function CoolModule(){
    var something = 'cool';
    var another = [1,2,3];
    
    function doSomething(){
        console.log(something);
    }

    function doAnother(){
        console.log(another.join('!'));
    }

    return {
        doSomething: doSomething,
        another: another
    }
}

var foo = CoolModule();
foo.doSomething(); //cool
foo.another(); //1!2!3!

上面这段代码的模式,在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露。

通过调用CoolModule() 来创建一个模块实例。如果不执行,内部作用域和闭包都无法被创建。

CoolModule() 返回一个用对象字面量语法来表示的对象,没有返回内部数据变量的引用,这是为了保持内部数据变量的隐藏和私有的状态。可以将这个对象类型的返回值看做是模块的公共API。

从模块中返回一个对象并不是必须的,也可以直接返回一个内部函数。jQuery就是最好的例子。

模块模式必备两个必要条件:
1. 必须有外部的封闭函数,该函数必须至少被调用一次。
2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象不是真正的模块。

var foo = (function CoolModule(id){
    function change(){
        publicAPI.identity = identity2;
    }

    function identity1(){
        console.log(id);
    }

    function identity2(){
        console.log(id.toUpperCase());
    }

    var publicAPI = {
        change: change,
        identity: identity1
    }

    return publicAPI;
})("foo module");

foo.identity();  //foo module
foo.change();
foo.identity();  //FOO MODULE

通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

现代的模块机制

var MyModules = (function(){
    var modules = {};

    function define(name, deps, impl){
        for(var i = 0;i < deps.length;i++){
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl,deps);
    }

    function get(name) {
        return modules[name];
    }

    return {
        define: define,
        get: get
    }
})();

MyModules.define('bar',[],function(){
    function hello(who){
        return 'Let me introduce: ' + who;
    }

    return {
        hello: hello
    }
});

MyModules.define('foo',['bar'],function(bar){
    var hungry = 'hippo';
    
    function awesome(){
        console.log(bar.hello(hunpry).toUpperCase());
    }

    return {
        awesome: awesome
    }
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');
console.log(bar.hello('hippo')); //Let me introduce: hippo
foo.awesome(); //LET ME INTRODUCE: HIPPO

这段代码最核心的就是 modules[name] = impl.apply(impl, deps) 为模块定义引入包装函数,并将返回值,也就是模块的API,存储在一个根据名字来管理的模块列表中。换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

未来的模块机制

ES6中为模块增加了一级语法支持,一个文件为一个模块。与基于函数的模块相比,ES6模块API更加稳定。

//bar.js

function hello(who){
    return 'Let me introduce: ' + who;
}

export hello;
//foo.js

//仅从‘bar’模块导入hello()
import hello from 'bar';

var hungry = 'hippo';

function awesome(){
    console.log(hello(hungry).toUpperCase());
}

export awesome;
//导入完成的‘foo’和‘bar’模块
import bar from 'bar';
import foo from 'foo';

console.log(bar.hello('rhino'));
foo.awesome();

import 可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上。module会将整个模块的API导入并绑定到一个变量上。export会将当前模块的一个标识符(变量、函数)导出为公共API。

模块文件中的内容会被当做好像包含在作用域闭包中一样来处理。

2017/11/25 posted in  JavaScript

函数作用域和块作用域

一个作用域就犹如一个气泡,气泡可以层层嵌套,也可以如蜂窝一样整齐的排列。

一、函数中的作用域

在Javascript中每声明一个函数都会为其自身创建一个气泡。

function foo(a) { 
    var b = 2;
    // 一些代码
    function bar() { // ...
    }
    // 更多的代码 
    var c = 3;
}

在这片代码中,foo(…)的作用域气泡中包含了a、b、c和bar,无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或者函数都附属于所处作用域的气泡。

全局作用域也有自己的气泡,它只包含了一个标识符:foo。

函数作用域的含义:属于这个函数的全部变量都可以在整个函数范围内使用及复用(嵌套的作用域中也可以使用)

二、隐藏内部实现

在软件设计中,有个原则叫最小授权或最小暴露原则,就是应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来。

在Javascript中可以通过函数来实现这一原则,在所写的代码中挑选出任意片段,然后用函数声明对它进行包装,实际上就是在这篇代码的周围创建了一个作用域气泡,这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中。

function doSomething(a) {
    b = a + doSomethingElse( a * 2 );
  console.log( b * 3 );
}
function doSomethingElse(a) { 
    return a - 1;
}
var b;
doSomething( 2 ); // 15

这段代码中,变量b和函数doSomethingElse(…)应该是doSomething(…)内部具体实现的“私有”内容。给予外部作用域对b 和 doSomethingElse(…)的“访问权限”不仅没必要,而且可能是“危险”的。

function doSomething(a) { 
    function doSomethingElse(a) {
        return a - 1; 
    }
    var b = a + doSomethingElse( a * 2 );
  console.log( b * 3 );
}
doSomething( 2 ); // 15

现在变量b和函数doSomethingElse(…)都无法从外部被访问了。

规避冲突
“隐藏”作用域中的变量和函数可以避免同名标识符之间的冲突

function foo() { 
    function bar(a) {
        i = 3;
      console.log( a + i );
  }

    for (var i=0; i<10; i++) {
        bar( i * 2 );
    } 
}
foo();

上面的代码中,因为bar(...)内容的赋值表达式是 i = 3,在bar(…)作用域中找不到 i 变量的声明,顺着作用域链就会找到foo(…)作用域里的变量 i ,这样就导致了无限循环。

只要bar(…)内部的赋值操作改成声明一个本地变量来使用,如 var i = 3; 就可以满足这个需求(遮蔽变量)

1.全局命名空间

当程序中加载了多个第三库时,如果它们没有妥善的将内部私有的函数或变量隐藏起来,就会很容易发生冲突。

这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。

var MyReallyCoolLibrary = { 
    awesome: "stuff", 
    doSomething: function() {
        // ... 
    },
  doAnotherThing: function() {
      // ...
    } 
};

2.模块管理

另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。

三、函数作用域

在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,但这并不理想。

var a = 2;
function foo() { 
    var a = 3; 
    console.log( a ); // 3
} 
foo(); 
console.log( a ); // 2

因为首先必须声明一个具名函数 foo(),这意味着 foo 这个名称本身“污染”了所在的作用域。其次,必须显示地通过函数名(foo())调用这个函数才能运行其中的代码。

var a=2;
(function foo(){ 
    var a = 3;
    console.log( a ); // 3 
})();  
console.log( a ); // 2

只要 function 是声明中的第一个词,那么就是一个标准函数声明,否则就是一个函数表达式。(function foo(){ …. }) 作为函数表达式意味着 foo 只能在 … 所代表的位置中被访问。

1、匿名和具名

setTimeout( function() {
    console.log("I waited 1 second!");
}, 1000 );

匿名函数表达式,因为function() …. 没有名称标识符。函数表达式可以是匿名的,而函数声明不可以省略函数名。匿名函数用的最多的地方就是作为回调参数。

匿名函数表达式书写起来很方便,但却有几个缺点:

  1. 在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 没有函数名,在函数需要引用自身时只能使用已经过期的 arguments.callee 引用
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名,一个描述性的名称可以让代码不言自明。

2、立即执行函数表达式

术语:IIFE(Immediately Invoked Function Expression)

var a = 2;
(function foo() { 
    var a = 3;
  console.log(a); // 3
})();
console.log(a); // 2

由于函数被包含在一对( )括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数。

进阶用法:

var a = 2;
(function IIFE( global ) {
    var a = 3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
})( window );
console.log( a ); // 2

可以从外部作用域传递任何你需要的东西,并将变量命名为任何你觉得合适的名字。

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! 
(function IIFE( undefined ) {
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();

上面的代码可以解决 undefined 标识符的默认值被错误覆盖导致异常。将第一个参数命名为undefined ,但对应的位置不传入任何值。

var a = 2;
(function IIFE( def ) { 
    def( window );
})(function def( global ) {
    var a = 3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
});

倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当做参数传递进去。

四、块作用域

for (var i = 0; i<10; i++) { 
    console.log( i );
}

上面这段代码中,通常我们只想在 for 循环内部的上下文中使用 i ,但其实 i 会被绑定在外部作用域(函数或全局)中。

var foo = true;
if (foo) { 
    var bar= foo * 2;
    bar = something( bar ); 
    console.log( bar );
}

这段代码中,尽管我们是在 if 声明的上下文中声明了 bar 变量。但是,当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

块级作用域的用处就是使变量的声明距离使用的地方越近越好,并最大限度地本地化。但可惜,表面上看 Javascript 并没有块级作用域。除非,深入研究,下面几种情况都会创建出块级作用域。

  1. with
  2. try/catch
  3. let(ES6引入的关键字)
  4. const (ES6引入的关键字,其值为常量)
2017/11/25 posted in  JavaScript

提升

考虑以下两段代码:

a = 2;
var a;
console.log(a);
console.log(a);
var a = 2;

分别会输出什么呢?

当看到 var a = 2; 时,我们会习惯的认为这是一个声明,但是在 javascript 中实际上会将其看成两个声明:var a; 和 a = 2;。第一个声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

所以以上两段代码会以如下形式进行处理:

var a;
a = 2;
console.log(a); //2
var a;
console.log(a); //undefined
a = 2;

这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到最上面。这个过程就叫作提升。另外值得注意的是,每个作用域都会进行提升操作

foo();

function foo(){
    console.log(a); //undefined
    var a = 2;
}

foo(); //TypeError
bar(); // ReferenceError

var foo = function bar(){

}

可以看到函数的声明会被提升(还包括实际函数的隐含值)。

但函数表达式,提升的只是 foo 这个变量标识符,所以第二段代码段中 foo() 由于对 undefined 值进行函数调用而导致非法操作,因此抛出 TypeError 异常。

另外即使是具名的函数表达式,名称标识符在赋值之前也无法再所在的作用域中使用。

上面两段代码可以看成:

var foo = function(){
    var a;
    console.log(a); //undefined
    a = 2;
}

foo();
var foo;

foo(); //TypeError
bar(); // ReferenceError

foo = function(){
    var bar = ...self...;
}

还需要注意的一点,函数和变量声明都会被提升,但函数会首先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() { 
    console.log( 1 );
}

foo = function() { 
    console.log( 2 );
};

尽管 var foo 出现在 function foo()… 之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前,这段代码会被引擎理解为如下形式:

function foo() { 
    console.log(1);
}

foo(); // 1

foo = function() { 
    console.log(2);
};

尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

2017/11/25 posted in  JavaScript

编译

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编 译”。

  • 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。

分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的, 主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简 单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法 单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法 分析。

  • 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
    var a = 2;的抽象语法树中可能会有一个叫作VariableDeclaration的顶级节点,接下 来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression 的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子 节点。

  • 代码生成
    将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。
    抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指 令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

2017/11/25 posted in  JavaScript

词法作用域

一、词法阶段

function foo(a) { 
    var b = a * 2;
    function bar(c) { 
        console.log( a, b, c );
    }
    bar(b*3); 
}
foo(2);//2,4,12
  1. 全局作用域中有一个标识符:foo
  2. foo 所创建的作用域中有三个标识符:a、b、bar
  3. bar 所创建的作用域中有一个标识符:c

当执行console.log 声明并查找a、b和c 这三个变量的引用的时候,首先从最内部的作用域bar 函数所在的作用域开始找。无法找到a就会去上一级foo作用域中查找。

作用域查找会在找到第一个匹配的标识符时停止。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域只由函数被声明时所处的位置决定。

词法作用域只会查找一级标识符,也就是普通变量。如果代码中引用了foo.bar.baz,词法作用域只会试图查找foo标识符,找到后,对象属性访问规则会分别接管对bar和baz属性的访问。

二、欺骗词法

欺骗词法作用域会导致性能下降

1.eval

eval函数接受一个字符串为参数,并其中的内容视为好像在书写时就存在于程序中这个位置的代码。

function foo(str, a) { 
    eval( str ); // 欺骗! 
    console.log( a, b );
}
var b=2; 
foo("var b=3;",1);//1,3 

使用严格模式,可以使eval(…)中的声明无法修改所在的作用域。

function foo(str) { 
    "use strict";
    eval( str );
  console.log( a ); // ReferenceError: a is not defined
}
foo("var a = 2");

setTimeout(…) 还有 setInterval(…)和eval(…)很相似,它们的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码,这些功能已过时且并不提倡。

new Function(…)的最后一个参数可以接受代码字符串(前面的参数为这个新生成函数的形参),转化为动态生成的函数。这种构建函数的语法比eval(…)略微安全一些。

var foo = new Function('a','console.log(a)');
foo(3); //3

2.with 声明实际上是根据你传递给它的对象凭空创建一个全新的词法作用域

function foo(obj) { 
    with (obj) {
        a=2; 
    }
}
var o1 = { 
    a:3
};
var o2 = { 
    b:3
};
foo( o1 );
console.log( o1.a ); //2

foo( o2 );
console.log( o2.a ); // undefined

console.log( a ); // 2, a 被泄漏到全局作用域上了!

在with内部,我们写的代码看上去只是对变量a进行了简单的词法引用,实际上就是一个LHS引用(查看[[什么是作用域]])。

由于o2的作用域、foo(…)的作用域和全局作用域中都没有找到标识符a,因此当 a=2执行时,自动创建了一个全局变量。

在严格模式下with被完全禁止。

词法作用域和动态作用域的区别在于,词法作用域的定义过程发生在书写阶段,它关心的是代码中的作用域嵌套,而动态作用域不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。动态作用域的作用域链是基于调用栈的,而不是代码中作用域嵌套。JavaScript中的this机制某种程度上很像动态作用域。

2017/11/25 posted in  JavaScript