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

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

内容简介:原文:作者:时间: 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 的值。在局部执行上下文中又进行查找 retret 的值为 5 。所以该函数返回数值 5 ,函数结束。
  11. 第 4-5 行。函数结束。局部执行上下文被销毁。变量 xret 被清除,不再存在。调用栈弹出该上下文,返回值返回给调用上下文。在这个例子中,调用上下文是全局执行上下文,因为函数 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 行,两个操作数——变量 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: 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: 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: 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 的值。

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

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

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

并不简单的闭包

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

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

结论

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


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

查看所有标签

猜你喜欢:

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

C#入门经典

C#入门经典

[美] Karli Watson、Christian Nagel / 齐立波、黄静 / 清华大学出版社 / 2008-12 / 118.00元

这是一本成就无数C#程序员的经典名著,厚而不“重”,可帮助您轻松掌握C#的各种编程知识,为您的职业生涯打下坚实的基础,《C#入门经典》自第1版出版以来,全球销量已经达数万册,在中国也有近8万册的销量,已经成为广大初级C#程序员首选的入门教程,也是目前国内市场上最畅销的C#专业店销书,曾两次被CSDN、《程序员》等机构和读者评选为“最受读者喜爱的十大技术开发类图书”!第4版面向C#2008和.NET......一起来看看 《C#入门经典》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具