我从没理解js的闭包,直到他人向我这么解释。。。
前段时间根据执行上下文写过一次闭包,但是写的简陋些。昨天在twitter上看到这篇文章,感觉背包的比喻挺恰当的。所以就翻译了。
这篇文章有些啰嗦,但是讲解很细,希望还是耐心看完。也欢迎指出错误。
如题所述,闭包对我有很强的神秘感。我读过许多的文章,在工作中使用闭包,有时我甚至在没有意识到使用闭包的情况下使用了闭包。
开始前
在你了解闭包之前,有些概念是很重要的。其中一个就是执行上下文。
可以通过这篇文章了解下执行上下文。引用下里面的一些内容:
当代码在JavaScript中运行时,其执行的环境非常重要,通常是以下之一:
Global code-默认环境,代码第一次执行的时候。
Function code-代码执行进入函数体中。
(...)
(...)让我们思考下这个术语
执行上下文
,是作为当前代码的执行环境/作用域。
换句话说,当我们执行一个程序的时候,先从全局执行上下文开始。一些变量在全局执行上下文中被声明。我们成这些为全局变量。当程序调用函数时,会发生什么?有下面几个步骤:
- javascript创造一个新的执行上下文,局部执行上下文。
- 该局部执行上下文拥有自己的一组变量,这些变量在该执行上下文中是局部的。
- 新的执行上下文被抛出到执行栈上。执行栈可以看作是跟踪程序执行过程的机制。
一个function
什么时候结束呢?当它遇到一个return
语句或者碰到一个右括号}
。当一个函数结束后,接下来会发生:
- 局部执行上下文弹出执行栈。
- 这些函数将返回值发送回调用上下文。调用上下文是调用此函数的执行上下文,是全局执行上下文或者另一个局部执行上下文。在那个时候处理返回值取决于调用执行上下文。返回的值可以是一个对象,一个数组,一个函数,一个布尔值,任何都可以。如果这个函数没有
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
。函数内的代码不会被求值,也不会被执行,只是存储在一个变量中以备将来使用。 - 现在到第6行。看上去很简单,但是有很多东西需要挖掘出来。首先在全局执行上下文中声明一个新的变量,并且标记为
b
。只要变量被声明,它的值就是undefined
。 - 接下来,仍然是第6行,我们看到一个赋值操作符。我们正在准备为变量
b
赋值一个新值。然后会看到一个函数被调用。当我们看到一个函数后面跟着一个圆括号(...)
,这是一个函数正在被调用的一个信号。每个函数都会返回一些东西(一个值,一个对象或者undefined
)。无论从函数返回的是什么,都会被赋值给变量b
。 - 首先我们需要调用
addTwo
函数。js将会在全局执行上下文内存中查找名为addTwo
的变量。好的,在第二步(或者2到5行)。并且看到变量addTwo
包含一个函数定义。变量a
作为参数传给了这个函数。js在全局执行上下文内存中搜索变量a
,找到了它,找到了它的值是3
,并且把3
作为参数传给了函数。准备执行该函数。 - 现在执行上下文将被切换。一个新的局部上下文被创造,我们称它为'addTwo 执行上下文'。这个执行上下文被压到执行栈里。在局部执行上下文,我们做的第一步是什么?
- 你也许很冲动的说,"在局部执行上下文中一个新的变量
ret
被创建"。这不是答案。正确的答案是,我们首先要看函数的参数。局部执行上下文中新的变量x
被创建。而且由于3
被作为参数传递,所以变量x被赋值了数字3
。 - 下一步:一个新的变量
ret
在局部执行上下文被创建。它的值是undefined
。 - 第3行,一个加法要被执行。首先我们需要
x
的值。js寻找变量x
。首先在局部执行上下文寻找,找到一个,值为3。第二个操作是数字2
。加法运算的结果(5)被赋值给变量ret
。 - 第4行,返回变量
ret
的内容。接着在局部执行上下文中查找。 - 4-5行,函数结束。局部执行上下文被摧毁。变量
x
和ret
被消除。它们不在存在。上下文弹出调用栈。返回值返回到调用上下文。在这种情况下,调用上下文是全局执行上下文,因为函数addTwo
是从全局执行上下文中调用的。 - 回到刚才的第4步,返回值(5)被赋值给变量
b
。 - 我没有详细说明,但是在第7行中,变量b的内容被打印在控制台中。
对于一个非常简单的程序来说,这是一个很长时间的解释,我们甚至还没有涉及到闭包。我保证我会到达那里。但是首先我们需要再绕道一两圈。
词法作用域
我们需要理解词法作用域的某些方面。看下下面的例子:
let val1 = 2
function multiplyThis(n) {
let ret = n * val1
return ret
}
let multiplied = multiplyThis(6)
console.log('example of scope:', multiplied)
这里的想法是我们在局部执行上下文中有变量,在全局执行上下文中有变量。js的一个复杂性在于它如何寻找变量。如果它在局部执行上下文中找不到变量,它将在其调用上下文中查找它。如果没有在它的调用环境中找到。重复上面的步骤,直到它在全局执行上下文中查找到。(如果都没有找到,它是undefined
)。按照上面的例子,将会解释它。如果你了解作用域的运行机制,你可以跳过这个。
- 在全局执行上下文中声明一个新变量
val1
并为其指定数字2
。 - 2-5行。声明一个新的变量
multiplyThis
并且为其赋值一个函数。 - 第6行。在全局执行上下文中声明
multiplied
。 - 从全局执行上下文内存中检索变量
multiplyThis
并作为函数执行。将数字6
作为参数传入。 - 新函数调用=新的执行上下文。创建一个新的局部执行上下文。
- 在局部上下文中,声明一个变量
n
,并且赋值数字6
。 - 第3行。局部行上下文声明变量
ret
。 - 继续第3行。用2个操作数做乘法运算;变量
n
和var1
的值。在局部执行上下文中查找变量n
。在第6步已经声明它,值是6。在局部执行上下文查找变量var1
,但是并没有一个var1
的变量标识。检查下调用上下文。调用上下文是全局执行上下文。在全局执行上下文上下文寻找var1
,是的,在第一行找到了,值是2。 - 仍然是第3行。2个操作数相乘,并且赋值给
ret
。6 * 2 = 12,ret
现在是12。 - 返回
ret
变量。局部执行上下文连同ret
和n
被消除。变量var1
没有被消除,因为它是全局执行上下文的一部分。 - 回到第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);
我们一步步分解。
- 第一步,在全局执行上下文中声明变量
val
,赋值7。 - 2-8行。全局上下文中声明变量
createAdder
,并且给它定义一个函数。3-7行描述了函数的定义。像以前一样,在这一点上,我们并没有进入这个函数。我们仅仅在变量(createAdder
)中缓存了函数定义。 - 第9行。全局执行上下文中声明一个新的变量
adder
,undefined
暂时被赋值给adder
。 - 仍然第9行。看到了括号
()
,我们需要执行一个函数。我们查询全局执行上下文的内存并寻找一个叫做createAdder
的变量。在第2步被找到。好的,执行它。 - 现在在第2行,正在执行函数。一个新的局部执行上下文被新建,并且在新的执行上下文中创建局部变量。这个机制添加了一个新的上下文到执行栈中。该函数没有参数,我们直接跳到它的正文。
- 仍然在3-6行。有一个新的函数声明。局部执行上下文中创建变量
addNumbers
,重要的是,addNumbers
仅仅存在局部执行上下文中。在局部变量addNumbers
中缓存一个函数定义。 - 来到第7行。返回变量
addNumbers
的内容。运行机制寻找变量addNumbers
并且找到,它是一个函数定义。很好,一个函数可以反回任何东西,包括一个函数定义。所以我们返回addNumbers
的定义。在4-5行的括号内,任何东西都组成了函数定义。从执行栈中也移除了一个局部执行上下文。 - 在
return
上面。局部执行上下文被清除。变量addNumbers
不存在了。函数定义还在,它从函数中返回并且赋值给了adder
,这个变量是在步骤3中创建的。 - 来到第10行。全局执行上下文中定义一个新的变量
sum
,暂时的赋值是undefined
。 - 接下来我们需要执行一个函数。哪个函数?在名为
adder
中被定义的函数。在全局执行上下文中需找它,并且找到,是一个有2个参数的函数。 - 让我们检索这两个参数,以便我们可以调用该函数并传递正确的参数。第一个是在第一步被定义的
val
,值为7,第二个是数字8。 - 现在执行那个函数。在3-5行描述了该函数定义。一个新的局部执行上下文被创建,里面有2个新的变量被创建:
a
和b
。它们分别赋值了值7
和8
,因为2个值是我们上一步传递给函数的参数。 - 第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
和cunter
不在存在。运行机制返回到调用上下文。 - 9行,在调用上下文内,全局执行上下文中,由
createCounter
返回的值被赋值为increment
。increment
包含一个函数定义。函数定义通过createCounter
返回。它不再是myFunction
这个标记,但是是相同的定义。全局上下文中,被叫做increment
。 - 10行,声明新的变量
c1
。 - 10行,查询变量
increment
,它是一个函数,调用它。它包含从前面返回的函数定义,第4-5行中所定义的。 - 创建新的执行上下文,没有参数,执行函数。
- 4行,
counter = counter + 1
。在局部执行作用域寻找counter
。我们仅仅创建了上下文,没有摧毁任何变量。看一看全局执行上下文,没有counter
。js将评定为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
的值。
亲自尝试一下,看看会发生什么。你会注意到log不是1,1,1,并不像我们分析中期望的那样。而是1,2,3。怎么回事?
不知为何,increment
函数记住了counter
的值。它是怎么运行的。
counter
是全局执行上下文的一部分吗?通过console.log(counter)
得到的结果是undefined
。也不是。
所以必须有另一种机制。闭包。我们终于找到了失去的那一块。
无论何时声明一个新函数并将其赋值给一个变量,都可以存储函数定义及闭包。闭包包含创建函数时在作用域内的所有变量。类似一个背包,函数定义附带一个小的背包,在这个包中,它存储了创建函数定义时所有作用域内的变量。
因此我们上面的解释是错的。我们再试一次,这次是对的。
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
。increment
包含一个函数定义。函数定义通过createCounter
返回。它不再是myFunction
这个标记,但是是相同的定义。全局上下文中,被叫做increment
。 - 10行,声明新的变量
c1
。 - 10行,查询变量
increment
,它是一个函数,调用它。它包含从前面返回的函数定义,第4-5行中所定义的。 - 创建新的执行上下文,没有参数,执行函数。
- 第4行,我们需要寻找变量
counter
。我们从局部和全局执行上下文中查找前,先看下闭包。你看,闭包中包含了一个变量counter
,值为0。在第4行表达后,植被设置为了1。再次被存储到闭包中。现在闭包包含一个值为1的变量counter
。 - 第5行,我们返回了数字1,并摧毁局部执行上下文。
- 再到第10行,返回值1被赋值给了
c1
。 - 第11行,重复10-14步。这次,当我们查看闭包的时候,看到了值为1的变量
counter
。在第4步或者程序的第4行被设置。在增量函数的闭包它的值被叠加,并且存储为2。c2
被赋值为2。 - 12行,重复10-14步,
c3
被赋值1。 - 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。
结论
我能够记住闭包的方式是通过背包的比喻。当一个函数被创建并传递或从另一个函数返回时,它会携带一个背包,并且背包里是函数声明时作用域内的变量。