译 | 我从未真正搞懂 JavaScript 闭包
原文: I never understood JavaScript closures
作者: Olivier De Meulder
时间: Sep 7, 2017
译注:作者从 JavaScript 的原理出发,详细解读执行过程,通过“背包”的形象比喻,来解释闭包。
我从没理解过 JavaScript 闭包
直到有人这样跟我解释……
正如标题所说,JavaScript 闭包对我来说一直是个迷。我 看过 很多 文章,在工作中用过闭包,甚至有时候我都没有意识到我在使用闭包。
最近参加一个交流会,有人用某种方式向我解释了闭包,点醒了我。这篇文章我也将用这种方式来解释闭包。这里要称赞一下 CodeSmith 的优秀人才和他们的《JavaScript The Hard Parts》系列。
开始之前
在理解闭包之前,一些重要的概念需要理解。其中一个就是 执行上下文(execution context)。
这篇文章 对执行上下文有很好的介绍。引用一下这篇文章:
JavaScript 代码在执行时,它的执行环境非常重要,它会被处理成下面的某一种情况:
全局代码(Global code) —— 代码开始执行时的默认环境。
函数代码(Function code) —— 当执行到函数体时。
(…)
(…), 我们把术语
执行上下文(execution context)
称为当前执行代码所处的 环境或者作用域。
换句话说,当我们开始执行程序时,首先处于全局上下文中。在全局上下文中声明的变量,称为全局变量。当程序调用函数时,会发生什么?发生下面这几步:
- JavaScript 创建一个新的执行上下文 —— 局部执行上下文。
- 这个局部执行上下文有属于它的变量集,这些变量是这个执行上下文的局部变量。
- 这个新的执行上下文被压入执行栈中。将执行栈当成是用来跟踪程序执行位置的一种机制。
函数什么时候执行完?当遇到 return
语句或者结束括号 }
时。函数结束时,发生下面情况:
- 局部执行上下文从执行栈弹出。
- 函数把返回值返回到调用上下文。调用上下文是指调用该函数的的执行上下文,它可以是全局执行上下文也可以是另外一个局部执行上下文。这里的返回值怎么处理取决于调用执行上下文。返回值可是
object
,array
,function
,boolean
等任何类型。如果函数没有return
语句,那么返回值是undefined
。 - 局部执行上下文被销毁。这点很重要 —— 被销毁。所有在局部执行上下文中声明的变量都被清除。这些变量不再可用。这也是为什么称它们为局部变量。
一个非常简单的例子
在开始学习闭包之前,我们先来看下下面这段代码。它看起来很简单,所有的读者应该都能清楚的知道它的作用。
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 引擎的真正工作原理,我们来详细解释一下。
- 在代码第一行,我们在全局执行上下文声明了一个新的变量
a
,并赋值为3
。 - 接下来比较棘手了。第 2 到第 5 行属于一个整体。这里发生了什么呢?我们在全局执行上下文声明了一个变量,命名为
addTwo
。然后我们怎么对它赋值的?通过函数定义。所有在两个括号{}
之间的内容都被赋给addTwo
。函数里的代码不计算、不执行,只是保存在变量,留着后面使用。 - 现在我们到了第 6 行。看似很简单,其实这里有很多需要解读。首先我们在全局执行上下文声明了一个变量,标记为
b
。当变量刚声明时,它的默认值是undefined
。 - 接着,还是在第 6 行,我们看到有个赋值运算符。我们准备给变量
b
赋新值。接着看到一个将要被调用的函数。当你看到变量后面跟着圆括号(...)
,那就是函数调用的标识。提前说下后面的情况:每个函数都有返回值(一个值、一个对象或者是undefined
)。函数的返回值将被赋值给变量b
。 - 但是(在赋值前)我们首先要调用函数
addTwo
。JavaScript 将在全局执行上下文内存中查找变量addTwo
。找到了!它在第 2 步(第 2-5 行)中定义,你瞧,变量addTwo
包含函数定义。注意,变量a
当做参数传给了函数。JavaScript 在全局执行上下文内存中寻找变量a
,找到并发现它的值是3
,然后把数值3
做为参数传给函数。函数执行准备就绪。 - 现在执行上下文将会切换。一个新的局部执行上下文被创建,我们把它命名为 “addTwo 执行上下文”。该执行上下文被压入调用栈。在局部执行上下文中首先做些什么事呢?
- 你可能会想说:“在局部执行上下文中声明一个新的变量
ret
”。然后答案不是这样。正确答案是:我们首先需要查看函数的参数:在局部执行上下文中声明新的变量x
,因为值3
作为参数传给函数,所以变量x
赋值为数值3
。 - 下一步:局部执行上下文中声明新变量
ret
。它的值默认为undefined
。(第3行) - 还是第 3 行,准备执行加法。我们首先需要获取
x
的值。JavaScript 将寻找变量x
。首先在局部执行上下文中寻找。找到变量x
的值为3
。第二个操作数是数值2
,加法的结果(5
)赋值给变量ret
。 - 第 4 行。我们返回变量
ret
的值。在局部执行上下文中又进行查找ret
。ret
的值为5
。所以该函数返回数值5
,函数结束。 - 第 4-5 行。函数结束。局部执行上下文被销毁。变量
x
和ret
被清除,不再存在。调用栈弹出该上下文,返回值返回给调用上下文。在这个例子中,调用上下文是全局执行上下文,因为函数addTwo
是在全局执行上下文中调用的。 - 现在回到我们在第 4 步遗留的内容。返回值(数值
5
)复制给变量b
。在这个小程序中,我们还在第 6 行。 - 下面我不再详细说明了。在第 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
)。按照上面的例子来说明,它会验证这点。如果你理解作用域的原理,你可以跳过这部分。
- 在全局执行上下文声明一个新变量
val1
,并赋值为数值2
。 - 第 2-5 行声明新变量
multiplyThis
并赋值为函数定义。 - 第 6 行,在全局执行上下文声明新变量
multiplied
。 - 在全局执行上下文内存中获取变量
multiplyThis
并作为函数执行。传入参数数值6
。 - 新函数调用 = 新的执行上下文:创建新的局部执行上下文。
- 在局部执行上下文中,声明变量
n
并赋值为数值6
。 - 第 3 行,在局部执行上下文中声明变量
ret
。 - 还是第 3 行,两个操作数——变量
n
和val1
的值执行乘法运算。先在局部执行上下文查找变量n
,它是我们在第 6 步中声明的,值为数值6
。接着在局部执行上下文查找变量val1
,在局部执行上下文没有找到名为val1
的变量,所以我们检查调用上下文中。这里调用上下文是全局执行上下文。我们在全局执行上下文中找到它,它在第 1 步中被定义,值为数值2
。 - 依旧是第 3 行。两个操作数相乘然后赋值给变量
ret
。6 * 2 = 12。ret
现在值为12
。 - 返回变量
ret
。局部执行上下文以及相应的变量ret
和n
一起被销毁。变量val1
作为全局执行上下文的一部分没有被销毁。 - 回到第 6 行。在调用上下文中,变量
multiplied
被赋值为数值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 行,我们在全局执行上下文声明变量
val
并赋值为数值7
。 - 第 2-8 行,我们在全局执行上下文声明变量
createAdder
并赋值为函数定义。第 3-7 行表示函数定义。和前面所说,这时候不会进入函数,我们只是把函数定义保存在变量 (createAdder)。 - 第 9 行,我们在全局执行上下文声明名为
adder
的新变量,暂时赋值为undefined
。 - 还是第 9 行,我们看到有括号
()
,知道需要执行或者调用函数。我们从全局执行上下文的内存中查找变量createAdder
,它在第 2 步创建。ok,现在调用它。 - 调用函数,我们现在处于第 2 行。新的局部执行上下文被创建。我们可以在新的执行上下文中创建局部变量。JavaScript 引擎把新的上下文压入调用栈。该函数没有参数,我们直接进入函数体。
- 还是在 3-6 行。我们声明了个新函数。我们在局部执行上下文中创建了新的变量
addNumbers
,这点很重要,addNumbers
只在局部执行上下文中出现。我们使用局部变量addNumbers
保存了函数定义。 - 现在到了第 7 行。我们返回变量
addNumbers
的值。JavaScript 引擎找到addNumbers
这个变量,它是个函数定义。这没问题,函数可以返回任意类型,包括函数定义。所以我们返回了addNumbers
这个函数定义。括号中的所有内容——第 4-5 行组成了函数定义。我们也从调用栈中移除了该局部执行上下文。 - 局部执行上下文在返回时销毁了。
addNumbers
变量不存在了,但是函数定义还在,它被函数返回并赋值给了变量adder
—— 我们在第 3 步创建的变量。 - 现在到了第 10 行。我们在全局执行上下文中定义了新变量
sum
,暂时赋值是undefined
。 - 接下来需要需要执行函数。函数定义在变量
adder
中。我们在全局执行上下文中查找并确保找到了它。这个函数带有两个参数。 - 我们获取这两个参数,以便能调用函数并传入正确的参数。第一个参数是变量
val
,在第 1 步中定义,表示数值7
, 第二个参数是数值8
。 - 现在我们开始执行函数。该函数在定义在 3-5 行。新的局部执行上下文被创建,同时创建了两个新变量:
a
和b
,他们分别赋值为7
和8
,这是上一步提到的传给函数的参数。 - 第 4 行,声明变量
ret
。它是在局部执行上下文中声明的。 - 第 4 行,进行加法运算:我们让变量
a
和变量b
的值相加。相加的结果(15)赋值给变量ret
。 - 函数返回变量
ret
。局部执行上下文销毁,从调用栈中移除,变量a
、b
和ret
都不存在了。 - 返回值赋值给在第 9 步定义的变量
sum
。 - 在 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-8 行。我们在全局执行上下文创建了变量
createCounter
并赋值为函数定义。 - 第 9 行。在全局执行上下文声明变量
increment
。 - 还是第 9 行。我们需要调用函数
createCounter
并把它的返回值赋值给变量increment
。 - 1-8 行,函数调用,创建新的局部执行上下文。
- 第 2 行,在局部执行上下文中声明变量
counter
,并赋值为数值0
。 - 3-6 行,声明名为
myFunction
的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。 - 第 7 行,返回变量
myFunction
的值。局部执行上下文被删除了,myFunction
和counter
也不存在了。程序控制权回到调用上下文。 - 第 9 行。在调用上下文,也是全局执行上下文中,
createCounter
的返回值赋给increment
。现在变量increment
包含一个函数定义。该函数定义是createCounter
返回的。它不再是标记为myFunction
,但是是同一个函数定义。在全局执行上下文中,它被命名为increment
。 - 第 10 行,声明变量
c1
。 - 继续第 10 行,寻找变量
increment
,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。 - 创建新的执行上下文,这里没有参数,开始执行函数。
- 第 4 行,
counter = counter + 1
。在局部执行上下文寻找counter
的值。我们只是创建了上下文而没有声明任何局部变量。我们看看全局执行上下文,也没有变量counter
。JavaScript 会把这个转化成counter = undefined + 1
,声明新的局部变量counter
并赋值为数值1
,因为undefined
会转化成0
。 - 第 5 行,我们返回
counter
的值,或者说数值1
。销毁局部执行上下文和变量counter
。 - 回到第 10 行,返回值(
1
)赋给c1
。 - 第 11 行,重复第 10-14 的步骤,最后
c2
也赋值为1
。 - 第 12 行,重复第 10-14 的步骤,最后
c3
也赋值为1
。 - 第 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-8 行。我们在全局执行上下文创建了变量
createCounter
并赋值为函数定义。同上。 - 第 9 行。在全局执行上下文声明变量
increment
。同上。 - 还是第 9 行。我们需要调用函数
createCounter
并把它的返回值赋值给变量increment
。同上。 - 1-8 行,函数调用,创建新的局部执行上下文。同上。
- 第 2 行,在局部执行上下文中声明变量
counter
,并赋值为数值0
。同上。 - 3-6 行,声明名为
myFunction
的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。现在我们同时 创建了一个闭包 并把它作为函数定义的一部分。闭包包含了当前作用域里的变量,在这里是变量counter
(值为0
)。 - 第 7 行,返回变量
myFunction
的值。局部执行上下文被删除了,myFunction
和counter
也不存在了。程序控制权回到调用上下文。所以我们返回了函数定义和它的 闭包 —— 这个背包包含了函数创建时作用域里的变量。 - 第 9 行。在调用上下文,也是全局执行上下文中,
createCounter
的返回值赋给increment
。现在变量increment
包含一个函数定义(和闭包)。该函数定义是createCounter
返回的。它不再是标记为myFunction
,但是是同一个函数定义。在全局执行上下文中,它被命名为increment
。 - 第 10 行,声明变量
c1
。 - 继续第 10 行,寻找变量
increment
,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。(同时它也有个包含变量的背包) - 创建新的执行上下文,这里没有参数,开始执行函数。
- 第 4 行,
counter = counter + 1
。我们需要寻找变量counter
。我们在局部或者全局执行上下文寻找前,先查看我们的背包。我们检查闭包。你瞧!闭包里包含变量counter
,值为0
。通过第 4 行的表达式,它的值设为1
。它继续保存在背包里。现在闭包包含值为1
的变量counter
。 - 第 5 行,我们返回
counter
的值,或者说数值1
。销毁局部执行上下文和变量counter
。 - 回到第 10 行,返回值(
1
)赋给c1
。 - 第 11 行,重复第 10-14 的步骤。这次,当我们查看闭包时,我们看到变量
counter
的值为1
。它是在第 12 步(程序第 4 行)设置的。通过increment
函数,它的值增加并保存为2
。 最后c2
也赋值为2
。 - 第 12 行,重复第 10-14 的步骤,最后
c3
也赋值为3
。 - 第 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
。
结论
我牢牢记住闭包的方法是通过 背包的比喻 。当一个函数被创建、传递或者从另一个函数中返回时,它就背着一个背包。背包里是函数声明时的作用域里的所有变量。
本文文字及图片出自 blog.hhking.cn
你也许感兴趣的:
- 让你3分钟学会 javascript 闭包
- 【外评】电脑从哪里获取时间?
- 【外评】为什么 Stack Overflow 正在消失?
- Android 全力押注 Rust,Linux 却在原地踏步?谷歌:用 Rust 重写固件太简单了!
- 【外评】哪些开源项目被广泛使用,但仅由少数人维护?
- 【外评】好的重构与不好的重构
- C 语言老将从中作梗,Rust for Linux 项目内讧升级!核心维护者愤然离职:不受尊重、热情被消耗光
- 【外评】代码审查反模式
- 我受够了维护 AI 生成的代码
- 【外评】Linux 桌面市场份额升至 4.45
你对本文的反应是: