(译)学习JavaScript闭包

时间:2021-07-26 15:17:57
 
闭包是JavaScript中一个基础概念,这是每个严格意思上的程序员都应该十分熟悉的。
 
网络上有很多文章介绍闭包是什么,但是很少有文章深入讲解为什么是这样的。
 
我发觉从根本上去理解一种技术,能够使开发人员去熟练地使用他们所掌握的工具,所以这篇文章致力于从细节上去讲解闭包内部原理是怎么样的,以及为什么是这样的。
 
希望在你以后的日常工作中,能够更好的运用闭包的优势。那我们开始吧!
 

什么是闭包?

闭包是JavaScript(和大多数编程语言)中一个强大的特性。MDN对闭包的定义是:
闭包是指向独立(*)变量的函数,换句话说,定义在闭包里的函数“记住”了它创建时的环境。
 
注:*变量是指那些既不是局部变量,也不是作为参数传递的变量。
 
我们看几个例子:
 
例1:
 function numberGenerator() {
// 闭包里的局部“*”变量
var num = 1;
function checkNumber() {
console.log(num);
}
num++;
return checkNumber;
} var number = numberGenerator();
number(); //
在上面的例子中,numberGenerator函数创建了一个局部的“*”变量num (数字变量)和checkNumber (把num打印在命令窗口的函数)。checkNumber 函数中没有定义局部变量——然而,它可以访问父函数(numberGenerator)里定义的变量,这就是因为闭包。因此,它可以使用定义在numberGenerator函数里的num变量,并成功地把它输出在命令窗口,即便是在numberGenerator函数返回之后依然如此。 
 
例2:
在这个例子中,我们将演示一个闭包里包含了所有定义在其父函数内部定义的局部变量。
 function sayHello() {
var say = function() { console.log(hello); }
// Local variable that ends up within the closure
var hello = 'Hello, world!';
return say;
}
var sayHelloClosure = sayHello();
sayHelloClosure(); // ‘Hello, world!’

注意变量hello是如何在匿名函数后定义的,但这个匿名函数依然可以访问hello变量,这是因为hello变量被创建时已经定义在函数“作用域”里了,这使得当匿名函数最终执行时,hello变量依然可用。(别急,我将随后在这篇文章中解释什么是“作用域”,现在,就让我们来看看) 

 

高层次的理解

 
这两个例子从一个高层次来阐述了“什么”闭包。一般的主题是这样的——我们可以访问定义在封闭函数里的变量,即使这些封闭函数定义在变量返回之后。显然,在这背后肯定做了一些其它的事情,这些事使得这些变量即使在其后的封闭函数返回之后还可以访问。
 
为了理解这是怎么实现的,我们需要去接触一些相关的概念——我们将从更高的层次一步步走向闭包。让我们从一个函数运行的全局上下文开始,即所谓的“执行上下文”。
 

执行上下文

 
执行上下文是ECMAScript规范使用的抽象概念,用于跟踪代码的运行时评估。这可以是你代码首先执行时的全局上下文,或者是当执行到一个函数体时的上下文。
 
(译)学习JavaScript闭包
 
在任意一个时间点,都只能运行一个执行上下文,这就是为什么JavaScript是“单线程”的,这就意味着每次只能有一条命令被处理。通常,浏览器使用“栈”来维护这个执行上下文,栈是后进先出的数据结构,这意味着最后压进栈的是最先被弹出来的(这是因为我们只能在栈顶插入或删除元素)。当前或“正在运行的”执行上下文总是在栈顶的,当运行执行上下文的代码被完全执行后,它就从栈顶弹出,这就允许下一个顶部项接管运行执行上下文。
 
而且,仅仅因为执行上下文正在运行,并不意味着它必须在不同的执行上下文运行之前完成运行;有时,运行执行上下文被挂起,不同的执行上下文成为运行的执行上下文,被挂起的执行上下文可能会在以后的某个点上重新回到它被挂起的位置,在任何时刻,一个执行上下文就这样被其它执行上下文替代,一个新的执行上下文被创建,并压到栈里,成为当前执行上下文。
 
(译)学习JavaScript闭包
 
在浏览器里用实际的例子来说明这个概念,请看下面这个例子:
 var x = 10;
function foo(a) {
var b = 20; function bar(c) {
var d = 30;
return boop(x + a + b + c + d);
} function boop(e) {
return e * -1;
} return bar;
} var moar = foo(5); // Closure
/*
The function below executes the function bar which was returned
when we executed the function foo in the line above. The function bar
invokes boop, at which point bar gets suspended and boop gets push
onto the top of the call stack (see the screenshot below)
*/
moar(15);
(译)学习JavaScript闭包 
 
 
当boop 返回时,它会弹出栈顶,而bar 复原:
 
(译)学习JavaScript闭包
 
当我们有一串执行上下文一个接一个运行时——通常一个执行上下文在中间被暂停,然后又会被恢复——我们需要一种方式来跟进这个状态的变化,所以我们可以管理这些执行这些上下文的顺序,实际上就是这样的。根据ECMAScript规范,每个执行上下文有各种状态组件,用于记录每个上下文中的代码的进展情况。这包括:
  • 代码评估状态:执行、暂停和恢复与此执行上下文相关的代码的任何状态。
  • 函数:该执行上下文正在评估的函数对象。(如果被评估的上下文是脚本或模块,则为null)
  • 领域:一组内部对象,ECMAScript全局环境,在该全局环境范围内加载的所有ECMAScript代码,以及其他关联的状态和资源。
  • 词法环境: 用来解析该执行上下文中的代码所作的标识符引用。
  • 变量环境:词法环境,环境记录保存由该执行上下文中的变量状态创建的绑定。
如果这听起来让你很迷惑,不要担心,所有这些变量,词法环境变量对我们来说是最有意思的变量,因为它显示声明,它解析该执行上下文中的代码所作的“标识符引用”。你可以认为“标识符”就是变量。因为我们最初的目的是弄清楚,它是怎么去访问那些即使函数(或“上下文”)已经返回的变量,词法环境看起来就是我们应该去深究的东西。
 
注意:从技术上说,通过使用变量环境和词法环境一起来实现闭包,但是为了简单起见,我们将统一用“环境”来表示,对于词法环境和变量环境间的不同处的细节解释,可以查看 Alex Rauschmayer’s博士的article
 

词法环境

 
定义:词法环境是一种规范类型,用于根据ECMAScript代码的词汇嵌套结构定义标识符与特定变量和函数的关联。词汇环境由一个环境记录和一个指向外部词汇环境的可能为空的引用组成。通常,词汇环境与ECMAScript代码的某些特定的语法结构相关联,比如函数声明、块语句或异常捕获语句,以及每次执行这些代码时,都会创建一个新的词法环境。
 
让我们来分开解释下:
  • “用于定义标识符的关联”:词法环境的目的是用来管理代码里的数据(如标识符),换句话说,它使得标识符有意义。例如,如果我们有一行代码“console.log(x / 10)”,如果变量(或“标识符”)x没有任何含义,那么这行代码就没有任何意义了。词法环境就是通过它的环境记录来提供意义(或“关联”)。
  • “词法环境由环境记录组成”:环境记录是用一种奇特的方式来描述它是保存了所有标识符和它们在词法环境里的绑定的记录。每个词法环境都有各自的环境记录。
  • “词法嵌套结构”:这是最有意思的部分,这个基本上说是它的内部环境引用它的外部环境,而它的外部环境也一样可以有它的外部环境,所以,一个环境可以是多个内部环境的外部环境。全局环境是唯一一个没有外部环境的词法环境,这就是JS的棘手之处,我们可以用洋葱的皮层来表示词法环境:全局环境就是洋葱最外层的皮层,每一个子层都嵌套在它里面。
(译)学习JavaScript闭包
 
 
抽象地说,用伪代码来描述环境它看起来就是这样的:
 LexicalEnvironment = {
EnvironmentRecord: {
// Identifier bindings go here
}, // Reference to the outer environment
outer: < >
};
 
  • “每次执行这样的代码就会创建一个新的词法环境”:每次一个封闭的外部函数被调用时,就会创建一个新的词法环境,这一点很重要——我们在文章最后将会再说到这点。(边注:函数不是唯一可以创建词法环境的方式,块语句和catch子句也可以创建词法环境,为了简单起见,在这篇文章中我们将只说函数创建的环境。
总之,每一个执行上下文都有一个词法环境,这个词法环境包含了变量和其相关的值,以及对它外部环境的引用。词法环境可以是全局环境、模块环境(它包含对模块顶层声明的绑定),或者函数环境(由于调用函数创建的环境)
 
 

作用域链

 
基于上面的定义,我们知道一个环境可以访问它的父环境,它的父环境也可以访问它的父环境,依次类推。每个环境都可以访问的这个标识符集称为“作用域”。我们可以嵌套作用域到一个层次环境链里,这就是我们所知道的“作用域链”。
 
我们来看一个嵌套结构的例子:
 var x = 10;

 function foo() {
var y = 20; // free variable
function bar() {
var z = 15; // free variable
return x + y + z;
}
return bar;
}
就像你所看到的,bar就是嵌套在foo里,为你帮你视觉化嵌套,请看下图: 
 
(译)学习JavaScript闭包
 
我们在文章后面再回顾一下这个例子。
 
作用域链或者一个函数相关的环境链,是在创建时保存在这个函数对象。它是由源代码中的位置静态定义的。(这就是我们熟知的“词法作用域”)
 
让我们快速地了解一下“动态作用域”和“静态作用域”的不同之处,这将帮助我们理解为了实现闭包, 为什么静态作用域(或者词法作用域)是必须存在的。
 

动态作用域 VS 静态作用域

 
动态作用域语言具有“基于栈的实现”,这意味着局部变量和函数参数被存放在堆栈里,因此,程序堆栈的运行时状态决定了你所引用的变量。
 
另一方面,静态范围是根据创建的时间来记录在上下文中,换句话说,程序源代码的结构决定了你所引用的变量。
 
到此,你可能会想动态作用域和静态作用域是如何不同的。下面有两个例子来帮你阐述这一点:
 
例1:
 
 var x = 10;

 function foo() {
var y = x + 5;
return y;
} function bar() {
var x = 2;
return foo();
} function main() {
foo(); // Static scope: 15; Dynamic scope: 15
bar(); // Static scope: 15; Dynamic scope: 7
return 0;
}

bar函数被调用时,我们可以看到上面的动态作用域和静态作用域返回了不同的值。

 
在静态作用域里,bar返回的值是基于foo函数创建时返回的x的值,这是因为源代码的静态和词法结构,结果就是x的值是10,最后返回的结果就是15.
 
另一方面,动态作用域在运行时为我们提供了一组变量定义——这样我们具体使用的是哪个x就取决于哪个x在作用域里,以及在运行时哪个x被动态定义了。运行bar函数把x=2压到栈顶,这样就使得foo返回7了。
 
例2:
 
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(); //

基于我们对环境是如何工作的认识,我们可以说,上面例子中定义的环境看起来是这样的(注意,这个完全是伪代码): 

 
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
};
当我们调用test函数时,我们得到的结果是45,这是从bar函数被调用时返回的值(因为foo函数返回bar函数),即使foo函数返回后,bar还是可以访问变量y,因为bar通过它的外部环境引用y,它的外部环境就是foo的环境,bar也可以访问全局变量x,因为foo的环境可以访问全局环境。这称之为“沿着作用域链查找” 
 
返回我们讨论的动态作用域和静态作用域:要实现闭包,我们不能使用动态作用域来存储我们的变量。这是因为,这样做的话,当函数返回时,变量将会从栈里弹出,并将不再有效——这就和我们对闭包最初的定义正好相反。取而代之是闭包中父级上下文中的数据被保存在称之为“堆”的东西里,它允许函数调用返回后,它的数据还保存在堆里(比如 即使执行上下文被弹出执行调用栈)。
 
听起来很有道理?很好,我们现在在抽象层面理解了闭包的内部实现,让我们来多看几个例子:
 
例1:
一个典型的例子/错误是当有一个for循环,而且我们尝试把for循环中的计数变量与for循环中的一些函数相关联:
 
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

对!这样就可以了:) 

 
另外,更聪明的方法是用let代替var,因为let是块作用域,所以在for循环中一个新的标识符绑定是在每次迭代时被创建的:
 
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函数时都会创建一个新的闭包,也就是foobar。后续调用每个闭包函数都会更新闭包内的变量,这展示了iCantThinkOfAName函数返回后,每个闭包里的变量继续被iCantThinkOfAName函数里的doSomething函数所使用。

 
例3:
 
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,
};

因为我们的addsubtract函数都有一个指向mysteriousCalculator函数环境的引用,它们可以使用那个环境里的变量来计算结果。 

 
例4:
最后这个例子演示了闭包最重要的一个功能:维护一个私有指向外部作用域变量的引用。
 
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里的闭包作用域就是词法,这意味着它是在源代码里的位置静态定义的。
  • 闭包用许多实际的用处,最重要的一个用处是维护一个私有指向外部环境变量的引用。
 

结束语

 
我希望这篇文章能对你有所帮助,希望它能给你一种心智模式——在JavaScript里闭包是如何实现的。正如你所见,理解它们是如何工作的,可以让你更好地掌握闭包——更不用说当你调试Bug时为你省下了很多麻烦。
 
PS:人有失足——如果你发现有任何问题,我希望你能跟我说一声。
 

延伸阅读

 
为了简单起见,我避开了一些可能对有些读者感兴趣的主题,下面是一些我想分享给你们的链接: