我从来都不理解闭包

841 阅读19分钟

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


正如标题所述,JavaScript 闭包对我来说一直是一个谜。为此,我读过很多文章,工作中我也用过闭包,有些时候,我甚至都不知道使用了闭包。

最近,我和一些人讨论了一下,他们真正的点醒了我。在这篇文章中,我将会尝试解释一下闭包。首先,我要感谢一下 CodeSmith 和他们的JavaScript 的课程

前言

为了理解闭包,有些概念非常重要。其中之一就是执行上下文

这篇文章很好的解释了什么是执行上下文。以下是引用:

当执行 JavaScript 代码时,执行环境非常重要,并且会按照以下情况计算:

全局代码 — 当代码第一次执行时,默认的执行环境

函数代码 — 在函数体内执行的代码

执行上下文其实就是当前代码执行的环境/作用域。

换句话说,程序开始时,是在全局的执行上下文中。有些变量是在全局上下文中被声明定义的。我们称之为全局变量。当程序在函数中执行,会发生什么呢?会有以下几步:

  1. JavaScript 会创建一个本地的全新上下文
  2. 本地的上下文会有它内部的变量,这些变量属于当前的本地执行上下文
  3. 新的执行上下文将会被压入执行栈中。执行栈就是为了跟踪程序在哪执行的机制。

函数何时结束?当遇到 return 语句或者遇到闭合的大括号 }。当函数结束时,会依次发生以下情况:

  1. 本地执行上下文会从执行栈中被弹出
  2. 调用上下文将会得到函数的返回值。调用上下文就是函数被调用时的执行上下文,它可是全局执行上下文或者另外一个本地执行上下文。此时,调用上下文会处理函数的返回值。返回值可以是对象、数组、函数、布尔值、任何值。如果,函数没有 return 语句,默认,将会返回 undefined
  3. 接下来,本地执行上下文将会被销毁。这个很重要。销毁代表着所有在其内部声明的变量都会被抹除。它们不再可用。这也是为什么把它们称为本地变量。

一个简单的演示

解释闭包之前,我们先看一下以下的代码片段。它看起非常简单直接,任何人都知道会发生什么。

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. 接下来会变得棘手。第二行到第五行是一个整体。发生了什么呢?在全局执行上下文中我们声明了一个名为 addTwo 的新变量。我们给它分配什么呢?函数定义。两个大括号 { } 任何代码都会分配给 addTwo。函数内部的代码,这时并不会被计算,也不会执行,只是存储在一个变量中以便将来使用。

  3. 现在,我们来到了第六行。看起非常简单,但是,这里包含了很多东西。首先,在全局执行上下文中,我们声明了一个新的变量 b。变量声明的同时,也会赋值 undefined

  4. 接下来,仍然是第六行,我们看到有一个赋值操作符。这时,我们才真正赋值给变量 b 。接下来,我们看到函数被调用了。当你看到一个变量后面跟着一个圆扣号 (),那代表着函数调用执行。如前所述,每个函数都会返回一些值(值、对象或者 undefined)。不管,函数返回什么都会赋值给变量 b

  5. 但是,首先我们需要调用函数 addTwo。JavaScript 将会在全局执行上下文中查找一个名为 addTwo 的变量。是的,它找到了,在第二步定义的(第二行到第五行)。你看,变量 addTwo 是一个函数。注意,变量 a 作为一个参数传递给了函数。JavaScript 会在全局执行上下文中搜索变量 a 并找到它,发现它的值是 3,然后,数字 3 就传递给了函数。准备开始执行函数。

  6. 现在,执行上下文发生了改变。一个新的本地执行上下文被创建,我们叫它 “addTwo 执行上下文”。这个执行上下文被压入到调用栈。在本地执行上下文中我们首先要做什么呢?

  7. 你可能会说:“一个新的变量 ret本地执行上下文中被声明了”。这不对,正确的答案是,首先,我们需要看一下函数的参数。在本地执行上下文中声明了一个新的变量 x 。然后,由于数字 3 做为参数传递给了函数,那么,变量 x 就的值就变成了 3

  8. 下一步:本地执行上下文声明了新的变量 ret。它的值是 undefined(第三行)

  9. 仍旧是第三行,需要执行加法。首先,我们需要用到 x 的值。JavaScript 将会查找变量 x。首先,它会在本地执行上下文中查找。而且,找到了,它的值是 3。第二个操作数是数字 2。两者相加之后的结果(5)将会赋值给变量 ret

  10. 第四行。我们会返回变量 ret。另外,根据本地执行上下文的内容得知 ret 的值是 5。函数将会返回数字 5。这时,函数结束。

  11. 函数在第四到第五行结束。本地执行上下文也随之被销毁。变量 xret 同时被抹除。它们将会消失。调用栈也会弹出响应的上下文,返回值将会返回到调用上下文。在这个案例中,调用栈就是全局执行上下文,这是因为,函数 addTwo 是在全局执行上下文中被调用的。

  12. 现在,我们重新回到第四步。返回值(数字5)赋值给了变量 b。我们仍旧在程序的第六行。

  13. 我不用详解介绍,在第七行,变量 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)。根据这个规则,上面的示例就很清晰了。如果,你清楚作用域是如何工作的,你可以跳过这部分。

  1. 在全局执行上下文中声明一个变量 val1,然后,给它赋值数字 2

  2. 第 2 - 5 行。声明了一个新变量 multiplyThis,然后,定义了一个函数

  3. 第 6 行。在全局执行上下文声明了一个变量 multiplied

  4. 在全局执行上下文中找到变量 multiplyThis,并做为一个函数执行。然后,把数字 6 做为参数传递给函数

  5. 函数被调用 = 新的执行上下文。创建新的本地执行上下文

  6. 在本地执行上下文中,声明了变量 n 并赋值了数字 6

  7. 第 3 行。声明了变量 ret

  8. 第 3 行。变量 nvall 两个数的相乘。在本地执行上下文中查找变量 n。我们在第 6 行声明了这个变量。它的值是数字 6。本地上下文中没有找到变量 vall。需要检测调用上下文。因为,调用上下文是全局上下文。我们需要在全局上下文中查找 vall。很好,找到了。它在第 1 行被定义的。它的值是数字 2

  9. 第 3 行。两个数相差,然后赋值给变量 ret。6 * 2 = 12。ret 的值是 12

  10. 返回 ret 的值。随之本地上下文也被销毁,同时销毁的还有变量 retn。变量 vall 并不会被销毁,因为它是全局上下文的一部分。

  11. 回到第 6 行。在调用上下文中,数字 12 被复制给变量 multiplied

  12. 最后的第 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. 第 1 行。我们在全局上下文中声明了变量 val,并且赋值数字 7 给变量

  2. 第 2 - 8 行。在全局上下文中我们声明了一个名为 createAdder 的函数。第 3 - 7 行就是函数的具体定义。和之前一样,这个时候,我们并不会执行函数。我们只是把函数赋值给一个变量(createAdder

  3. 第 9 行。在全局上下文中,我们声明了新的变量 adder。同时,它的值是 undefined

  4. 还是第 9 行。我们看到了一个圆括号();代表着我们需要调用函数。我们在全局上下文中搜索找了到名为 createAdder 的变量。它是在第 2 步创建的。好,我们来调用它。

  5. 调用函数。现在,我们回到第 2 行。一个新的上下文被创建。在新的上下文中我们创建了本地变量。同时,引擎也会把新的上下文压入到调用栈。这个函数没有参数,我们直接看它的内部。

  6. 第 3 - 6 行。我们又声明了一个新的函数。在本地上下文中我们创建变量 addNumber。这个很重要。addNumber 只在本地上下文中有效。在本地上下文中我们定义了一个函数并命名为 addNumber

  7. 现在,我来到第 7 行。我们返回了变量 addNumber。引擎会查找变量 addNumber,当然也会找到它。它是一个函数。好,函数可以返回任何东西,包括函数。因此,我们返回了 addNumbers 的函数体。在第 4 - 5 行就是函数的具体定义。同时,我们也把本地上下文从调用栈中移除。

  8. return 之后,本地上下文也随之销毁。变量 addNumbers 也不存在了。但是,函数的定义仍然存在,它通过 retrun 语句,并赋值给了变量 adder;这个变量,我们是在第 3 步创建的。

  9. 来到第 10 行。在全局上下文中,我们定义了新的变量 sum。并分配了一个临时的值 undefined

  10. 接下来,我们需要执行函数。哪一个函数呢?就是名为 adder 的函数。我们在全局上下文中查找它,可以保证一定能找到它。这个函数需要两个参数

  11. 我们得到了两个参数,并把它们传递了函数。第一个是变量 val,我们在第 1 步定义的,它的值是数字 7,第二个参数是数字 8

  12. 现在,我们来调用函数。这个函数是在第 3 - 5 行被定义的。一个新的本地上下文被创建。在这个上下文中有两个新的变量:ab。它们的值分别是 78,这就是我们在上一步传递给函数的。

  13. 第 4 行。名为 ret 的变量被声明。它只存在本地上下文中。

  14. 第 4 行。我们把变量 ab 相加。相加后的结果(15)赋值给了变量 ret

  15. 变量 ret 通过函数返回。随之,与之相关的本地上下文被销毁,也从调用栈中被移除,变量 abret 也不存在了

  16. 返回的值被赋值给我们在第 9 步定义的变量 sum

  17. 最后,我们在控制台输出了 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. 第 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。此时的变量就是一个函数。这个函数是由 createCounter 返回的。虽然,不是 myFunction,但是,函数体内容是一致的。在全局上下文中,它就是 imcrement

  9. 第 10 行。声明新变量(c1

  10. 第 10 行。调用了函数 increment。这个函数是早期在第 4 - 5 行中定义的

  11. 创建新的上下文。只是执行函数,并没有参数。

  12. 第 4 行。counter = counter + 1。在本地上下文中查找变量 counter。我们只会创建上下文,绝对不会声明任何本地变量。我们看一下全局上下文。并没有变量 counter。因此,刚才的表达式等同于 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 只是有着相同的函数体,并不是 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. 第 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。此时的变量就是一个函数(也包括闭包)。这个函数是由 createCounter 返回的。虽然,不是 myFunction,但是,函数体内容是一致的。在全局上下文中,它就是 imcrement

  9. 第 10 行。声明新变量(c1

  10. 第 10 行。调用了函数 increment。这个函数是早期在第 4 - 5 行中定义的。(并且变量也有一个背包)

  11. 创建新的上下文。只是执行函数,并没有参数。

  12. 第 4 行。counter = counter + 1。我们需要查询变量 counter。在此之前,我在本地或者全局上下文中查找,这次,我们来看一下背包,闭包。你瞧,闭包中包含一个名为 counter 的变量,它的值是 0。经过第 4 行的计算后,它的值变成 1。它也重新存储在背包中。这时闭包中的变量 counter 值变成了 1

  13. 第 5 行。我们返回了 counter 的值,也就是数字 1。同时,销毁本地上下文。

  14. 回到第 10 行。返回的值(1)赋值给了 c1

  15. 第 11 行。重复第 10 - 14 步。这次,当我们查看闭包时,看到变量 counter 的值是 1。这是因为第 12 步导致的。这时,它的值再次被递加得到了 2,并存储在闭包中。同时,c2 的值也是 2

  16. 第 12 行。重复第 10 - 14 步,c3 也得到数字 3

  17. 第 13 行。我们打印变量 c1c2c3 的值

现在,我们已经理解了它是如何工作的了。关键点在于,当函数被声明时,它同时包含函数体和一个闭包。这个闭包会收集创建函数时作用域中的所有变量。

你或许会问,所有的函数都有闭包吗?即使,是在全局作用域中声明?是的。全局作用创建的函数也会创建闭包。但是,由于函数是在全局作用域中被创建,因此,它们可以访问全局作用域中所有的变量。这并不完全跟闭包有关。

当函数返回一个函数时,闭包的就比较重要了。返回的函数可访问那些不在全局作用域,只存在于闭包中的变量。

非同小可的闭包

有时,在你不经意间就会出现闭包。你或许在我们应用中看到过类似的代码。

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

总结

为了记住闭包,我把它比喻为背包。当一个函数被创建并传递或者通过另外一个函数返回,它就会包含一个背包。背包中包含函数声明时作用域中所有的变量。

如果,你喜欢这篇文章,不要吝啬你的赞扬 👏

谢谢