深入理解闭包
什么是闭包
闭包 是指一个函数可以记住其外部变量并可以访问这些变量。
JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。
让我们来看一段代码:
js1function makeCounter() { 2 let count = 0 3 4 return function () { 5 return count++ 6 } 7} 8 9let counter = makeCounter()
makeCounter函数的返回值(也是一个函数),它能够访问makeCounter中的count变量。
返回的函数与count变量就形成了一种闭包。
闭包是如何产生的
在某些编程语言中,闭包是不可能的,或者应该以一种特殊的方式编写函数来实现。
但是 JavaScript 天然就支持闭包,这跟 JavaScript 内部机制有关。
全局词法环境
在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。
词法环境由两部分组成:
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如
this的值)的对象。 - 对 外部词法环境 的引用,与外部代码相关联。
Environment Record相当于一个特殊的内部对象,在一个“代码块”中声明的变量都是这个内部对象的属性。
举个例子:
js1let a = 'Hello'
这段代码会生成一个“全局词法环境”,它的属性是 a。
由于是顶级词法环境,它的外部词法环境(以 outer 代替对外部词法环境的引用)为 null。
js1Environment Record <global> = { a:"Hello" }; 2Environment Record <global> [outer] ==> null
在全局词法环境生成过程中,会经过以下步骤:
- JavaScript 启动,词法环境先收集到所有变量(这里仅有变量 a),此时它的
Uninitialized状态。 - 所有变量会根据
let、const声明做初始化,此时a = undefined,并且只有在let、const声明后才能使用该变量。(var不一样,var允许在声明前使用——“声明提升”) a被赋值成Hello
到目前为止,全局词法环境生成完毕了。
词法环境-函数声明
下面我们用函数声明来定义一个函数
js1let a = 'Hello' 2function f(who) { 3 console.log(a + who) 4}
函数声明类似于var声明,它也有一个提升的过程,所以我们可以在函数声明前调用这个函数。
js1x() // 'x' 2function x() { 3 console.log('x') 4} 5console.log(h) // undefined 6var h = 'world'
跟 var 声明的不同之处在于, 函数声明的初始化会被立即完成,所以上面 x 能被调用,而 h只是undefined。
下面这种函数的声明方式只能算是变量声明
js1f() // Uncaught SyntaxError: Identifier 'f' has already been declared 2const x = function () { 3 console.log('x') 4}
当函数f声明完成后,全局词法环境的初始状态会马上变成这样:
js1Environment Record <global> = { 2 a:'Uninitialized' , 3 f:function 4 }; 5Environment Record <global>[outer] ==> null
随后再对a变量初始化为undefined,再赋值为Hello
函数词法环境
现在我们要调用函数了。
js1let a = 'Hello' 2function f(who) { 3 console.log(a + who) 4} 5f('World')
在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。
js1Environment Record <f> ==> Environment Record <global> 2Environment Record <f>[outer] ==> Environment Record <global>[outer] ==> null
在函数调用期间,我们拥有两个词法环境:内部一个,外部一个。
- 函数内部即对应
f函数,它有who属性即形参,who属性的值为World即实参 - 外部词法环境是全局词法环境。它具有
a变量和f函数。
内部(函数)词法环境的outer 连接到了外部词法环境的outer。
当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。
在示例中:
- 函数
f中的a变量会顺着对外部作用域的引用(outer)一路找到global中的变量a - 函数
f中的who变量会在函数f作用域中找到who属性
闭包产生的过程
让我们回到 makeCounter 这个例子。
js1function makeCounter() { 2 let count = 0 3 4 return function () { 5 return count++ 6 } 7} 8 9let counter = makeCounter()
在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。
因此,我们有两层嵌套的词法环境,就像上面的示例一样:

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。我们尚未运行它,仅创建了它。
所有的函数在“诞生”时都会记住创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用。

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]] 引用在函数创建时被设置并永久保存。
稍后,当调用 counter() 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]:

现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。
在变量所在的词法环境中更新变量。
这是执行后的状态:

现在明白闭包产生的原因了吧?
因为一开始就有一个隐藏属性在引用着它们,所以我们可以利用这个隐藏属性一直去获取或者改变它们。
垃圾收集与闭包
通常,函数调用完成后,会将词法环境和其中的所有变量从内存中删除。因为现在没有任何对它们的引用了。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。
但是,如果有一个嵌套的函数在函数结束后仍可达,则它将具有引用词法环境的 [[Environment]] 属性。
js1function f() { 2 let value = 123 3 4 return function () { 5 alert(value) 6 } 7} 8 9let g = f() // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用
此时f函数的返回值依然是可达的,即变量 g 仍然可以访问到它。
所有相应的词法环境对象也会保留在内存中,垃圾收集器并不会删除它们。
这是闭包能在内存中一直存在的主要原因——跟普通变量是一样的待遇,并没有什么特别
当词法环境对象变得不可达时,它就会死去。
在下面的代码中,嵌套函数被删除后,其封闭的词法环境(以及其中的 value)也会被从内存中删除:
js1function f() { 2 let value = 123 3 4 return function () { 5 alert(value) 6 } 7} 8 9let g = f() // 当 g 函数存在时,该值会被保留在内存中 10 11g = null // ……现在内存被清理了
总结
- 闭包就是一个函数可以记住其外部变量并可以访问这些变量。
- 闭包能产生的原因是函数的隐藏属性
[[Environment]]保存着从函数内部词法环境到全局词法环境的引用。 - 闭包能一直存在于内存中是因为它是可达的——有外部变量引用到它。
- 如果闭包函数不再可达,那么垃圾收集器就会删除掉它。
var、let/const、function这几个变量声明的方式,生成词法环境时实现不太一致。var和function都允许在声明前使用变量,对于let/const来说,它们是提升的。var和function的区别是function在初始化时是立即初始化的,而var会先变成undefined。