正如标题所示, JavaScript 闭包对我来说一直有点神秘, 我读过许多相关的文章, 也在工作中使用过闭包, 有时甚至用了闭包而不自知.
最近我去参加了一个讲座, 最终有人以一种我能理解的方式诠释了它. 我会尽量在这篇文章中以这种方式来解释闭包. 我要感谢在 CodeSmith 上的那些了不起的人, 以及他们的 JavaScript 难懂系列.
开篇语
在你能对闭包心领神会之前有些概念是你需要知道的, 其一就是执行上下文.
这篇文章是很好的执行上下文的入门文章, 引用一段:
当代码在 JavaScript 中运行时, 它运行的环境是很重要的, 它可能是以下情况之一:
全局代码 -- 你的代码第一次被执行的默认环境;
函数代码 -- 每当执行流进入到函数体中时;
(...)
(...), 我们可以把术语执行上下文视为评估当前代码的环境
另一方面, 我们是在全局的执行上下文中启动程序的, 一些变量是在全局的执行上下文中声明的, 我们称之为全局变量. 当程序调用函数时, 发生了什么呢? 以下几步:
JavaScript 创建一个新的局部执行上下文;
该局部执行上下文有它自己的变量集合, 这些变量对这个执行上下文来说是本地的;
这个新的执行上下文被扔到执行栈中. 把执行栈想象成一个持续追踪程序执行的机制.
函数什么时候运行结束呢? 当遇到 return 或者关闭符号 } 时. 当函数结束时, 会发生以下几步:
局部执行上下文从执行栈中移除;
函数发送返回值给调用的上下文, 调用上下文是调用这个函数的执行上下文, 它可能是全局的执行上下文或另一个局部执行上下文. 此时是由调用执行上下文来处理返回值的. 返回值可能是对象, 数组, 函数, boolean 值什么的. 如果函数没有 return 声明, 则会返回 undefined.
销毁局部执行上下文. 这很重要, 销毁. 所有在这个局部执行上下文中声明的变量都会被释放, 它们将失效. 这也是称它们为局部变量的原因.
一个很基础的示例
讲闭包之前, 我们先看下下面这段代码, 它看起来很直观, 任何在看这篇文章的人应该都知道这代码在干嘛吧.
- let a = 3;
- function addTwo(x) {
- let ret = x +2 ;
- return ret;
- }
- let b = addTwo(a);
- console.log(b);
为了理解 JavaScript 引擎到底是如何工作的, 我们来分解一下:
第一行我们在全局执行上下文中声明了一个新的变量 a 并赋值为数字
3
;
接下来就有点棘手了, 第二行到第五行是一体的, 这里发生了什么? 我们在全局执行上下文中声明了一个名为 addTwo 的变量, 并把它分配给了函数定义, 不管 { } 里面的是什么, 它们都只属于 addTwo. 函数里面的代码没有被评估, 没有被执行, 只是存在变量中以供将来使用;
现在来看第六行. 它看起来很简单, 但这里有太多要分解的地方了. 首先我们在全局执行上下文中声明了一个新的变量并标识为 b. 一旦一个变量被声明它就有了 undefined 值;
接下来, 还是在第六行, 我们看到一个赋值运算符, 准备给变量 b 分配一个新值. 接下来是一个函数被调用, 当你看到一个变量后面跟着括号(), 就表明这个函数被调用了. 每个函数都会有返回值(要不就是一个值, 对象, 要不就是 undefined), 不管这个函数返回什么都将赋值给 b;
但首先我们要先调用名为 addTwo 的函数, JavaScript 将会在全局执行上下文内存中查找名为 addTwo 的变量, 噢~ 看呐, 找到一个, 它是在第二行声明的. 变量 addTwo 包含了一个函数定义, 变量 a 被当作一个参数传给了该函数. JavaScript 又在全局执行上下文内存中查找变量 a, 找到了, 并且它的值是 3, 然后把数字 3 作为参数传给函数, 已经准备好执行函数了;
现在执行上下文将改变. 一个新的局部执行上下文被创建了, 我们暂且把它称为 addTwo 执行上下文, 这个执行上下文被推到调用堆栈中, 在局部执行上下文中做的第一件事是什么呢?
你可能会说 "在局部执行上下文中声明了一个新变量 ret" . 这是不对的, 正确答案应该是, 先检查函数的参数. 在局部执行上下文中声明新变量 x, 直到
3
被当作参数传过来, 变量 x 被赋值为 3;
下一步是: 在局部执行上下文中声明新变量 ret. 它的值现在是 undefined;
还是第三行, 这有个加法的操作要执行. 首先我们需要 x 的值, JavaScript 会查找变量 x. 它会先在局部执行上下文中查找, 然后找到了, 值为
3
; 第二个操作数是数字 2, 执行加法得出的结果 5 被赋值给变量 ret;
第四行, 返回了变量 ret 的内容, 另一个局部执行上下文的查找发现 ret 包含数字
5
, 然后函数返回数字
5
, 函数执行结束;
第四行和第五行, 函数终止. 局部执行上下文被销毁, 包括变量 x 和 ret, 不复存在, 该上下文从调用堆栈中移出并返回给调用上下文一个返回值. 在这个案例中调用上下文是全局执行上下文, 因为函数 addTwo 是在全局执行上下文中调用的;
现在我们回过头看一下第四步, 返回值 (数字 5) 被赋值给变量 b, 在这个小程序中我们依旧处于第六行;
不想说的太细, 但第七行, 变量 b 的内容被打印在控制台中, 输出值为
5
;
这就是这个非常简单的程序的冗长解释, 而我们至今还没有讲到闭包, 我保证一定会讲的, 但首先我们还要再绕一两个弯路.
词法作用域
我们需要对词法作用域有一定的了解, 看下面这个例子:
- let vall = 2;
- function multiplyThis(n){
- let ret = n* vall;
- return ret;
- }
- let multiplied = multiplyThis(6);
- console.log('example of scope:', multiplied);
这里的想法是我们的变量既有在局部执行上下文中的也有在全局执行上下文中的, JavaScript 的一个复杂点就是如何查找这些变量. 如果它在局部执行上下文中找不到这个变量, 那么它就会去该变量的调用执行上下文中查找, 如果还没找到, 就一直重复这个操作, 直到在全局执行上下文中查找(如果在这也没找到那这个变量就是 undefined). 跟着分析这个案例将会更清晰明了, 如果你知道作用域是怎么工作的, 那你可以跳过这一步.
在全局执行上下文中声明一个新的变量 vall 并赋值为 2;
第二行到第五行声明了一个新变量 multiplyThis, 并为其分配了一个函数定义;
第六行, 在全局执行上下文中声明一个新的变量 multiplied;
在全局执行上下文内存中检索 multiplyThis 变量并将它作为函数执行, 将数字
6
作为参数传递;
创建函数调用等于创建执行上下文 -- 创建一个新的局部执行上下文;
6. 在局部执行上下文中, 声明一个变量 n 并赋值为
6
;
7. 第三行, 在局部执行上下文中声明一个变量 ret;
还是第三行, 对变量 n 和 vall 的内容值执行乘法操作; 先在局部执行上下文中查找变量 n, 我们在第六步中声明了它, 它的值是
6
; 接着在局部执行上下文中查找变量 vall, 先检查一下调用上下文, 发现调用上下文是全局执行上下文, 那就在全局执行上下文中查找 vall, 看, 找到了, 它是在第一步中声明的, 它的值是 2;
继续看第三行, 把两数相乘并赋值给变量 ret, 6 * 2 = 12. 现在 ret 的值是
12
;
返回 ret 变量, 该局部执行上下文被销毁, 包括它的变量 ret 和 n. 变量 vall 没有被销毁, 因为它是在全局执行上下文中的;
回到第六行, 在调用上下文中, 数字 12 被赋值给变量 multiplied;
最后在第七行, 在控制台展示了变量 multiplied 的值.
所以在这个示例中, 我们要记住函数有权限访问在其调用上下文中的变量. 专业名词就叫做词法作用域.
一个返回函数的函数
在第一个示例中函数 addTwo 返回了一个数字, 之前我们说过函数是可以返回任何值的, 现在我们来看一个返回函数的函数, 这对于理解闭包是必不可少的.
这就是我们要来分析的示例:
- let val = 7;
- function createAdder() {
- function addNumbers(a, b){
- let ret = a + b;
- return ret;
- }
- return addNumbers
- }
- let adder = createAdder();
- let sum = adder(val, 8);
- console.log('example of function returning a function:', sum);
让我们继续一步步分解.
第一行, 在全局执行上下文中声明变量 val 并赋值为
7
;
第二到第八行, 在全局执行上下文中声明了一个名为 createAdder 的函数定义. 第三行到第七行描述了函数定义的内容, 和之前一样, 先不进入该函数, 先把这个函数定义存在变量 createAdder 中;
第九行, 我们在全局执行上下文中声明了一个名为 adder 的新变量, undefined 被立即赋值给 adder;
还是第九行, 我们看到了括号(), 这时需要执行或调用一个函数, 接下来在全局执行上下文的内存中查找名为 createAdder 的变量, 发现它在第二步中创建了, 调用它;
调用函数. 现在我们来看第二行, 一个新的局部执行上下文被创建了, 我们可以在这个新的执行上下文中创建局部变量. 引擎会把这个新的上下文添加到调用栈中, 这个函数没有参数, 可以直接进入函数体中;
继续看第三到第六行, 我们声明了一个新的函数, 在局部执行上下文中创建了变量 addNumbers, 这很关键, addNumbers 只存在于局部执行上下文中, 我们把函数定义存储在名为 addNumbers 的局部变量中;
现在来看第七行, 我们返回了变量 addNumbers 的内容, 引擎查找名为 addNumbers 变量并且找到了, 这是一个函数定义, 没事, 函数是可以返回任意值的, 包括函数定义. 所以我们返回了 addNumbers 的定义, 所有第四到第五行 {} 之间的内容构成了这个函数定义; 这时也把局部执行上下文从调用栈中移除了;
return 之后, 局部执行上下文被销毁, 变量 addNumbers 也一样, 但函数声明还是存在的, 它是从函数中返回的并且被分配给了变量 adder, 就是我们在第三步中创建的变量;
现在看第十行, 在全局执行上下文中声明了变量 sum, 暂时分配的值是 undefined;
接下来就要执行一个函数了, 哪个函数呢? 在名为 adder 的变量中定义的函数. 我们先在全局执行上下文中查找它, 毫无疑问能找到, 这是一个需要两个参数的函数;
先来检索这两个参数, 以便调用这个函数并正确传参; 第一个是我们在第一步中定义的变量 val, 它代表的值是
7
; 第二个是数字
8
;
现在可以执行这个函数了, 函数的定义在第三到第五行, 然后一个新的局部执行上下文又被创建了, 在这个局部上下文中创建了两个变量 a 和 b, 它们分别被赋值为
7
和
8
, 这就是上一步中我们传递给函数的参数;
第四行, 一个名为 ret 的新变量在局部执行上下文中被声明;
第四行, 执行了一个变量 a 和 b 相加的加法操作, 得出的结果
15
被赋值给 ret 变量;
函数返回了 ret 变量的值, 局部执行上下文被摧毁, 并从调用栈中移除, 变量 a,b,ret 都不复存在;
返回值被赋值给我们在第九步中定义的变量 sum;
在控制台中打印了 sum 的值;
正如所料控制台会打印 15, 我们在这真的绕了一个挺大圈子的, 我只是想阐述以下几点:
第一, 函数定义可以被存储在变量中, 且在它被调用之前对程序来说都是不可见;
第二, 每当一个函数被调用, 就会创建一个局部执行上下文, 当函数结束时该局部执行上下文也会消失; 当函数体遇到 return 或 关闭的大括号 }时函数就结束了;
闭包
看下面一段代码并猜一下会发生什么.
- function createCounter() {
- let counter = 0;
- const myFunction = function () {
- counter = counter +1;
- return counter;
- }
- return myFunction;
- }
- const increment = createCounter();
- const c1 = increment ();
- const c2 = increment ();
- const c3 = increment ();
- console.log('example increment', c1, c2, c3);
现在我们有了前面两个例子的经验, 来快速浏览一下这段代码的执行过程, 就如我们期望的那样运行:
第一到八行, 在全局执行上下文中创建了一个新变量 createCounter, 且被分配为函数定义;
第九行, 在全局执行上下文中创建了一个新变量 increment;
还是第九行, 我们要调用函数 createCounter, 并把返回值赋值给变量 increment;
第一到第八行, 调用函数, 创建局部执行上下文;
第二行, 在局部执行上下文中声明一个名为 counter 的变量, 数字 0 被赋值给 counter;
第三到六行, 声明一个名为 myFunction 的变量, 该变量是在局部执行上下文中的, 该变量的内容是另一个在第四到第五行的函数定义;
返回变量 myFunction 的内容, 局部执行上下文被删除, myFunction 和 counter 也被销毁, 控制权回到调用上下文中;
第九行, 在调用上下文中(全局执行上下文), createCounter 的返回值被赋值给 increment; 现在变量 increment 包含了一个函数定义. 这个函数定义就是 createCounter 返回的, 它的标签不再是 myFunction, 但具有相同的意义, 在全局上下文中, 它的标签是 increment;
第十行, 声明新变量 c1;
还是第十行, 查找变量 increment, 它是一个函数, 调用它. 它包含了前面返回的一个函数定义(第四到第五行定义的);
创建一个新的执行上下文, 没有参数, 开始执行函数;
第四行,
counter = counter + 1
, 在局部执行上下文中查找 counter 的值. 我们刚刚创建这个上下文并且没有声明任何局部变量, 在全局执行上下文中查找, 没有发现名为 counter 的变量, 所以 JavaScript 执行的是
counter = undefined + 1
, 声明一个新的局部变量 counter 并赋值为 1,undefined 的值是 0;
第五行, 返回 counter 的内容值 1, 销毁这个局部执行上下文和变量 counter;
回到第十行, 返回值 1 被赋值给了 c1;
第十一行, 重复第 10-14 步, c2 也被赋值为 1;
第十二行, 重复第 10-14 步, c3 也被赋值为 1;
第十三行, 打印变量 c1,c2,c3;
亲自试一下看会发生什么, 你会发现并没有如我说明的那样输出 1, 1 ,1, 而是输出了 1,2,3, 那是怎么回事呢?
不知何故, increment 函数记住了 counter 的值, 这是怎么实现的呢?
难道 counter 是全局执行上下文的一部分吗? 试着打印 console.log(counter)得到的是 undefined, 所以并不是.
或许当你调用 increment 时, 它是从自身被创建的函数 (createCounter) 处开始执行的? 这怎么可能呢, 变量 increment 包含的是函数定义, 而不是生成它的函数, 所以也不是这个原因.
所以一定是另一个机制 -- 闭包, 我们终于讲到它了.
这里讲一下闭包的工作原理. 无论何时你定义一个新的函数并把它赋值给一个变量时会保存函数定义, 闭包也是如此. 闭包包含了在创建函数时作用域内的所有变量, 这有点像一个背包的作用. 函数定义自带一个小背包, 背包里装着在创建函数时作用域内的所有变量.
所以上面我们的步骤分析全是错的, 这次我们再准确地分析一遍.
- function createCounter() {
- let counter = 0;
- const myFunction = function () {
- counter = counter +1;
- return counter;
- }
- return myFunction;
- }
- const increment = createCounter();
- const c1 = increment ();
- const c2 = increment ();
- const c3 = increment ();
- console.log('example increment', c1, c2, c3);
第一到八行, 跟之前一样, 我们在全局执行上下文中创建了一个新的变量 createCounter 并分配为函数定义;
第九行, 跟之前一样, 在全局执行上下文中创建了一个新变量 increment;
还是第九行, 跟之前一样, 我们要调用函数 createCounter, 并把返回值赋值给变量 increment;
第一到第八行, 跟之前一样, 调用函数, 创建局部执行上下文;
第二行, 跟之前一样, 在局部执行上下文中声明一个名为 counter 的变量, 数字 0 被赋值给 counter;
第三到六行, 在局部执行上下文中声明一个新的变量 myFunction, 该变量现在是另一个函数定义(第四到五行), 此时我们创建了一个闭包并将其作为函数定义的一部分. 闭包包含了作用域中的变量, 这个示例中是 counter 变量(值为 0);
第七行, 返回变量 myFunction 的内容, 局部执行上下文被删除, myFunction 和 counter 都不存在了. 控制权回到调用上下文中, 所以我们返回的是函数定义和它的闭包, 此时背包中装着闭包被创建时作用域中的变量;
第九行, 在调用上下文中(即全局执行执行上下文),createCounter 返回的值被赋值给 increment, 现在变量 increment 包含一个函数定义(和其闭包), 这个函数定义就是 createCounter 返回的, 它的标签不再是 myFunction, 但具有相同的意义, 在全局上下文中, 它是 increment;
第十行, 声明一个新变量 c1;
继续看第十行, 查找变量 increment, 是一个函数, 调用它, 它包含一个函数定义(第四到五行定义的), 还有一个装着变量的背包;
创建一个新的执行上下文, 不带参数, 开始执行函数;
第四行,
counter = counter + 1
, 查找变量 counter, 在局部或全局执行上下文中查找之前, 先检查一下闭包, 在背包中查找. 瞧瞧, 闭包里包含了一个名为 counter 的变量, 它的值为 0. 在表达式的最后, 它的值被设置为 1, 并且它的值被再次存入背包中. 现在闭包中包含一个变量 counter, 其值为 1;
第五行, 返回 counter 的内容值 1, 销毁局部执行上下文;
回到第十行, 返回值 1 被赋值给 c1;
第十一行, 重复第 10-14 步. 这次, 当我们查看闭包时, 可以看到变量 counter 的值变为 1 了. 它是在第 12 步中第四行代码被设置的. 它的值增加并且在 increment 函数的闭包中存储的值为 2,c2 被赋值为 2;
第十二行, 重复第 10-14 步, c3 被赋值为
3
;
第十三行, 打印变量 c1,c2,c3.
所以现在我们能理解它的工作原理了, 要记住的关键就是当声明函数的时候, 它包含了一个函数定义和一个闭包. 闭包就是函数创建时作用域内所有变量的集合.
你可能会问, 是任何函数都有闭包吗, 甚至在全局作用域内声明的函数? 是的. 全局作用域内创建的函数也会创建闭包, 但是, 由于这些函数是在全局范围内创建的, 因此它们可以访问全局范围内的所有变量. 与闭包的概念不是真正相关的.
当一个函数返回函数时, 闭包的概念变得更清晰一点了. 当作返回值的那个函数可以访问不在全局作用域内的函数, 但它们仅存在于其闭包中.
没那么简单的闭包
有时闭包会在你没注意时出现, 你可能看过称之为局部应用的例子, 代码如下:
- let c = 4
- const addX = x => n => n + x
- const addThree = addX(3)
- let d = addThree(c)
- console.log('example partial application', d)
如果箭头函数不行, 下面的代码也是等效的:
- let c = 4
- function addX(x) {
- return function(n) {
- return n + x
- }
- }
- const addThree = addX(3)
- let d = addThree(c)
- console.log('example partial application', d)
我们声明了一个通用的带 x 参数并返回另一个函数的函数 addX.
返回值的参数也带一个参数和变量 x 相加.
变量 x 是闭包的一部分, 当变量 addThree 在局部上下文中被声明的时候, 它被赋值为一个函数定义和闭包, 闭包中包含变量 x;
所以当 addThree 被调用执行时, 它是可以在其闭包中访问到变量 x 并和当作参数传过来的变量 n 相加返回值的.
在这个例子中控制台打印的值会是数字 7.
结论
我能一直记得闭包是通过背包类比, 当一个函数被创建或被当作参数传递或从另一个函数返回时, 它都带有一个背包, 并且在背包中装着声明函数时作用域内的所有变量.
来源: http://www.jianshu.com/p/1c8deb1493f2