原文地址: medium.com/dailyjs/i-n…
译文地址:github.com/xiao-T/note…
本文版权归原作者所有,翻译仅用于学习。

正如标题所述,JavaScript 闭包对我来说一直是一个谜。为此,我读过很多文章,工作中我也用过闭包,有些时候,我甚至都不知道使用了闭包。
最近,我和一些人讨论了一下,他们真正的点醒了我。在这篇文章中,我将会尝试解释一下闭包。首先,我要感谢一下 CodeSmith 和他们的JavaScript 的课程。
前言
为了理解闭包,有些概念非常重要。其中之一就是执行上下文。
这篇文章很好的解释了什么是执行上下文。以下是引用:
当执行 JavaScript 代码时,执行环境非常重要,并且会按照以下情况计算:
全局代码 — 当代码第一次执行时,默认的执行环境
函数代码 — 在函数体内执行的代码
执行上下文
其实就是当前代码执行的环境/作用域。
换句话说,程序开始时,是在全局的执行上下文中。有些变量是在全局上下文中被声明定义的。我们称之为全局变量。当程序在函数中执行,会发生什么呢?会有以下几步:
- JavaScript 会创建一个本地的全新上下文
- 本地的上下文会有它内部的变量,这些变量属于当前的本地执行上下文
- 新的执行上下文将会被压入执行栈中。执行栈就是为了跟踪程序在哪执行的机制。
函数何时结束?当遇到 return
语句或者遇到闭合的大括号 }
。当函数结束时,会依次发生以下情况:
- 本地执行上下文会从执行栈中被弹出
- 调用上下文将会得到函数的返回值。调用上下文就是函数被调用时的执行上下文,它可是全局执行上下文或者另外一个本地执行上下文。此时,调用上下文会处理函数的返回值。返回值可以是对象、数组、函数、布尔值、任何值。如果,函数没有
return
语句,默认,将会返回undefined
。 - 接下来,本地执行上下文将会被销毁。这个很重要。销毁代表着所有在其内部声明的变量都会被抹除。它们不再可用。这也是为什么把它们称为本地变量。
一个简单的演示
解释闭包之前,我们先看一下以下的代码片段。它看起非常简单直接,任何人都知道会发生什么。
1: let a = 3
2: function addTwo(x) {
3: let ret = x + 2
4: return ret
5: }
6: let b = addTwo(a)
7: 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
。
为了解释一个简单的程序费了不少口舌,然而,我们还没有真正的讲道闭包。我保证我会的。首先,我们需要绕个弯路。
词法作用域
我们需要理解什么是词法作用域。看下面的代码。
1: let val1 = 2
2: function multiplyThis(n) {
3: let ret = n * val1
4: return ret
5: }
6: let multiplied = multiplyThis(6)
7: console.log('example of scope:', multiplied)
想法是:我们在本地执行上下文和全局执行上下文都有变量。JavaScript 中比较难理解是:如何查找变量。如果,在本地执行上下文中找不到,将会在自身的调用上下文中继续查找。如果,还是没有找到。重复以上的动作,直到查到全局执行上下文。(如果,仍旧没有找到,就会返回 undefined
)。根据这个规则,上面的示例就很清晰了。如果,你清楚作用域是如何工作的,你可以跳过这部分。
-
在全局执行上下文中声明一个变量
val1
,然后,给它赋值数字2
-
第 2 - 5 行。声明了一个新变量
multiplyThis
,然后,定义了一个函数 -
第 6 行。在全局执行上下文声明了一个变量
multiplied
-
在全局执行上下文中找到变量
multiplyThis
,并做为一个函数执行。然后,把数字6
做为参数传递给函数 -
函数被调用 = 新的执行上下文。创建新的本地执行上下文
-
在本地执行上下文中,声明了变量
n
并赋值了数字6
-
第 3 行。声明了变量
ret
-
第 3 行。变量
n
和vall
两个数的相乘。在本地执行上下文中查找变量n
。我们在第 6 行声明了这个变量。它的值是数字6
。本地上下文中没有找到变量vall
。需要检测调用上下文。因为,调用上下文是全局上下文。我们需要在全局上下文中查找vall
。很好,找到了。它在第 1 行被定义的。它的值是数字2
。 -
第 3 行。两个数相差,然后赋值给变量
ret
。6 * 2 = 12。ret
的值是12
。 -
返回
ret
的值。随之本地上下文也被销毁,同时销毁的还有变量ret
和n
。变量vall
并不会被销毁,因为它是全局上下文的一部分。 -
回到第 6 行。在调用上下文中,数字
12
被复制给变量multiplied
。 -
最后的第 7 行,我们在控制台中打印了变量
multiplied
的值
通过这个示例,我们可以知道函数可以访问调用上下文中的变量。这种现象的正式名称就叫做词法作用域。
函数中返回另外一个函数
第一个示例中函数 addTwo
返回了一个数字。早前,我们也说过函数可以返回任何类型。我们来看一个函数返回函数的示例,这对于理解闭包至关重要。下面的演示,我们会一点点分析它。
1: let val = 7
2: function createAdder() {
3: function addNumbers(a, b) {
4: let ret = a + b
5: return ret
6: }
7: return addNumbers
8: }
9: let adder = createAdder()
10: let sum = adder(val, 8)
11: console.log('example of function returning a function: ', sum)
我们开始一步步的分析下代码。
-
第 1 行。我们在全局上下文中声明了变量
val
,并且赋值数字7
给变量 -
第 2 - 8 行。在全局上下文中我们声明了一个名为
createAdder
的函数。第 3 - 7 行就是函数的具体定义。和之前一样,这个时候,我们并不会执行函数。我们只是把函数赋值给一个变量(createAdder
) -
第 9 行。在全局上下文中,我们声明了新的变量
adder
。同时,它的值是undefined
-
还是第 9 行。我们看到了一个圆括号
()
;代表着我们需要调用函数。我们在全局上下文中搜索找了到名为createAdder
的变量。它是在第 2 步创建的。好,我们来调用它。 -
调用函数。现在,我们回到第 2 行。一个新的上下文被创建。在新的上下文中我们创建了本地变量。同时,引擎也会把新的上下文压入到调用栈。这个函数没有参数,我们直接看它的内部。
-
第 3 - 6 行。我们又声明了一个新的函数。在本地上下文中我们创建变量
addNumber
。这个很重要。addNumber
只在本地上下文中有效。在本地上下文中我们定义了一个函数并命名为addNumber
-
现在,我来到第 7 行。我们返回了变量
addNumber
。引擎会查找变量addNumber
,当然也会找到它。它是一个函数。好,函数可以返回任何东西,包括函数。因此,我们返回了addNumbers
的函数体。在第 4 - 5 行就是函数的具体定义。同时,我们也把本地上下文从调用栈中移除。 -
return
之后,本地上下文也随之销毁。变量addNumbers
也不存在了。但是,函数的定义仍然存在,它通过 retrun 语句,并赋值给了变量adder
;这个变量,我们是在第 3 步创建的。 -
来到第 10 行。在全局上下文中,我们定义了新的变量
sum
。并分配了一个临时的值undefined
-
接下来,我们需要执行函数。哪一个函数呢?就是名为
adder
的函数。我们在全局上下文中查找它,可以保证一定能找到它。这个函数需要两个参数 -
我们得到了两个参数,并把它们传递了函数。第一个是变量
val
,我们在第 1 步定义的,它的值是数字7
,第二个参数是数字8
-
现在,我们来调用函数。这个函数是在第 3 - 5 行被定义的。一个新的本地上下文被创建。在这个上下文中有两个新的变量:
a
和b
。它们的值分别是7
和8
,这就是我们在上一步传递给函数的。 -
第 4 行。名为
ret
的变量被声明。它只存在本地上下文中。 -
第 4 行。我们把变量
a
和b
相加。相加后的结果(15
)赋值给了变量ret
-
变量
ret
通过函数返回。随之,与之相关的本地上下文被销毁,也从调用栈中被移除,变量a
、b
、ret
也不存在了 -
返回的值被赋值给我们在第 9 步定义的变量
sum
-
最后,我们在控制台输出了
sum
的值
如你所望,控制台输出的是 15。以上,我们经历了很多。有几点我需要指出。首先,函数体的定义可以存在一个变量中,直到程序调用了函数,函数的定义是不可见的。第二,每次函数被调用,一个新的本地上下文都会被创建(临时的)。当函数执行完,随之上下文也会消失。在遇到 return
语句或者闭合的大括号 }
就说明函数结束了。
最后,闭包
看一下下面的代码,并指出将会发生什么。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
现在,我们已经从前面两个示例中学到了窍门,我们来按照以上的模式来逐步的分析下代码。
-
第 1 - 8 行。我们在全局上下文中创建了新的函数变量
createCounter
-
第 9 行。我们在全局上下文中声明了变量
increment
-
还是第 9 行。我们需要调用函数
createCounter
,并把结果赋值给你变量increment
。 -
第 1 - 8 行。调用函数期间,会创建新的本地上下文。
-
第 2 行。在本地上下文中声明了变量
counter
。默认值是数字0
。 -
第 3 - 6 行。声明了名为
myFunction
的变量。这个变量是在本地上下文中声明的。这个变量也是一个函数。第4 - 5 行就是相应的函数体。 -
第 7 行。直接放回了函数
myFunction
。本地上下文销毁。myFunction
和counter
也伴随被销毁。重新回调了调用上下文。 -
第 9 行。在调用上下文中,也就是全局上下文,
createCounter
返回的值赋值给了变量increment
。此时的变量就是一个函数。这个函数是由createCounter
返回的。虽然,不是myFunction
,但是,函数体内容是一致的。在全局上下文中,它就是imcrement
。 -
第 10 行。声明新变量(
c1
) -
第 10 行。调用了函数
increment
。这个函数是早期在第 4 - 5 行中定义的 -
创建新的上下文。只是执行函数,并没有参数。
-
第 4 行。
counter = counter + 1
。在本地上下文中查找变量counter
。我们只会创建上下文,绝对不会声明任何本地变量。我们看一下全局上下文。并没有变量counter
。因此,刚才的表达式等同于counter = undefined + 1
,声明一个本地变量counter
,并给它赋值数字1
,因为,undefined
有点类似0
。 -
第 5 行。我们返回了
counter
的值,也就是数字1
。同时,销毁本地上下文和变量counter
-
回到第 10 行。返回的值(
1
)赋值给了c1
-
第 11 行。重复第 10 - 14 步,
c2
也得到数字1
-
第 12 行。重复第 10 - 14 步,
c3
也得到数字1
-
第 13 行。我们打印变量
c1
、c2
、c3
的值
自己试一下,看看会发生什么。你会看到,并不会像我上面说的那样输出 1
、 1
和 1
。而是,输出了 1
、2
、 3
。为什么?
莫名其妙,函数 increment
记住了 counter
的值。它是如何做到的?
难道 counter
是全局上下文的一部分?试着在控制台打印 console.log(counter)
,你会看到输出 undefined
。这说明它并不在全局上下文中。
或许,当你调用 increment
时,作用域回到了函数被创建的地方(createCounter
)?怎么会呢?变量 increment
只是有着相同的函数体,并不是 createCounter
。因此,也不对。
因此,必然有另外一种机制。就是闭包。我们最终说到它了。
以下就是它的工作模式。每当你声明一个新函数,并把它赋值给一个变量,用来存储函数的定义,这就是闭包。闭包包含创建函数时作用域内的所有变量。这就类似一个背包。函数定义时附带一个小背包。这个背包存储了创建函数时作用域中所有的变量。
因此,以上的分析是错误的,我们重新正确的分析一次。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
-
第 1 - 8 行。我们在全局上下文中创建了新的函数变量
createCounter
。和上次一样 -
第 9 行。我们在全局上下文中声明了变量
increment
。和上次一样 -
还是第 9 行。我们需要调用函数
createCounter
,并把结果赋值给你变量increment
。和上次一样 -
第 1 - 8 行。调用函数期间,会创建新的本地上下文。和上次一样
-
第 2 行。在本地上下文中声明了变量
counter
。默认值是数字0
。和上次一样 -
第 3 - 6 行。声明了名为
myFunction
的变量。这个变量是在本地上下文中声明的。这个变量也是一个函数。第4 - 5 行就是相应的函数体。现在,我创建了一个闭包,它是函数的一部分。闭包包含当前作用域的中的变量,在这个示例中变量是counter
(它的值是0
)。 -
第 7 行。直接放回了函数
myFunction
。本地上下文销毁。myFunction
和counter
也伴随被销毁。重新回调了调用上下文。因此,我们得到了一个函数和闭包,这个背包中包含了函数定义时作用域中的所有变量。 -
第 9 行。在调用上下文中,也就是全局上下文,
createCounter
返回的值赋值给了变量increment
。此时的变量就是一个函数(也包括闭包)。这个函数是由createCounter
返回的。虽然,不是myFunction
,但是,函数体内容是一致的。在全局上下文中,它就是imcrement
。 -
第 10 行。声明新变量(
c1
) -
第 10 行。调用了函数
increment
。这个函数是早期在第 4 - 5 行中定义的。(并且变量也有一个背包) -
创建新的上下文。只是执行函数,并没有参数。
-
第 4 行。
counter = counter + 1
。我们需要查询变量counter
。在此之前,我在本地或者全局上下文中查找,这次,我们来看一下背包,闭包。你瞧,闭包中包含一个名为counter
的变量,它的值是0
。经过第 4 行的计算后,它的值变成1
。它也重新存储在背包中。这时闭包中的变量counter
值变成了1
。 -
第 5 行。我们返回了
counter
的值,也就是数字1
。同时,销毁本地上下文。 -
回到第 10 行。返回的值(
1
)赋值给了c1
-
第 11 行。重复第 10 - 14 步。这次,当我们查看闭包时,看到变量
counter
的值是1
。这是因为第 12 步导致的。这时,它的值再次被递加得到了2
,并存储在闭包中。同时,c2
的值也是2
。 -
第 12 行。重复第 10 - 14 步,
c3
也得到数字3
-
第 13 行。我们打印变量
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)
我们声明了一个普通的函数 addX
,它需要一个参数 x
,并返回了另外一个函数。
这个返回的函数也需要一个参数,并用它与变量 x
相加。
变量 x
就是闭包的一部分。当变量 addThree
在本地上下文中声明时,它得到了一个函数和一个闭包。闭包中就有变量 x
。
因此,当调用和执行函数 addThree
时,它可以通过闭包访问变量 x
和做为参数传递的 n
,最终返回了两者之和。
在这个示例中,控制台将会输出数字 7
。
总结
为了记住闭包,我把它比喻为背包。当一个函数被创建并传递或者通过另外一个函数返回,它就会包含一个背包。背包中包含函数声明时作用域中所有的变量。
如果,你喜欢这篇文章,不要吝啬你的赞扬 👏
谢谢