我从没理解js的闭包,直到他人向我这么解释。。。

前段时间根据执行上下文写过一次闭包,但是写的简陋些。昨天在twitter上看到这篇文章,感觉背包的比喻挺恰当的。所以就翻译了。

这篇文章有些啰嗦,但是讲解很细,希望还是耐心看完。也欢迎指出错误。

原地址

如题所述,闭包对我有很强的神秘感。我读过许多的文章,在工作中使用闭包,有时我甚至在没有意识到使用闭包的情况下使用了闭包。

开始前

在你了解闭包之前,有些概念是很重要的。其中一个就是执行上下文

可以通过这篇文章了解下执行上下文。引用下里面的一些内容:

当代码在JavaScript中运行时,其执行的环境非常重要,通常是以下之一:

Global code-默认环境,代码第一次执行的时候。

Function code-代码执行进入函数体中。

(...)

(...)让我们思考下这个术语执行上下文,是作为当前代码的执行环境/作用域。

换句话说,当我们执行一个程序的时候,先从全局执行上下文开始。一些变量在全局执行上下文中被声明。我们成这些为全局变量。当程序调用函数时,会发生什么?有下面几个步骤:

  1. javascript创造一个新的执行上下文,局部执行上下文。
  2. 该局部执行上下文拥有自己的一组变量,这些变量在该执行上下文中是局部的。
  3. 新的执行上下文被抛出到执行栈上。执行栈可以看作是跟踪程序执行过程的机制。

一个function什么时候结束呢?当它遇到一个return语句或者碰到一个右括号}。当一个函数结束后,接下来会发生:

  1. 局部执行上下文弹出执行栈。
  2. 这些函数将返回值发送回调用上下文。调用上下文是调用此函数的执行上下文,是全局执行上下文或者另一个局部执行上下文。在那个时候处理返回值取决于调用执行上下文。返回的值可以是一个对象,一个数组,一个函数,一个布尔值,任何都可以。如果这个函数没有return语句,则返回undefined
  3. 局部上下文被摧毁。这是很重要的。摧毁。在局部上下文内声明的变量会被抹除掉。它们不在可用。这就是为什么他们被称作局部变量

一个非常基本的例子

在开始闭包之前,看下下面这段代码。看上去非常简单,任何阅读这篇文章的人都能知道它究竟做了什么。

let a = 3;
function addTwo(x) {
   let ret = x + 2;
   return ret;
}
let b = addTwo(a);
console.log(b);

为了理解JavaScript引擎是怎么样工作的,我们详细分析下。

  1. 第一行在全局执行上下文中声明了一个新的变量a,并且赋值一个数字3
  2. 接下来变得棘手。第二行和第五行是一块的。这里面发生了什么?在全局执行上下文中,我们声明了一个新的变量,命名addTwo。我们给它赋值什么?定义一个函数。花括号{}里无论是什么都被赋值给了addTwo。函数内的代码不会被求值,也不会被执行,只是存储在一个变量中以备将来使用。
  3. 现在到第6行。看上去很简单,但是有很多东西需要挖掘出来。首先在全局执行上下文中声明一个新的变量,并且标记为b。只要变量被声明,它的值就是undefined
  4. 接下来,仍然是第6行,我们看到一个赋值操作符。我们正在准备为变量b赋值一个新值。然后会看到一个函数被调用。当我们看到一个函数后面跟着一个圆括号(...),这是一个函数正在被调用的一个信号。每个函数都会返回一些东西(一个值,一个对象或者undefined)。无论从函数返回的是什么,都会被赋值给变量b
  5. 首先我们需要调用 addTwo 函数。js将会在全局执行上下文内存中查找名为addTwo的变量。好的,在第二步(或者2到5行)。并且看到变量addTwo包含一个函数定义。变量a作为参数传给了这个函数。js在全局执行上下文内存中搜索变量a,找到了它,找到了它的值是3,并且把3作为参数传给了函数。准备执行该函数。
  6. 现在执行上下文将被切换。一个新的局部上下文被创造,我们称它为'addTwo 执行上下文'。这个执行上下文被压到执行栈里。在局部执行上下文,我们做的第一步是什么?
  7. 你也许很冲动的说,"在局部执行上下文中一个新的变量ret被创建"。这不是答案。正确的答案是,我们首先要看函数的参数。局部执行上下文中新的变量x被创建。而且由于3被作为参数传递,所以变量x被赋值了数字3
  8. 下一步:一个新的变量ret在局部执行上下文被创建。它的值是undefined
  9. 第3行,一个加法要被执行。首先我们需要x的值。js寻找变量x。首先在局部执行上下文寻找,找到一个,值为3。第二个操作是数字2。加法运算的结果(5)被赋值给变量ret
  10. 第4行,返回变量ret的内容。接着在局部执行上下文中查找。
  11. 4-5行,函数结束。局部执行上下文被摧毁。变量xret被消除。它们不在存在。上下文弹出调用栈。返回值返回到调用上下文。在这种情况下,调用上下文是全局执行上下文,因为函数addTwo是从全局执行上下文中调用的。
  12. 回到刚才的第4步,返回值(5)被赋值给变量b
  13. 我没有详细说明,但是在第7行中,变量b的内容被打印在控制台中。

对于一个非常简单的程序来说,这是一个很长时间的解释,我们甚至还没有涉及到闭包。我保证我会到达那里。但是首先我们需要再绕道一两圈。

词法作用域

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

let val1 = 2
function multiplyThis(n) {
  let ret = n * val1
  return ret
}
let multiplied = multiplyThis(6)
console.log('example of scope:', multiplied)

这里的想法是我们在局部执行上下文中有变量,在全局执行上下文中有变量。js的一个复杂性在于它如何寻找变量。如果它在局部执行上下文中找不到变量,它将在其调用上下文中查找它。如果没有在它的调用环境中找到。重复上面的步骤,直到它在全局执行上下文中查找到。(如果都没有找到,它是undefined)。按照上面的例子,将会解释它。如果你了解作用域的运行机制,你可以跳过这个。

  1. 在全局执行上下文中声明一个新变量val1并为其指定数字2
  2. 2-5行。声明一个新的变量multiplyThis并且为其赋值一个函数。
  3. 第6行。在全局执行上下文中声明multiplied
  4. 从全局执行上下文内存中检索变量multiplyThis并作为函数执行。将数字6作为参数传入。
  5. 新函数调用=新的执行上下文。创建一个新的局部执行上下文。
  6. 在局部上下文中,声明一个变量n,并且赋值数字6
  7. 第3行。局部行上下文声明变量ret
  8. 继续第3行。用2个操作数做乘法运算;变量nvar1的值。在局部执行上下文中查找变量n。在第6步已经声明它,值是6。在局部执行上下文查找变量var1,但是并没有一个var1的变量标识。检查下调用上下文。调用上下文是全局执行上下文。在全局执行上下文上下文寻找var1,是的,在第一行找到了,值是2。
  9. 仍然是第3行。2个操作数相乘,并且赋值给ret。6 * 2 = 12,ret现在是12。
  10. 返回ret变量。局部执行上下文连同retn被消除。变量var1没有被消除,因为它是全局执行上下文的一部分。
  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. 第一步,在全局执行上下文中声明变量val,赋值7。
  2. 2-8行。全局上下文中声明变量createAdder,并且给它定义一个函数。3-7行描述了函数的定义。像以前一样,在这一点上,我们并没有进入这个函数。我们仅仅在变量(createAdder)中缓存了函数定义。
  3. 第9行。全局执行上下文中声明一个新的变量adderundefined暂时被赋值给adder
  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中被定义的函数。在全局执行上下文中需找它,并且找到,是一个有2个参数的函数。
  11. 让我们检索这两个参数,以便我们可以调用该函数并传递正确的参数。第一个是在第一步被定义的val,值为7,第二个是数字8。
  12. 现在执行那个函数。在3-5行描述了该函数定义。一个新的局部执行上下文被创建,里面有2个新的变量被创建:ab。它们分别赋值了值78,因为2个值是我们上一步传递给函数的参数。
  13. 第4行,在局部执行上下文中一个新的变量ret被声明。
  14. 仍然第4行。执行加法,在其中将变量a的内容和变量b的内容相加。加法的结果(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的内容。局部执行上下文被摧毁。myFunctioncunter不在存在。运行机制返回到调用上下文。
  8. 9行,在调用上下文内,全局执行上下文中,由createCounter返回的值被赋值为incrementincrement包含一个函数定义。函数定义通过createCounter返回。它不再是myFunction这个标记,但是是相同的定义。全局上下文中,被叫做increment
  9. 10行,声明新的变量c1
  10. 10行,查询变量increment,它是一个函数,调用它。它包含从前面返回的函数定义,第4-5行中所定义的。
  11. 创建新的执行上下文,没有参数,执行函数。
  12. 4行,counter = counter + 1。在局部执行作用域寻找counter。我们仅仅创建了上下文,没有摧毁任何变量。看一看全局执行上下文,没有counter。js将评定为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的值。

亲自尝试一下,看看会发生什么。你会注意到log不是1,1,1,并不像我们分析中期望的那样。而是1,2,3。怎么回事?

不知为何,increment函数记住了counter的值。它是怎么运行的。

counter是全局执行上下文的一部分吗?通过console.log(counter)得到的结果是undefined。也不是。

所以必须有另一种机制。闭包。我们终于找到了失去的那一块。

无论何时声明一个新函数并将其赋值给一个变量,都可以存储函数定义及闭包。闭包包含创建函数时在作用域内的所有变量。类似一个背包,函数定义附带一个小的背包,在这个包中,它存储了创建函数定义时所有作用域内的变量。

因此我们上面的解释是错的。我们再试一次,这次是对的。

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返回的值被赋值为incrementincrement包含一个函数定义。函数定义通过createCounter返回。它不再是myFunction这个标记,但是是相同的定义。全局上下文中,被叫做increment
  9. 10行,声明新的变量c1
  10. 10行,查询变量increment,它是一个函数,调用它。它包含从前面返回的函数定义,第4-5行中所定义的。
  11. 创建新的执行上下文,没有参数,执行函数。
  12. 第4行,我们需要寻找变量counter。我们从局部全局执行上下文中查找前,先看下闭包。你看,闭包中包含了一个变量counter,值为0。在第4行表达后,植被设置为了1。再次被存储到闭包中。现在闭包包含一个值为1的变量counter
  13. 第5行,我们返回了数字1,并摧毁局部执行上下文。
  14. 再到第10行,返回值1被赋值给了c1
  15. 第11行,重复10-14步。这次,当我们查看闭包的时候,看到了值为1的变量counter。在第4步或者程序的第4行被设置。在增量函数的闭包它的值被叠加,并且存储为2。c2被赋值为2。
  16. 12行,重复10-14步,c3被赋值1。
  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。

结论

我能够记住闭包的方式是通过背包的比喻。当一个函数被创建并传递或从另一个函数返回时,它会携带一个背包,并且背包里是函数声明时作用域内的变量。

posted @ 2018-05-06 16:44  open_wang  阅读(256)  评论(0编辑  收藏  举报