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

栏目: JavaScript · 发布时间: 8年前

内容简介:原文:作者:时间: Sep 7, 2017

原文: I never understood JavaScript closures

作者: Olivier De Meulder

时间: Sep 7, 2017

译注:作者从 JavaScript 的原理出发,详细解读执行过程,通过“背包”的形象比喻,来解释闭包。

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

我从没理解过 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: 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 的值。在局部执行上下文中又进行查找 ret 。 ret 的值为 5 。所以该函数返回数值 5 ,函数结束。
  11. 第 4-5 行。函数结束。局部执行上下文被销毁。变量 x 和 ret 被清除,不再存在。调用栈弹出该上下文,返回值返回给调用上下文。在这个例子中,调用上下文是全局执行上下文,因为函数 addTwo 是在全局执行上下文中调用的。
  12. 现在回到我们在第 4 步遗留的内容。返回值(数值 5 )复制给变量 b 。在这个小程序中,我们还在第 6 行。
  13. 下面我不再详细说明了。在第 7 行,变量 b 的值在 console 中打印出来。在我们的例子里将打印出数值 5 。

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

词法作用域 (Lexical scope)

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

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 的变量,所以我们检查调用上下文中。这里调用上下文是全局执行上下文。我们在全局执行上下文中找到它,它在第 1 步中被定义,值为数值 2 。
  9. 依旧是第 3 行。两个操作数相乘然后赋值给变量 ret 。6 * 2 = 12。 ret 现在值为 12 。
  10. 返回变量 ret 。局部执行上下文以及相应的变量 ret 和 n 一起被销毁。变量 val1 作为全局执行上下文的一部分没有被销毁。
  11. 回到第 6 行。在调用上下文中,变量 multiplied 被赋值为数值 12 。
  12. 最后在第 7 行,我们在 console 中显示变量 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 步创建。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 行。新的局部执行上下文被创建,同时创建了两个新变量: a 和 b ,他们分别赋值为 7 和 8 ,这是上一步提到的传给函数的参数。
  13. 第 4 行,声明变量 ret 。它是在局部执行上下文中声明的。
  14. 第 4 行,进行加法运算:我们让变量 a 和变量 b 的值相加。相加的结果(15)赋值给变量 ret 。
  15. 函数返回变量 ret 。局部执行上下文销毁,从调用栈中移除,变量 a 、 b 和 ret 都不存在了。
  16. 返回值赋值给在第 9 步定义的变量 sum 。
  17. 在 console 中打印 sum 的值。

正如所预期的,console 打印出 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 的值。局部执行上下文被删除了, 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 。销毁局部执行上下文和变量 counter 。
  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 。所以它并不是。

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

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

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

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

 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 的值。局部执行上下文被删除了, 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 。销毁局部执行上下文和变量 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 行,我们打印出变量 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 是通过参数传入,所以函数可以返回相加的和。

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

结论

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


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。

R数据科学

R数据科学

[新西兰] 哈德利 • 威克姆、[美] 加勒特 • 格罗勒芒德 / 陈光欣 / 人民邮电出版社 / 2018-7 / 139.00元

本书的目标是教会读者使用最重要的数据科学工具,从而为实施数据科学奠定坚实的基础。读完本书后,你将掌握R语言的精华,并能够熟练使用多种工具来解决各种数据科学难题。每一章都按照这样的顺序组织内容:先给出一些引人入胜的示例,以便你可以整体了解这一章的内容,然后再深入细节。本书的每一节都配有习题,以帮助你实践所学到的知识。一起来看看 《R数据科学》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试