(译)学习JavaScript闭包
什么是闭包?
1 function numberGenerator() { 2 // 闭包里的局部“自由”变量 3 var num = 1; 4 function checkNumber() { 5 console.log(num); 6 } 7 num++; 8 return checkNumber; 9 } 10 11 var number = numberGenerator(); 12 number(); // 2
在上面的例子中,numberGenerator函数创建了一个局部的“自由”变量num (数字变量)和checkNumber (把num打印在命令窗口的函数)。checkNumber 函数中没有定义局部变量——然而,它可以访问父函数(numberGenerator)里定义的变量,这就是因为闭包。因此,它可以使用定义在numberGenerator函数里的num变量,并成功地把它输出在命令窗口,即便是在numberGenerator函数返回之后依然如此。
1 function sayHello() { 2 var say = function() { console.log(hello); } 3 // Local variable that ends up within the closure 4 var hello = 'Hello, world!'; 5 return say; 6 } 7 var sayHelloClosure = sayHello(); 8 sayHelloClosure(); // ‘Hello, world!’
注意变量hello是如何在匿名函数后定义的,但这个匿名函数依然可以访问hello变量,这是因为hello变量被创建时已经定义在函数“作用域”里了,这使得当匿名函数最终执行时,hello变量依然可用。(别急,我将随后在这篇文章中解释什么是“作用域”,现在,就让我们来看看)
高层次的理解
执行上下文
1 var x = 10; 2 function foo(a) { 3 var b = 20; 4 5 function bar(c) { 6 var d = 30; 7 return boop(x + a + b + c + d); 8 } 9 10 function boop(e) { 11 return e * -1; 12 } 13 14 return bar; 15 } 16 17 var moar = foo(5); // Closure 18 /* 19 The function below executes the function bar which was returned 20 when we executed the function foo in the line above. The function bar 21 invokes boop, at which point bar gets suspended and boop gets push 22 onto the top of the call stack (see the screenshot below) 23 */ 24 moar(15);
- 代码评估状态:执行、暂停和恢复与此执行上下文相关的代码的任何状态。
- 函数:该执行上下文正在评估的函数对象。(如果被评估的上下文是脚本或模块,则为null)
- 领域:一组内部对象,ECMAScript全局环境,在该全局环境范围内加载的所有ECMAScript代码,以及其他关联的状态和资源。
- 词法环境: 用来解析该执行上下文中的代码所作的标识符引用。
- 变量环境:词法环境,环境记录保存由该执行上下文中的变量状态创建的绑定。
词法环境
- “用于定义标识符的关联”:词法环境的目的是用来管理代码里的数据(如标识符),换句话说,它使得标识符有意义。例如,如果我们有一行代码“console.log(x / 10)”,如果变量(或“标识符”)x没有任何含义,那么这行代码就没有任何意义了。词法环境就是通过它的环境记录来提供意义(或“关联”)。
- “词法环境由环境记录组成”:环境记录是用一种奇特的方式来描述它是保存了所有标识符和它们在词法环境里的绑定的记录。每个词法环境都有各自的环境记录。
- “词法嵌套结构”:这是最有意思的部分,这个基本上说是它的内部环境引用它的外部环境,而它的外部环境也一样可以有它的外部环境,所以,一个环境可以是多个内部环境的外部环境。全局环境是唯一一个没有外部环境的词法环境,这就是JS的棘手之处,我们可以用洋葱的皮层来表示词法环境:全局环境就是洋葱最外层的皮层,每一个子层都嵌套在它里面。
1 LexicalEnvironment = { 2 EnvironmentRecord: { 3 // Identifier bindings go here 4 }, 5 6 // Reference to the outer environment 7 outer: < > 8 };
- “每次执行这样的代码就会创建一个新的词法环境”:每次一个封闭的外部函数被调用时,就会创建一个新的词法环境,这一点很重要——我们在文章最后将会再说到这点。(边注:函数不是唯一可以创建词法环境的方式,块语句和catch子句也可以创建词法环境,为了简单起见,在这篇文章中我们将只说函数创建的环境。)
作用域链
1 var x = 10; 2 3 function foo() { 4 var y = 20; // free variable 5 function bar() { 6 var z = 15; // free variable 7 return x + y + z; 8 } 9 return bar; 10 }
就像你所看到的,bar就是嵌套在foo里,为你帮你视觉化嵌套,请看下图:
动态作用域 VS 静态作用域
1 var x = 10; 2 3 function foo() { 4 var y = x + 5; 5 return y; 6 } 7 8 function bar() { 9 var x = 2; 10 return foo(); 11 } 12 13 function main() { 14 foo(); // Static scope: 15; Dynamic scope: 15 15 bar(); // Static scope: 15; Dynamic scope: 7 16 return 0; 17 }
当bar函数被调用时,我们可以看到上面的动态作用域和静态作用域返回了不同的值。
var myVar = 100; function foo() { console.log(myVar); } foo(); // Static scope: 100; Dynamic scope: 100 (function () { var myVar = 50; foo(); // Static scope: 100; Dynamic scope: 50 })(); // Higher-order function (function (arg) { var myVar = 1500; arg(); // Static scope: 100; Dynamic scope: 1500 })(foo);
同样,在动态作用域的例子,上面的myVar变量在使用了myVar变量的函数被调用的地方解析。另一方面,在静态作用域里,将myVar解析为在创建两个IIFE函数的范围内保存的变量 。
闭包
var x = 10; function foo() { var y = 20; // free variable function bar() { var z = 15; // free variable return x + y + z; } return bar; } var test = foo(); test(); // 45
基于我们对环境是如何工作的认识,我们可以说,上面例子中定义的环境看起来是这样的(注意,这个完全是伪代码):
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc.. // custom identifiers x: 10 }, outer: null }; fooEnvironment = { EnvironmentRecord: { y: 20, bar: '<func>' } outer: GlobalEnvironment }; barEnvironment = { EnvironmentRecord: { z: 15 } outer: fooEnvironment };
var result = []; for (var i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 5, expected 0 result[1](); // 5, expected 1 result[2](); // 5, expected 2 result[3](); // 5, expected 3 result[4](); // 5, expected 4
回到我们刚才所学的,我们就可以轻而易举就发现其中的错误所在!绝对,当for循环结束后,它这里的环境就像下面的一样:
environment: { EnvironmentRecord: { result: [...], i: 5 }, outer: null, }
这里错误的假想在作用域,以为结果数组中五个函数的作用域是不一样的,然而,事实上结果数组中五个函数的环境(或者/上下文/作用域)是一样的,因此,变量i每增加一次,它就更新了作用域里的值——这个作用域里的值是被所有函数共享的。这就是为什么五个函数中的任意一个去访问i时都返回5的原因(当for循环结束时,i等于5)。
var result = []; for (var i = 0; i < 5; i++) { result[i] = (function inner(x) { // additional enclosing context return function() { console.log(x); } })(i); } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
对!这样就可以了:)
var result = []; for (let i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
例2:
function iCantThinkOfAName(num, obj) { // This array variable, along with the 2 parameters passed in, // are 'captured' by the nested function 'doSomething' var array = [1, 2, 3]; function doSomething(i) { num += i; array.push(num); console.log('num: ' + num); console.log('array: ' + array); console.log('obj.value: ' + obj.value); } return doSomething; } var referenceObject = { value: 10 }; var foo = iCantThinkOfAName(2, referenceObject); // closure #1 var bar = iCantThinkOfAName(6, referenceObject); // closure #2 foo(2); /* num: 4 array: 1,2,3,4 obj.value: 10 */ bar(2); /* num: 8 array: 1,2,3,8 obj.value: 10 */ referenceObject.value++; foo(4); /* num: 8 array: 1,2,3,4,8 obj.value: 11 */ bar(4); /* num: 12 array: 1,2,3,8,12 obj.value: 11 */
在这个例子里,我们可以看到每次调用iCantThinkOfAName函数时都会创建一个新的闭包,也就是foo和bar。后续调用每个闭包函数都会更新闭包内的变量,这展示了iCantThinkOfAName函数返回后,每个闭包里的变量继续被iCantThinkOfAName函数里的doSomething函数所使用。
function mysteriousCalculator(a, b) { var mysteriousVariable = 3; return { add: function() { var result = a + b + mysteriousVariable; return toFixedTwoPlaces(result); }, subtract: function() { var result = a - b - mysteriousVariable; return toFixedTwoPlaces(result); } } } function toFixedTwoPlaces(value) { return value.toFixed(2); } var myCalculator = mysteriousCalculator(10.01, 2.01); myCalculator.add() // 15.02 myCalculator.subtract() // 5.00
我们能够看到的是mysteriousCalculator是在全局作用域里,而且它返回了两个函数。抽象来看,上面例子中的环境就像是这样的:
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc... // custom identifiers mysteriousCalculator: '<func>', toFixedTwoPlaces: '<func>', }, outer: null, }; mysteriousCalculatorEnvironment = { EnvironmentRecord: { a: 10.01, b: 2.01, mysteriousVariable: 3, } outer: GlobalEnvironment, }; addEnvironment = { EnvironmentRecord: { result: 15.02 } outer: mysteriousCalculatorEnvironment, }; subtractEnvironment = { EnvironmentRecord: { result: 5.00 } outer: mysteriousCalculatorEnvironment, };
因为我们的add和subtract函数都有一个指向mysteriousCalculator函数环境的引用,它们可以使用那个环境里的变量来计算结果。
function secretPassword() { var password = 'xh38sk'; return { guessPassword: function(guess) { if (guess === password) { return true; } else { return false; } } } } var passwordGame = secretPassword(); passwordGame.guessPassword('heyisthisit?'); // false passwordGame.guessPassword('xh38sk'); // true
这是一个很强大的技巧——它使得闭包函数guessPassword可以独占访问password变量,同时让password变量不能从外部访问。
摘要
- 执行上下文是ECMAScript规范用来根据运行时代码执行的一个抽象概念。在任何时候,在代码执行时都只有一个执行上下文。
- 每个执行上下文都有一个词法环境,这个词法环境保留着标识符绑定(如变量及其相关的值),同时还有一个指向它外部环境的引用。
- 每个环境都可以访问的标识符集称为“作用域”。我们可以嵌套这些作用域到层次环境链中,这就是“作用域链”。
- 每个函数都有一个执行上下文,它由一个给予函数里的变量意义的词法环境,和指向父环境的引用组成,这看起来就像是函数“记住”这个环境(或者作用域),因为函数事实上有一个指向这个环境的引用,这就是闭包。
- 每次一个封闭外部函数被调用时就会创建一个闭包,换句话说,内部函数不需要返回要创建的闭包。
- JavaScript里的闭包作用域就是词法,这意味着它是在源代码里的位置静态定义的。
- 闭包用许多实际的用处,最重要的一个用处是维护一个私有指向外部环境变量的引用。
结束语
延伸阅读
- 执行环境里的变量环境是什么?Axel Rauschmayer博士对这个问题做了解释,所以我把它的博客文章链接放在这里: http://www.2ality.com/2011/04/ecmascript-5-spec-lexicalenvironment.html
- 各种环境记录有什么不同?http://www.ecma-international.org/ecma-262/6.0/#sec-environment-records
- MDN上关于闭包的一片优秀的文章: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures