闭包真的是一个谈烂掉的内容。说到闭包,自然就涉及到执行环境、变量对象以及作用域链。 汤姆大叔翻译的《深入理解JavaScript系列》很好,帮我解决了一直以来似懂非懂的很多问题,包括闭包。下面就给自己总结一下。包括参考大叔的译文 以及《JavaScript高级程序设计(第3版)》,一些例子引用自它们。 附上大叔的链接:《深入理解JavaScript系列》
首先说下ECMAScript可执行代码的类型包括:全局代码、函数代码、eval_r()代码。 每当执行流转到可执行代码时,即会进入一个执行环境。活动的执行环境构成一个栈:栈的底部始终是全局环境,顶部是当前活动的执行环境。 全局执行环境是最外围的一个执行环境。在浏览器中,全局环境就是window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。 每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境被推入栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环 境。某个执行环境中的代码执行完后,该环境销毁,保存在其中的所有变量和函数定义也随之销毁。而全局执行环境直到应用程序退出才会被销毁。 eval的执行环境与调用环境的执行环境相同。
我们知道变量和执行环境有着密切的关系: var a = 10; // 全局上下文中的变量 (function () { var b = 20; // function上下文中的局部变量 })(); alert(a); // 10 alert(b); // 全局变量 "b" 没有声明 而且我们也知道在JS里没有块级作用域这一说法,ES规范指出独立作用域只能通过函数(function)代码类型的执行环境创建。也就是说,像for循环并不能创建一个局部环境: for (var k in {a: 1, b: 2}) { alert(k); } alert(k); // 尽管循环已经结束但变量k依然在当前作用域 既然变量与执行环境相关,那变量自己应该知道它的数据存放在哪里,并知道如何访问。这就引出了“变量对象”这个概念。 每个执行环境都有一个与之关联的变量对象,这个对象存储着在环境中定义的以下内容: 1. 函数的形参 举例来说,用一个普通对象来表示变量对象,它是执行环境的一个属性: 执行环境 = { 变量对象:{ //环境中的数据 } }; 例如: 对应的变量对象为: // 全局执行环境的变量对象 全局环境的变量对象= { a: 10, test: 指向test()函数 }; // test函数执行环境的变量对象 test函数环境的变量对象 = { x: 30, b: 20 }; 全局环境中的变量对象 先看下全局对象的明确定义: 全局对象 是在进入任何执行环境之前就已经创建了的对象。 全局对象初始创建阶段,将Math、String等作为自身属性,初始化如下: 在这里,变量对象就是全局对象自己。 函数环境中的变量对象 在函数执行环境中,“活动对象” 扮演着变量对象这个角色。活动对象是在进入函数执行环境时创建的,它通过函数的arguments属性初始化: 活动对象 = { arguments: //是个对象,包括callee、length等属性 }; 理解了变量对象的初始化之后,下面就是关于变量对象的核心了。 环境中的代码,被分为两个阶段来处理:进入执行环境 、执行代码。变量对象的修改变化与这两个阶段紧密相关。 这2个阶段的处理是一般行为,和环境的类型无关(即,在全局环境和函数环境中的表现是一样的)。
①进入环境 当进入执行环境时(代码执行之前),变量对象已包含下列属性(上面有提到): ①函数的所有形参(如果是在函数执行环境中。因为全局环境没有形参。)
让我们来看一个例子: function test(a, b) { alert(c); //undefined alert(d); //function d() {} alert(e); //undefined alert(x); //出错 var c = 10; function d() {} var e = function _e() {}; (function x() {}); } test(10); // 注意,活动对象里不包含函数x。这是因为x是一个函数表达式而不是函数声明,函数表达式不会影响变量对象(在这里是活动对象)。函数_e同样是函数表达式,但是我们注意到它分配给了变量e,所以可以通过名称e来访问。 在这之后,将进入处理代码的第二个阶段:执行代码。 ②执行代码 这个阶段内,变量/活动对象已经拥有了属性(不过,并不是所有属性都有值,就像上面那个例子,大部分属性的值还是系统默认的undefined)。 继续上面那个例子,活动对象在“执行代码”这个阶段被修改如下(): AO(test) = { a: 10, b: undefined, //没有相应该参数传入,undefined c: 10, //之前是undefined d: 指向函数d, e: 指向函数表达式_e //之前是undefined };
注意此时,函数表达式_e保存到了已声明的变量e上,但函数表达式"x"本身不存在于活动对象中,也就是说,如果尝试调用函数"x",无论在函数定义之前或之后,都会出现 理解了以上内容之后,再来看一个例子: 为什么第一个alert(x)的值是function,而且它还是在x声明之前访问的x?为什么不是10或20呢? 现在我们知道,函数声明是在进入环境时填入活动对象的,同一时间,还有一个变量声明'x',但是正如前面所说,变量声明在顺序上跟在函数声明和形参声明之后。即,在进入环境阶段,变量声明不会干扰变量对象中已经存在的同名函数或形参声明。所以,就这个例子来说,在进入环境时,变量对象的结构如下: 变量对象 = { x:指向函数x //如果function x没有已经声明的话,这时的x应该是undefined }; 紧接着,在代码执行阶段,变量对象作如下修改: 变量对象['x'] = 10; 变量对象['x'] = 20; //可以在第二、三个alert看到这个结果 再看一个例子: if (true) { var a = 1; } else { var b = 2; } //变量是在进入环境阶段放入变量对象的,虽然else部分永远不会执行, //但是不管怎样,变量b仍然存在于变量对象中。 alert(a); //1 alert(b); //undefined,不是b未声明,而是b的值是undefined 另外,关于var声明变量和不用var声明: 大叔的译文中指出:任何时候,变量只能通过var关键字才能声明。 像a = 10;这仅仅是给全局对象创建了一个新属性(但它不是变量)。它之所以能成为全局对象的属性,完全是因为全局对象===全局变量对象。看例子: alert(a); // undefined alert(b); // "b" 没有声明,出错 b = 10; var a = 20; 进入环境阶段: 变量对象 = { a: undefined }; 可以看到,因为b不是一个变量,所以在这个阶段根本就没有b,b将只在代码执行阶段才会出现,但在这里,还未执行到那就出错了。 还有一个要注意的:var声明的变量,相对于属性(如a = 10;或window.a = 10;),变量的[[Configurable]]特性值为false,即不能通过delete删除,而属性则可以。
现在我们已经知道,一个执行环境的数据(变量、函数声明和函数形参)作为属性存储在变量对象中。 同时也知道,变量对象在每次进入环境时创建,并填入初始值,值的更新出现在代码执行阶段。 下面的内容讨论作用域链。 如果要简要地描述并展示其重点,那么作用域链大多数与内部函数相关。 我们可以创建内部函数,甚至能从父函数中返回这些函数。 var x = 10; function foo() { var y = 20; function bar() { alert(x + y); } return bar; } foo()(); // 30 很明显每个环境拥有自己的变量对象:对于全局环境,它是全局对象自身;对于函数,它是活动对象。 作用域链正是内部环境所有变量对象(包括父变量对象)的列表。此链用来在标识符解析中变量查找。 作用域链本质上,是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。 对于上面这个例子,bar执行环境中的作用域链包括:bar变量对象、foo变量对象、全局变量对象。 函数执行环境中的作用域链在函数调用时创建,包含这个函数的活动对象和函数的[[scope]]属性。示例如下: 其中的Scope定义为:Scope = 被调用函数的活动对象 + [[scope]]。 这种标识符的解析过程,与函数的生命周期相关,下面详细讨论。 (1)函数的生命周期 函数的生命周期分为创建和激活(调用时)两个阶段。 函数创建 让我们先看看在全局环境中的变量和函数声明(这里的变量对象就是全局对象自身,我们懂的。) 函数激活时,得到了正确的也是预期中的结果。但我们注意到,变量y在函数foo中定义(意味着它在foo的活动对象中),但是x并未在foo环境中 定义,相应地,它不会添加到foo的活动对象中。那么,foo是如何访问到变量x的?其实我们大都知道函数能访问更高一层的环境中的变量对象,事实也是如 此,而这种机制正是通过函数内部的[[scope]]属性实现的。 [[scope]]是所有父变量对象的层级链,处于当前函数环境,在函数创建时存在于其中。 注意重要的一点:[[scope]]属性在函数创建时被存储,永远不变,直到函数销毁。函数可以不被调用,但这个属性一直存在。 上面的例子,函数foo的[[scope]]如下: foo.[[Scope]] = [ 全局执行环境.变量对象 // === Global ]; 继续,我们知道在函数调用时进入执行环境,这时活动对象被创建,this、作用域链被确定。下面详细考虑这个时刻。 函数激活 正如上面提到的,进入环境创建变量/活动对象之后,环境的Scope属性(即作用域链)定义为:Scope = 变量/活动对象 + [[scope]]。 这个定义意思是:活动对象是被添加到[[scope]]前端,在作用域链中处理第一位。这很重要,对于标识符的查找,是从自身变量对象开始的,逐渐往父变量对象查找。 (2)通过构造函数创建的函数的[[scope]] 在上面的例子中,我们看到,在函数创建时,函数获得[[scope]]属性,该属性存储着所有父环境的变量/活动对象。但有一个例外,那就是通过构造函数创建的函数。 var x = 10; function foo() { var y = 20; function barFD() { // 函数声明 alert(x); alert(y); } var barFE = function () { // 函数表达式 alert(x); alert(y); }; var barFn = Function('alert(x); alert(y);'); barFD(); // 10, 20 barFE(); // 10, 20 barFn(); // 10, "y" is not defined } foo(); 从以上例子中,我们看出问题所在:通过构造函数创建的函数,它的[[scope]]仅包含全局对象。 另外关于eval,实践中很少用到eval,但有一点提示,eval代码的环境与当前的调用环境拥有相同的作用域链。 (3)延长作用域链 有两个能延长作用域链的方法:with声明和catch语句。它们添加到作用域链的最前端(比被调用函数的活动对象还要靠前)。 如果发生其中一个,作用域链作如下修改: Scope = withObject|catchObject +活动/变量对象 + [[Scope]] 看个例子: var x = 10, y = 10; with ({x: 20}) { var x = 30, y = 30; alert(x); // 30 alert(y); // 30 } alert(x); // 10 alert(y); // 30
到了这里,其实如果对前面的[[scope]]和作用域链完全理解的话,闭包也就懂了。 大叔的译文对闭包给出的2个定义是: 从理论角度:所有函数都是闭包。因为它们在创建的时候就将所有父环境的数据保存起来了。哪怕是简单的全局变量也是如此,因为在函数中访问全局变量就相当于在访问自由变量(指不在参数声明,也不在局部声明的变量),这个时候使用最外层的作用域。 从实践角度:以下函数才算是闭包: 下面我们再来具体看一下。 var x = 10; function foo() { alert(x); } (function (funArg) { var x = 20; // 变量"x"在foo中静态保存的,在该函数创建的时候就保存了 funArg(); // 10, 而不是20 })(foo); 我们已经知道,创建foo函数的父级环境(在这里是全局环境)的数据是保存在foo函数的内部属性[[scope]]中的。 这里还要注意的是:同一个父环境创建的闭包是共用一个[[scope]]属性的。也就是说,某个闭包对其中[[scope]]的变量的修改会影响到其他闭包对其变量的读取。 var firstClosure; var secondClosure; function foo() { var x = 1; firstClosure = function () { return ++x; }; secondClosure = function () { return --x; }; x = 2; // 影响"x", 在2个闭包公有的[[Scope]]中 alert(firstClosure()); // 3, 通过第一个闭包的[[Scope]] } foo(); alert(firstClosure()); // 4 alert(secondClosure()); // 3 关于这个问题,大叔的译文和《JS高级》里都有一个例子: var data = []; for (var k = 0; k < 3; k++) { data[k] = function () { alert(k); }; } data[0](); // 3, 而不是0 data[1](); // 3, 而不是1 data[2](); // 3, 而不是2 这就是闭包共用一个[[scope]]的问题。可以按下面的方法解决: var data = []; for (var k = 0; k < 3; k++) { data[k] = (function _helper(x) { return function () { alert(x); }; })(k); // 传入"k"值 } // 现在结果是正确的了 data[0](); // 0 data[1](); // 1 data[2](); // 2 在上例中,每次_helper都会创建一个新的变量对象,其中含有参数x,其值就是传递进来的k值。此时,返回的函数的[[scope]]如下: data[0].[[Scope]] === [ ... // 其它变量对象 父级环境中的活动对象: {data: [...], k: 3}, _helper环境中的活动对象: {x: 0} ]; data[1].[[Scope]] === [ ... // 其它变量对象 父级环境中的活动对象: {data: [...], k: 3}, _helper环境中的活动对象: {x: 1} ]; data[2].[[Scope]] === [ ... // 其它变量对象 父级环境中的活动对象: {data: [...], k: 3}, _helper环境中的活动对象: {x: 2} ]; 要注意的是,如果在返回的函数中,要获取k值,那么该值还会是3。 |
|
来自: 瞻云轩 > 《javascript》