微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

原来JavaScript的闭包是这么回事!

2018-09-01 15:07 吴留坡 译
作者|Olivier De Meulder
译者|吴留坡

正如标题所述,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. 在第 1 行,我们在全局执行上下文中声明一个新变量 a,并将它的值赋为数字 3。

  2. 在第 2 行到第 5 行,我们在全局执行上下文中声明了一个名为 addTwo 的新变量,并为其分配了一个函数定义,{}之间的内容被分配给了 addTwo。函数内部的代码不会被执行,只是存储在变量中以备将来使用。

  3. 第 6 行,我们在全局执行上下文中声明了一个新变量,并将其标记为 b。声明变量后,它的值为 undefined。

  4. 接下来,仍然是第 6 行,我们看到了一个赋值运算符。我们准备为变量 b 分配一个新值。接下来,我们看到一个被调用的函数。当你看到一个变量后面跟着圆括号 (...) 时,就表示在调用一个函数。从函数返回的任何内容都将被分配给变量 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 行)仍然是第 3 行,需要执行一个加法运算。首先,我们需要 x 的值,JavaScript 会尝试查找变量 x,它首先查看本地执行上下文。它找到了,值为 3。第二个操作数是数值 2,加法的结果(5)被赋给变量 ret。

  9. 第 4 行,我们返回变量 ret 的内容。在本地执行上下文中进行另一个查找。ret 包含值 5。函数返回数值 5,函数结束执行。

  10. 第 4-5 行,函数结束执行,本地执行上下文被销毁,变量 x 和 ret 被清除,它们不再存在。上下文弹出调用栈,返回值被返回到调用上下文。在这种情况下,调用上下文就是全局执行上下文,因为函数 addTwo 是从全局执行上下文中调用的。

  11. 现在我们从在步骤 4 中暂停的位置继续。返回值(数值 5)被分配给变量 b。

  12. 在第 7 行,变量 b 的内容会在控制台中打印出来。在这个例子中,数值为 5。

  13. 对于一个非常简单的程序来说,这样的解释显得太过冗长,但我们甚至都还没有提到闭包。我保证会说到那里,但首先我们需要说一些其他的。

词法作用域

我们需要了解词法作用域的某些方面,看下面的例子:

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 行,用两个操作数执行乘法运算:变量 n 和 val1 的内容。在本地执行上下文中查找变量 n,我们在步骤 6 中声明了它,它的内容是数值 6。在本地执行上下文中查找变量 val1,本地执行上下文中没有标记为 val1 的变量。我们从调用上下文中查找,调用上下文也就是全局执行上下文。让我们在全局执行上下文中查找 val1。是的,它就在那里。它在步骤 1 中定义,值为数值 2。

  9. 第 3 行,将两个操作数相乘,并将结果指定给变量 ret。6 * 2 = 12。ret 现在是 12。

  10. 返回 ret 变量,本地执行上下文及其变量 ret 和 n 被销毁。变量 val1 不会被销毁,因为它是全局执行上下文的一部分。

  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 行,我们有一个新的函数声明,我们在本地执行上下文中创建变量 addNumbers,这很重要。addNumbers 仅在本地执行上下文中存在,我们将函数定义存储在名为 addNumbers 的局部变量中。

  7. 第 7 行,我们返回变量 addNumbers 的内容。引擎查找名为 addNumbers 的变量,这是一个函数定义。一个函数可以返回任何东西,包括函数定义,所以我们返回 addNumbers 的定义。第 4 行和第 5 行的括号之间的任何内容构成了函数定义,我们还从调用栈中删除了本地执行上下文。

  8. 在 return 语句之后,本地执行上下文被销毁,addNumbers 变量也不复存在,但函数定义仍然存在,它从函数返回并赋给变量 adder,这是我们在第 3 步中创建的变量。

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

  10. 接下来我们需要执行一个函数,哪个函数?在名为 adder 的变量中定义的函数。我们在全局执行环境中查找它,这是一个带两个参数的函数。

  11. 我们先拿到两个参数,这样就可以调用函数并将正确的参数传给它。第一个是变量 val,在步骤 1 中定义的,它代表数值 7,第二个是数值 8。

  12. 现在我们要执行这个函数,函数体是在第 3-5 行定义的。创建一个新的本地执行上下文。在本地上下文中,创建了两个新变量:a 和 b。它们分别被赋值为 7 和 8,因为它们是在上一步传给函数的参数。

  1. 第 4 行,在本地执行上下文中声明一个新变量 ret。

  2. 第 4 行,执行加法运算,其中我们变量 a 的内容和变量 b 的内容相加,再将相加的结果(15)赋给变量 ret。

  3. 从函数返回变量 ret,本地执行上下文被销毁,并从调用栈中删除,变量 a、b 和 ret 不再存在。

  4. 返回的值被赋给我们在步骤 9 中定义的 sum 变量。

  5. 我们将 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)

现在让我们来看看这段代码将如何执行:

  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 变量的内容。删除本地执行上下文,myFunction 和 counter 不再存在,控制权返回到调用上下文。

  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。我们销毁本地执行上下文和变量 conter。

  14. 第 10 行,返回值(1)被分配给 c1。

  15. 第 11 行,我们重复步骤 10-14,c2 也被赋值为 1。

  16. 第 12 行,我们重复步骤 10-14,c3 也被赋值为 1。

  17. 第 13 行,我们记录变量 c1、c2 和 c3 的值。

亲自尝试一下,看看会发生什么。你会注意到它并不像你预想地那样输出 1、1 和 1,而是输出了 1、2 和 3。为什么会这样?

increment 函数会记住 counter 的值,为什么会这样?

counter 是全局执行上下文的一部分吗?试试 console.log(counter),你将得到 undefined,所以它不是。

所以,肯定存在另一种被我们忽略的机制——也就是闭包。

下面是它的工作原理。每当声明一个新函数并将其赋值给变量时,实际上是保存了函数定义和闭包。闭包包含了创建函数时声明的所有变量,就像一个背包一样——函数定义附带一个小背包。这个背包保存了创建函数时声明的所有变量。

所以我们上面的解释都是错误的,让我们再试一次,但这次是正确的:

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)
  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 变量的内容,删除本地执行上下文。myFunction 和 counter 不再存在,控制权返回到调用上下文。所以我们返回函数定义及其闭包,闭包中包含创建函数时声明的变量。

  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,销毁本地执行上下文。

  14. 返回第 10 行,返回值(1)被分配给 c1。

  15. 第 11 行,我们重复步骤 10-14。这次,我们可以看到变量 counter 的值为 1,这个值是在第 4 行代码中设置的。它的值加 1,并在 increment 函数的闭包中存为 2。c2 被赋值为 2。

  16. 第 12 行,我们重复步骤 10-14,c3 被设为 3。

  17. 第 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。

 结  论 

我通过背包类比的方式记住了闭包。当创建和传递一个函数或将其从另一个函数返回时,这个函数就带有一个背包,背包中包含了所有在创建函数时声明的变量。

  英文原文

https://medium.com/dailyjs/i-never-understood-javascript-closures-9663703368e8

  课程推荐

微服务是当下最火热的后端架构之一。不管你是一个什么级别的程序员,也不论你在一个什么体量的公司,服务化都是你迟早会遇到的难题。号称能够支持 8 位明星并发出轨的微服务系统是如何设计的,微博技术专家胡忠想老师在《从 0 开始学微服务》专栏中给出答案。



 
前端之巅 更多文章 专访尤雨溪:先别管4.0了,Vue CLI重构了解一下 Chrome十周年版更新了,你第一次用它是什么时候? 前端要凉?微软开源Sketch2Code,草图秒变代码 JS可以写操作系统?Windows 95被装进Electron App 从TensorFlow.js入手了解机器学习
猜您喜欢 从 UNIX 到 GitHub:十个关于自由和开源软件历史的重要事件 大数据告诉你,当父母的真不容易 思考机器人、瞬间变脸面具、纳米卫星……腾讯在悄悄捣鼓啥? ASP.NET + SqlSever 大数据解决方案 PK HADOOP 深入浅出zookeeper之一:功能及本质