🚀《你不知道的javascript》(一)-作用域和闭包

680 阅读39分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

作用域和闭包

作用域是什么

一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。

作用域的使用提高了程序逻辑的局部性,增强程序的可靠性,减少名字冲突。

编译原理

JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能 比预想的要复杂。在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

Javascript的语法是为了给开发者更好的编程而设计的,但是不适合程序的理解。所以需要转化为AST来使之更适合程序分析,
浏览器编译器一般会把源码转化为AST来进行进一步的分析等其他操作。
  • 分词/词法分析(Tokenizing/Lexing)
  • 解析/语法分析(Parsing)
  • 代码生成

分词/词法分析(Tokenizing/Lexing)

将字符组成的字符串分解成有意义的代码块,分割的代码块被称为词法单元(token)。空格是否被分割是取决于空格对这门语言是否具有意义。

解析/语法分析(Parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。

例子:

// 通过 esprima , 把一个名字为ast的空函数的源码生成一颗AST树
var esprima = require('esprima');
var code = 'function ast(){}';
var ast = esprima.parse(code);
// 生成的抽象语法树
{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {
        "type": "Identifier",
        "name": "ast",
        "range": [
          9,
          12
        ]
      },
      "params": [],
      "body": {
        "type": "BlockStatement",
        "body": [],
        "range": [
          14,
          16
        ]
      },
      "generator": false,
      "expression": false,
      "range": [
        0,
        16
      ]
    }
  ],
  "sourceType": "module",
  "range": [
    0,
    16
  ]
}
// 通过 estraverse 遍历并且更新抽象语法树,把函数名称改为ast_awsome
...
var estraverse = require('estraverse');
estraverse.traverse(ast, {
    enter: function (node) {
        node.name += "_awsome";
    }
});
// 通过 escodegen 将AST重新生成为源码
...
var escodegen = require("escodegen");
var regenerated_code = escodegen.parse(ast)

AST三板斧:

  1. 通过 esprima 把源码转化为AST
  2. 通过 estraverse 遍历并更新AST
  3. 通过 escodegen 将AST重新生成源码

一般来说每个js引擎都会有自己的抽象语法树格式,Chrome的v8引擎,firefox的SpiderMonkey引擎等等,MDN提供了详细SpiderMonkey AST format的详细说明,算是业界的标准。

代码生成

将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。

抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指 令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素
进行优化等。

其它

  • JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
  • 对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。
  • 任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。

理解作用域

演员表

  • 引擎

    从头到尾负责整个 JavaScript 程序的编译及执行过程

  • 编译器

    引擎的好朋友之一,负责语法分析及代码生成等脏活累活

  • 作用域

    引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

对话

当我们写一个 var a = 2; 时,编译流程大概是这样的:

  1. 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看 1.3 节)。

     变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如 果之前没有声明过),
     然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对 它赋值。
      
    

编译器有话说

LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“= 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

小测验

检验一下到目前的理解程度。把自己当作引擎,并同作用域进行一次“对话”:

function foo(a) { 
    var b = a;
    return a + b; 
}

var c = foo( 2 );
  1. 找到其中所有的LHS查询。(这里有3处!)

    var cvar b、foo函数的隐式变量创建a

  2. 找到其中所有的RHS查询。(这里有4处!)

    foo(2)=a=2a+b

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

239A82AE-C827-436B-A231-0A155476CD7A.png 这个建筑代表程序中的嵌套作用域链。第一层楼代表当前的执行作用域,LHS 和 RHS 引用都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,如果还是没有找到就继续向上,以此类推。一旦抵达顶层(全局作用域),可能找到了你所需的变量,也可能没找到,但无论如何查找过程都将停止。

异常

如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。值得注意的是,ReferenceError 是非常重要的异常类型。 相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,

全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”下。在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。

如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作, 比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的 属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对 结果的操作是非法或不合理的。

小结

LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。

不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛 出 ReferenceError 异常(严格模式下)。

词法作用域

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法 作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域。

词法阶段

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

以下代码:

function foo(a) { 
    var b = a * 2;
    function bar(c) { 
        console.log( a, b, c );
    }
    bar( b * 3 ); 
}
foo( 2 ); // 2, 4, 12
  • 包含着整个全局作用域,其中只有一个标识符:foo。

  • 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b。

  • 包含着 bar 所创建的作用域,其中只有一个标识符:c。

查找

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

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

欺骗词法

JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能下降。(谨慎使用)

eval

JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。

在执行 eval(..) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。

考虑下方代码:

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

eval(..) 调用中的 "var b = 3;" 这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量 b,因此它对已经存在的 foo(..) 的词法作用域进行了修改。事实上,和前面提到的原理一样,这段代码实际上在 foo(..) 内部创建了一个变量 b,并遮蔽了外部(全局)作用域中的同名变量。

当 console.log(..) 被执行时,会在 foo(..) 的内部同时找到 a 和 b,但是永远也无法找到外部的 b。因此会输出“1, 3”而不是正常情况下会输出的“1, 2”。

当然这种情况也只是在非严格模式下讨论的,在严格模式中 eval 在运行时有自己的词法作用域,意味着其中声明的无法修改所在的作用域。

with

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 被泄漏到全局作用域上了!

当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console. log(o1.a) 中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性, o2.a 保持 undefined。

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

可以这样理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符, 因此进行了正常的 LHS 标识符查找(查看第一部分内容)。

o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行 时,自动创建了一个全局变量(因为是非严格模式)。

另外一个不推荐使用 eval(..) 和 with 的原因是会被严格模式所
影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接
或非安全地使用 eval(..) 也被禁止了。
性能

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段 基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..) 和 with。前者可以对一段包 含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在 运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作 用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们

函数作用域和块级作用域

函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。

也就是说如果是函数内部创建的变量其实只有函数内部和函数内部的子函数可以访问,函数外部是无法访问到对应的变量的。

function foo(a) { var b = 2;
    // 一些代码
    function bar() { 
        // ...
    }
    // 一些代码
    var c = 3;
}
bar() // 失败
console.log(a,b,c) // 也都是失败

隐藏内部实现

为什么说“隐藏”变量和函数是一个有用的技术?

有很多的原因促成了这种基于作用域的隐藏方法:它们大都是从最小特权原则中引申出来 的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确的代码应该是可以阻止对这些变量或函数进行访问的。

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;
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
doSomething( 2 ); // 15

规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。

  1. 全局命名空间 变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。

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

var MyReallyCoolLibrary = { 
    awesome: "stuff",
    doSomething: function() {
        // ... 
    },
    doAnotherThing: function() {
        // ...
    } 
};
  1. 模块管理 另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。

显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用 域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域 中,这样可以有效规避掉所有的意外冲突。

函数作用域

任意代码片段外部添加被函数包裹就可以把内部变量和函数定义“隐藏”起来,外部作用域无法访问包装函数。例如:

var a = 2;
function foo() { // <-- 添加这一行
    var a = 3; 
    console.log( a ); // 3
} // <-- 以及这一行 foo(); // <-- 以及这一行
console.log( a ); // 2

但是这样不是很理想,会导致一些额外的问题出现:

  • 必须声明一个有具名的函数foo,意味着foo这个名字其实已经污染了当前的作用域;
  • 必须通过函数的调用才能运行其中的代码;

JavaScript 提供了能够同时解决这两个问题的方案:

(function foo(){ // <-- 添加这一行 
    var a = 3;
    console.log( a ); // 3 
})(); // <-- 以及这一行 console.log( a ); // 2
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置
(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的
第一个词,那么就是一个函数声明,否则就是一个函数表达式。

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处

比较一下前面两个代码片段。第一个片段中 foo 被绑定在所在作用域中,可以直接通过 foo() 来调用它。第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。

匿名和具名

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

上方定时器中的函数就是匿名函数,因为function()...没鱼标识符。函数表达式可以匿名,但是函数声明不可以省略函数名--在JavaScript中这是非法的。 缺点:

  • 匿名函数在栈追踪中不会显示有意义的函数名,使得调用调试比较困难。

  • 没有函数名,当函数需要引用自身只能使用过期的argument.callee引用,比如在递归中,另一个函数需要引用自身的例子,实在事件触发后事件监听器需要解绑自身。

    arguments.callee:

    在函数内部,有两个特殊的对象:arguments 和 this。其中, arguments 的主要用途是保存函数参数, 但这个对象还有一个名叫 callee 的属性,该属性是一个指针,指向拥有这个 arguments 对象的函数。

function factorial(num){    
   if (num <=1) {         
      return 1;     
   } else {         
   return num * arguments.callee(num-1);
   } 
}
  • 匿名函数省略了对代码的可读性/可理解性很重要的函数名。一个描述性的函数名称可以让代码不言明意。始终给函数表达式命名是一个最佳的实践方式。

立即执行函数表达式

由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表达式,第二个 ( ) 执行了这个函数。

这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式 (Immediately Invoked Function Expression);

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

块级作用域

我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息 扩展为在块中隐藏信息。

开发者需要检查自己的代码,以避免在作用范围外意外地使用(或复用)某些变量,如果在错误的地方使用变量将导致未知变量的异常。变量 i 的块作用域(如果存在的话)将使得其只能在 for 循环内部使用,如果在函数中其他地方使用会导致错误。这对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助。

with

我们在前边讨论过with,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

try/catch

例如:

try {
    undefined(); // 执行一个非法操作来强制制造一个异常
}catch (err) {
    console.log( err ); // 能够正常执行! 
}
console.log( err ); // ReferenceError: err not found

尽管这个行为已经被标准化,并且被大部分的标准 JavaScript 环境(除了老 版本的 IE 浏览器)所支持,但是当同一个作用域中的两个或多个 catch 分句 用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告。 实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部, 但是静态检查工具还是会很烦人地发出警告。

为了避免这个不必要的警告,很多开发者会将 catch 的参数命名为 err1、 err2 等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。

let

ES6 改变了现状,引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {.. } 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。

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

但是使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。

垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一 下,而内部的实现原理,也就是闭包的机制会在后边具体详细解释。

function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
    console.log("button clicked");
}, /*capturingPhase=*/false );

click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。 块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:

function process(data) {
// 在这里做点有趣的事情
}

// 在这个块中定义的内容可以销毁了! 
{
let someReallyBigData = { .. }; 
process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false ); 
let 循环

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环 的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

for (let i=0; i<10; i++) { 
    console.log( i );
 }
console.log( i ); // ReferenceError

const

ES6 还引入了 const,同样可以用来创建块作用域变量,但是 const 的值是固定的,之后任何试图修改值都会引起错误。但是如果赋值变量是对象的话,修改对象的属性和内容是不受影响的。

提升

任何声明在某个作用域的变量,都将附属于这个作用域。

先有鸡还是先有蛋

在直觉上可能代码是从上到下一行一行的执行的,但实际上这个并不完全正确,有一种特殊情况会导致这种假设错误。

a = 2;
var a;
console.log( a ); // 会输出什么?

刚进入的同学可能会说是 undefined,因为 var a 声明在 a=2之后,但是真正输出的结果是 2。

console.log( a ); // 会输出什么?
var a = 2;

鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,你可能会认为这个代码片段也会有同样的行为而输出 2。还有人可能会认为,由于变量 a 在使用前没有先进行声明, 因此会抛出 ReferenceError 异常。

不幸的是两种猜测都是不对的。输出来的会是 undefined。接下来我们继续深入的了解下编译器具体的编译操作是什么样的。

编译器再度来袭

引擎会在解释 JavaScript 代码之前首先对其进行编译,编辑阶段中有部分工作就是找到所有的声明,并用合适的作用域来关联他们,上一章的展示的这个机制,也是此法作用域的核心内容。

正确的思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个 声明:var a;和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。在这个过程中变量和函数声明从他们的位置被移动到最上边,这个过程就是变量提升。

函数优先

函数声明和变量声明都会被提升,但是有一个值得注意的细节是,在多个重复声明的代码中,函数会首先被提升,然后才是变量。

foo(); // 1
var foo;

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

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

会输出1,而不是2!引擎解析的形式如下:

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

foo(); // 1

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

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。重复的var 声明会被忽略掉,但是重复的函数声明会覆盖前边的声明。

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:

foo(); // "b"

var a = true; 
if (a) {
    function foo() { console.log("a"); } }
else {
    function foo() { console.log("b"); }
}

但是需要注意这样的写法并不可靠,在 JavaScript 未来的版本中有可能发生改变,因此应该尽可能避免在块内部声明函数。

小结

我们习惯将var a = 2;看作一个声明,而实际上JavaScript引擎并不这么认为。它将var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。 要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!

作用域闭包

启示

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意 识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。

实质问题

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

例如:

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

foo();

这段代码看起来和嵌套作用域中的示例代码很相似。基于词法作用域的查找规则,函数 bar() 可以访问外部作用域中的变量 a(这个例子中的是一个 RHS 引用查询)。

这个从技术上讲也许是,但是从前边的定义,确切的来说并不是。我认为最准确的的解释是 bar() 对 a的引用的方法是词法作用域的查找规则。而这些规则只是闭包非常重要的一部分。

在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的闭包 (事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 被封闭在了 foo() 的作用域中。

通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后的神秘阴影里,并不那么容易理解。

下面我们来看一段代码,清晰地展示了闭包:


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

var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

函数 bar 作为 foo 的返回值进行传递,然后在 foo 执行后,函数 bar 作为返回值赋值给了变量 baz 并且调用 bar(),bar() 执行过后,它在定义的词法作用域以外的地方。

在foo()执行后,通常会真个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不在使用的内存空间,所以看上去foo()的内容不会再次使用,所以很自然的会考虑对其进行回收。

而闭包神器的地方就是可以阻止这件事情发生,事实上内部作用域依然存在,因此没有被回收,因为bar()本身还在使用。bar() 依然持有对该作用域的引用,而这个引用就叫做闭包。

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到 闭包。

function foo() { 
    var a = 2;

    function baz() { 
        console.log( a ); // 2
    }
    bar( baz ); 
}
function bar(fn) {
    fn(); // 妈妈快看呀,这就是闭包!
}

把内部函数 baz 传递给 bar,当调用这个内部函数时(现在叫作 fn),它涵盖的 foo() 内部作用域的闭包就可以观察到了,因为它能够访问 a。

传递函数也可以是间接的:

var fn;
function foo() {
    var a = 2;

    function baz() { 
        console.log( a );
    }

    fn = baz; // 将 baz 分配给全局变量 
}

function bar() {
    fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

现在我懂了

function wait(message) {
    setTimeout( function timer() {
        console.log( message );
    }, 1000 ); 
}
wait( "Hello, closure!" );

将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..) 作用域的闭包,因此还保有对变量 message 的引用。wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..)

作用域的闭包。

深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的 timer 函数,而词法作用域在这个过程中保持完整。

这就是闭包。

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

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

虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中 的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发现的。

循环和闭包

IIFE 会通过声明并立即执行一个函数来创建作用域。

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

这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。

如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。

可以对这段代码进行一些改进:


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

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

重返块作用域

我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。前边介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。本质上这是将一个块转换成一个可以被关闭的作用域。

for循环也可以写成这个样子:

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

模块

考虑以下代码:

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() { 
        console.log( something );
    }

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

    return {
        doSomething: doSomething,
        doAnother: doAnother
    }; 
}

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

这种模式 JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。

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

CoolModule() 返回一个用对象字面量语法 { key: value, ... } 来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共 API。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery 就是一个很好的例子。
jQuery 和 $ 标识符就是 jQuery 模块的公共 API,但它们本身都是函数(由于函数也是对象,
它们本身也可以拥有属性)。

doSomething() 和 doAnother() 函数具有涵盖模块实例内部作用域的闭包(通过调用 CoolModule() 实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。

模块模式的必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

模块也是普通的函数,可以接收参数的形式进行传递。

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

现代的模块机制

大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。如下代码:

var MyModules = (function Manager() {
    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 
    };
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。。为了模块的定义引入了包装 函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。

具体的创建定义模块如下:

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( hungry ).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

应该多花一点时间来研究这些示例代码并完全理解闭包的作用吧。最重要的是要理解模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。

换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

未来模块机制

ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的 API 成员,同样也可以导出自己的 API 成员。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来。
因此可以在运行时修改一个模块的 API(参考前面关于公共 API 的讨论)。
相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。
由于编辑器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否真实存在。
如果 API 引用并不存在,编译器会在运行时抛出一个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

ES6 的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览器或引擎有一个默认的“模块加载器”(可以被重载,但这远超出了我们的讨论范围)可以在导入模块时异步地加载模块文件。

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

模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

附录A

事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。

词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

附录B

至少从 ES3 发布以来,JavaScript 中就有了块作用域,而 with 和 catch 分句就是块作用域的两个小例子。 catch 分句具有块作用域,因此它可以在 ES6 之前的环境中作为块作用域的替代方案。

Traceur

Google 维护着一个名为 Traceur 的项目,该项目正是用来将 ES6 代码转换成兼容 ES6 之前的环境(大部分是 ES5,但不是全部)。TC39 委员会依赖这个工具(也有其他工具)来测试他们指定的语义化相关的功能。

隐式和显式作用域

考虑下面这种 let 的使用方法,它被称作 let 作用域或 let 声明(对比前面的 let 定义)。

let (a = 2) {
    console.log( a ); // 2
}
console.log( a ); // ReferenceError

同隐式地劫持一个已经存在的作用域不同,let 声明会创建一个显示的作用域并与其进行绑定。显式作用域不仅更加突出,在代码重构时也表现得更加健壮。在语法上,通过强制性地将所有变量声明提升到块的顶部来产生更简洁的代码。这样更容易判断变量是否属于某个作用域。

性能

try/catch 的性能的确很糟糕,但技术层面上没有合理的理由来说明 try/catch 必须这么慢,或者会一直慢下去。自从 TC39 支持在 ES6 的转换器中使用 try/catch 后, Traceur 团队已经要求 Chrome 对 try/catch 的性能进行改进,他们显然有很充分的动机来做这件事情。

其次,IIFE 和 try/catch 并不是完全等价的,因为如果将一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的 this、return、break 和 contine 都会发生变化。IIFE 并不是一个普适的解决方案,它只适合在某些情况下进行手动操作。