[译] 我从没理解过 JavaScript 闭包

| Posted by hhking on 2018-09-08

原文: I never understood JavaScript closures
作者: Olivier De Meulder
时间: Sep 7, 2017
译注:作者从 JavaScript 的原理出发,详细解读执行过程,通过“背包”的形象比喻,来解释闭包。

images

我从没理解过 JavaScript 闭包
直到有人这样跟我解释……

正如标题所说,JavaScript 闭包对我来说一直是个迷。我 看过 很多 文章,在工作中用过闭包,甚至有时候我都没有意识到我在使用闭包。

最近参加一个交流会,有人用某种方式向我解释了闭包,点醒了我。这篇文章我也将用这种方式来解释闭包。这里要称赞一下 CodeSmith 的优秀人才和他们的《JavaScript The Hard Parts》系列。

开始之前

在理解闭包之前,一些重要的概念需要理解。其中一个就是 执行上下文(execution context)

这篇文章 对执行上下文有很好的介绍。引用一下这篇文章:

JavaScript 代码在执行时,它的执行环境非常重要,它会被处理成下面的某一种情况:

全局代码(Global code) —— 代码开始执行时的默认环境。

函数代码(Function code) —— 当执行到函数体时。

(…)

(…), 我们把术语 执行上下文(execution context) 称为当前执行代码所处的 环境或者作用域

换句话说,当我们开始执行程序时,首先处于全局上下文中。在全局上下文中声明的变量,称为全局变量。当程序调用函数时,会发生什么?发生下面这几步:

  1. JavaScript 创建一个新的执行上下文 —— 局部执行上下文。
  2. 这个局部执行上下文有属于它的变量集,这些变量是这个执行上下文的局部变量。
  3. 这个新的执行上下文被压入执行栈中。将执行栈当成是用来跟踪程序执行位置的一种机制。

函数什么时候执行完?当遇到 return 语句或者结束括号 } 时。函数结束时,发生下面情况:

  1. 局部执行上下文从执行栈弹出。
  2. 函数把返回值返回到调用上下文。调用上下文是指调用该函数的的执行上下文,它可以是全局执行上下文也可以是另外一个局部执行上下文。这里的返回值怎么处理取决于调用执行上下文。返回值可是 object, array, function, boolean 等任何类型。如果函数没有 return 语句,那么返回值是 undefined
  3. 局部执行上下文被销毁。这点很重要 —— 被销毁。所有在局部执行上下文中声明的变量都被清除。这些变量不再可用。这也是为什么称它们为局部变量。

一个非常简单的例子

在开始学习闭包之前,我们先来看下下面这段代码。它看起来很简单,所有的读者应该都能清楚的知道它的作用。

1
2
3
4
5
6
7
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 引擎的真正工作原理,我们来详细解释一下。

  1. 在代码第一行,我们在全局执行上下文声明了一个新的变量 a,并赋值为 3
  2. 接下来比较棘手了。第 2 到第 5 行属于一个整体。这里发生了什么呢?我们在全局执行上下文声明了一个变量,命名为 addTwo。然后我们怎么对它赋值的?通过函数定义。所有在两个括号 {} 之间的内容都被赋给 addTwo。函数里的代码不计算、不执行,只是保存在变量,留着后面使用。
  3. 现在我们到了第 6 行。看似很简单,其实这里有很多需要解读。首先我们在全局执行上下文声明了一个变量,标记为 b。当变量刚声明时,它的默认值是 undefined
  4. 接着,还是在第 6 行,我们看到有个赋值运算符。我们准备给变量 b 赋新值。接着看到一个将要被调用的函数。当你看到变量后面跟着圆括号 (...) ,那就是函数调用的标识。提前说下后面的情况:每个函数都有返回值(一个值、一个对象或者是 undefined)。函数的返回值将被赋值给变量 b
  5. 但是(在赋值前)我们首先要调用函数 addTwo。JavaScript 将在全局执行上下文内存中查找变量 addTwo。找到了!它在第 2 步(第 2-5 行)中定义,你瞧,变量 addTwo 包含函数定义。注意,变量 a 当做参数传给了函数。JavaScript 在全局执行上下文内存中寻找变量 a,找到并发现它的值是 3,然后把数值 3 做为参数传给函数。函数执行准备就绪。
  6. 现在执行上下文将会切换。一个新的局部执行上下文被创建,我们把它命名为 “addTwo 执行上下文”。该执行上下文被压入调用栈。在局部执行上下文中首先做些什么事呢?
  7. 你可能会想说:“在局部执行上下文中声明一个新的变量 ret ”。然后答案不是这样。正确答案是:我们首先需要查看函数的参数:在局部执行上下文中声明新的变量 x,因为值 3 作为参数传给函数,所以变量 x 赋值为数值 3
  8. 下一步:局部执行上下文中声明新变量 ret。它的值默认为 undefined。(第3行)
  9. 还是第 3 行,准备执行加法。我们首先需要获取 x 的值。JavaScript 将寻找变量 x。首先在局部执行上下文中寻找。找到变量 x 的值为 3。第二个操作数是数值 2,加法的结果(5)赋值给变量 ret
  10. 第 4 行。我们返回变量 ret 的值。在局部执行上下文中又进行查找 retret 的值为 5。所以该函数返回数值 5,函数结束。
  11. 第 4-5 行。函数结束。局部执行上下文被销毁。变量 xret 被清除,不再存在。调用栈弹出该上下文,返回值返回给调用上下文。在这个例子中,调用上下文是全局执行上下文,因为函数 addTwo 是在全局执行上下文中调用的。
  12. 现在回到我们在第 4 步遗留的内容。返回值(数值 5)复制给变量 b。在这个小程序中,我们还在第 6 行。
  13. 下面我不再详细说明了。在第 7 行,变量 b 的值在 console 中打印出来。在我们的例子里将打印出数值 5

对一个简单的程序,这真是个冗长的解释!而且我们甚至还没涉及到闭包。我保证一定会讲解闭包的。但是我们还是需求绕一两次。

词法作用域 (Lexical scope)

我们需要理解词法作用域的一些知识点。看看下面的例子:

1
2
3
4
5
6
7
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 )。按照上面的例子来说明,它会验证这点。如果你理解作用域的原理,你可以跳过这部分。

  1. 在全局执行上下文声明一个新变量 val1 ,并赋值为数值 2
  2. 第 2-5 行声明新变量 multiplyThis 并赋值为函数定义。
  3. 第 6 行,在全局执行上下文声明新变量 multiplied
  4. 在全局执行上下文内存中获取变量 multiplyThis 并作为函数执行。传入参数数值 6
  5. 新函数调用 = 新的执行上下文:创建新的局部执行上下文。
  6. 在局部执行上下文中,声明变量 n 并赋值为数值 6
  7. 第 3 行,在局部执行上下文中声明变量 ret
  8. 还是第 3 行,两个操作数——变量 nval1 的值执行乘法运算。先在局部执行上下文查找变量 n,它是我们在第 6 步中声明的,值为数值 6。接着在局部执行上下文查找变量 val1,在局部执行上下文没有找到名为 val1 的变量,所以我们检查调用上下文中。这里调用上下文是全局执行上下文。我们在全局执行上下文中找到它,它在第 1 步中被定义,值为数值 2
  9. 依旧是第 3 行。两个操作数相乘然后赋值给变量 ret。6 * 2 = 12。ret 现在值为 12
  10. 返回变量 ret。局部执行上下文以及相应的变量 retn 一起被销毁。变量 val1 作为全局执行上下文的一部分没有被销毁。
  11. 回到第 6 行。在调用上下文中,变量 multiplied 被赋值为数值 12
  12. 最后在第 7 行,我们在 console 中显示变量 multiplied 的值。

在这个例子中,我们需要记住,函数可以访问到它调用上下文中定义的变量。这种现象正式学名是 词法作用域

(译者注:觉得这里对词法作用域的解释限于此例,并不完全准确。词法作用域,函数的作用域是在函数定义的时候决定的,而不是调用时)。

返回值是函数的函数

在第一个例子里函数 addTwo 返回的是个数值。记得之前提过函数可以返回任何类型。我们来看个函数返回函数的例子,这个是理解闭包的关键点。下面是我们要分析的例子。

1
2
3
4
5
6
7
8
9
10
11
 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. 第 1 行,我们在全局执行上下文声明变量 val 并赋值为数值 7
  2. 第 2-8 行,我们在全局执行上下文声明变量 createAdder 并赋值为函数定义。第 3-7 行表示函数定义。和前面所说,这时候不会进入函数,我们只是把函数定义保存在变量 (createAdder)。
  3. 第 9 行,我们在全局执行上下文声明名为 adder 的新变量,暂时赋值为 undefined
  4. 还是第 9 行,我们看到有括号 (),知道需要执行或者调用函数。我们从全局执行上下文的内存中查找变量 createAdder,它在第 2 步创建。ok,现在调用它。
  5. 调用函数,我们现在处于第 2 行。新的局部执行上下文被创建。我们可以在新的执行上下文中创建局部变量。JavaScript 引擎把新的上下文压入调用栈。该函数没有参数,我们直接进入函数体。
  6. 还是在 3-6 行。我们声明了个新函数。我们在局部执行上下文中创建了新的变量 addNumbers,这点很重要,addNumbers 只在局部执行上下文中出现。我们使用局部变量 addNumbers 保存了函数定义。
  7. 现在到了第 7 行。我们返回变量 addNumbers 的值。JavaScript 引擎找到 addNumbers 这个变量,它是个函数定义。这没问题,函数可以返回任意类型,包括函数定义。所以我们返回了 addNumbers 这个函数定义。括号中的所有内容——第 4-5 行组成了函数定义。我们也从调用栈中移除了该局部执行上下文。
  8. 局部执行上下文在返回时销毁了。addNumbers 变量不存在了,但是函数定义还在,它被函数返回并赋值给了变量 adder —— 我们在第 3 步创建的变量。
  9. 现在到了第 10 行。我们在全局执行上下文中定义了新变量 sum,暂时赋值是 undefined
  10. 接下来需要需要执行函数。函数定义在变量 adder 中。我们在全局执行上下文中查找并确保找到了它。这个函数带有两个参数。
  11. 我们获取这两个参数,以便能调用函数并传入正确的参数。第一个参数是变量 val,在第 1 步中定义,表示数值 7 , 第二个参数是数值 8
  12. 现在我们开始执行函数。该函数在定义在 3-5 行。新的局部执行上下文被创建,同时创建了两个新变量:ab,他们分别赋值为 78,这是上一步提到的传给函数的参数。
  13. 第 4 行,声明变量 ret。它是在局部执行上下文中声明的。
  14. 第 4 行,进行加法运算:我们让变量 a 和变量 b 的值相加。相加的结果(15)赋值给变量 ret
  15. 函数返回变量 ret 。局部执行上下文销毁,从调用栈中移除,变量 abret 都不存在了。
  16. 返回值赋值给在第 9 步定义的变量 sum
  17. 在 console 中打印 sum 的值。

正如所预期的,console 打印出 15,但是这个过程我们真的经历了很多困难。我想在这里说明几点。首先,函数定义可以保存在变量中,函数定义在执行前对程序是不可见的;第二点,每次函数调用,都会创建一个局部执行上下文(临时的),局部执行上下文在函数结束后消失,函数在遇到 return 语句或者右括号 } 时结束。

最后,闭包

看看下面的代码,会发生什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
 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. 1-8 行。我们在全局执行上下文创建了变量 createCounter 并赋值为函数定义。
  2. 第 9 行。在全局执行上下文声明变量 increment
  3. 还是第 9 行。我们需要调用函数 createCounter 并把它的返回值赋值给变量 increment
  4. 1-8 行,函数调用,创建新的局部执行上下文。
  5. 第 2 行,在局部执行上下文中声明变量 counter,并赋值为数值 0
  6. 3-6 行,声明名为 myFunction 的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。
  7. 第 7 行,返回变量 myFunction 的值。局部执行上下文被删除了,myFunctioncounter 也不存在了。程序控制权回到调用上下文。
  8. 第 9 行。在调用上下文,也是全局执行上下文中,createCounter 的返回值赋给 increment。现在变量 increment 包含一个函数定义。该函数定义是 createCounter 返回的。它不再是标记为 myFunction,但是是同一个函数定义。在全局执行上下文中,它被命名为 increment
  9. 第 10 行,声明变量 c1
  10. 继续第 10 行,寻找变量 increment,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。
  11. 创建新的执行上下文,这里没有参数,开始执行函数。
  12. 第 4 行,counter = counter + 1。在局部执行上下文寻找 counter 的值。我们只是创建了上下文而没有声明任何局部变量。我们看看全局执行上下文,也没有变量 counter。JavaScript 会把这个转化成 counter = undefined + 1,声明新的局部变量 counter 并赋值为数值 1,因为 undefined 会转化成 0
  13. 第 5 行,我们返回 counter 的值,或者说数值 1。销毁局部执行上下文和变量 counter
  14. 回到第 10 行,返回值(1)赋给 c1
  15. 第 11 行,重复第 10-14 的步骤,最后 c2 也赋值为 1
  16. 第 12 行,重复第 10-14 的步骤,最后 c3 也赋值为 1
  17. 第 13 行,我们打印出变量 c1c2c3 的值。

自己尝试一下这个,看看会发生什么。你会发现,打印出来的并不是上面解释的预期结果 111,而是打印出 123。所以发生了什么?

不知道为什么,increment 函数记住了 counter 的值。这是怎么实现的呢?

是不是因为 counter 是属于全局执行上下文?试试 console.log(counter),你会得到 undefined。所以它并不是。

或许,是因为当你调用 increment 时,它以某种方式返回创建它的函数(createCounter)的地方?这是怎么回事呢?变量 increment 包含函数定义,而不是它从哪里创建。所以并不是这个原因。

所以这里肯定存在另一种机制。它就是闭包。我们终于讲到它了,一直缺失的部分。

下面是它的工作原理。只要你声明一个新的函数并赋值给一个变量,你就保存了这个函数定义,也就形成了闭包。闭包包含函数创建时的作用域里的所有变量。这类似于一个背包。函数定义带着一个背包,包里保存了所有在函数定义创建时作用域里的变量。

所以我们上面的解释全错了。我们重新来一遍,这次是正确的。

1
2
3
4
5
6
7
8
9
10
11
12
13
 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. 1-8 行。我们在全局执行上下文创建了变量 createCounter 并赋值为函数定义。同上。
  2. 第 9 行。在全局执行上下文声明变量 increment。同上。
  3. 还是第 9 行。我们需要调用函数 createCounter 并把它的返回值赋值给变量 increment。同上。
  4. 1-8 行,函数调用,创建新的局部执行上下文。同上。
  5. 第 2 行,在局部执行上下文中声明变量 counter,并赋值为数值 0。同上。
  6. 3-6 行,声明名为 myFunction 的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。现在我们同时 创建了一个闭包 并把它作为函数定义的一部分。闭包包含了当前作用域里的变量,在这里是变量 counter (值为 0)。
  7. 第 7 行,返回变量 myFunction 的值。局部执行上下文被删除了,myFunctioncounter 也不存在了。程序控制权回到调用上下文。所以我们返回了函数定义和它的 闭包 —— 这个背包包含了函数创建时作用域里的变量。
  8. 第 9 行。在调用上下文,也是全局执行上下文中,createCounter 的返回值赋给 increment。现在变量 increment 包含一个函数定义(和闭包)。该函数定义是 createCounter 返回的。它不再是标记为 myFunction,但是是同一个函数定义。在全局执行上下文中,它被命名为 increment
  9. 第 10 行,声明变量 c1
  10. 继续第 10 行,寻找变量 increment,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。(同时它也有个包含变量的背包)
  11. 创建新的执行上下文,这里没有参数,开始执行函数。
  12. 第 4 行,counter = counter + 1。我们需要寻找变量 counter。我们在局部或者全局执行上下文寻找前,先查看我们的背包。我们检查闭包。你瞧!闭包里包含变量 counter,值为 0。通过第 4 行的表达式,它的值设为 1。它继续保存在背包里。现在闭包包含值为 1 的变量 counter
  13. 第 5 行,我们返回 counter 的值,或者说数值 1。销毁局部执行上下文和变量 counter
  14. 回到第 10 行,返回值(1)赋给 c1
  15. 第 11 行,重复第 10-14 的步骤。这次,当我们查看闭包时,我们看到变量 counter 的值为 1。它是在第 12 步(程序第 4 行)设置的。通过 increment 函数,它的值增加并保存为 2。 最后 c2 也赋值为 2
  16. 第 12 行,重复第 10-14 的步骤,最后 c3 也赋值为 3
  17. 第 13 行,我们打印出变量 c1c2c3 的值。

现在我们理解它的原理了。需要记住的关键点是,但函数声明时,它包含函数定义和一个闭包。闭包是函数创建时作用域内所有变量的集合。

你可能会问,是不是所有函数都有闭包,即使是在全局作用域下创建的函数?答案是肯定的。全局作用域下创建的函数也生成闭包。但是既然函数是在全局作用域下创建的,他们可以访问全局作用域下的所有变量。所以这和闭包的概念不相关。

当函数的返回值是一个函数时,闭包的概念就变得更加相关了。返回的函数可以访问不在全局作用域里的变量,但它们只存在于闭包里。

并不简单的闭包

有时候,你可能都没有注意到闭包的生成。你可能在偏函数应用看到过例子,像下面这段代码:

1
2
3
4
5
let c = 4
const addX = x => n => n + x
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)

如果箭头函数让你难以理解,下面是等价的代码:

1
2
3
4
5
6
7
8
9
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 是通过参数传入,所以函数可以返回相加的和。

这个例子 console 会打印出数值 7

结论

我牢牢记住闭包的方法是通过 背包的比喻 。当一个函数被创建、传递或者从另一个函数中返回时,它就背着一个背包。背包里是函数声明时的作用域里的所有变量。


如果这篇文章对你有帮助,那么不妨?