美文网首页
原来我从未真正的理解过 JavaScript 闭包

原来我从未真正的理解过 JavaScript 闭包

作者: Coder_老王 | 来源:发表于2018-09-26 20:19 被阅读219次

    一直对JavaScript 的闭包理解的模棱两可,今天偶然看到了一位兄弟翻译的一篇文章,读来感觉受益匪浅,怕文章消失于茫茫文海,所以转载过来以做备忘.

    英文原文: I never understood JavaScript closures
    翻译原文:我从没理解过 JavaScript 闭包
    作者: Olivier De Meulder
    译注:作者从 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。

    结论

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

    相关文章

      网友评论

          本文标题:原来我从未真正的理解过 JavaScript 闭包

          本文链接:https://www.haomeiwen.com/subject/ocmjoftx.html