深入理解闭包

什么是闭包

闭包 是指一个函数可以记住其外部变量并可以访问这些变量。

JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

让我们来看一段代码:

js
1function 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相当于一个特殊的内部对象,在一个“代码块”中声明的变量都是这个内部对象的属性。

举个例子:

js
1let a = 'Hello'

这段代码会生成一个“全局词法环境”,它的属性是 a。

由于是顶级词法环境,它的外部词法环境(以 outer 代替对外部词法环境的引用)为 null。

js
1Environment Record <global> = { a:"Hello" };
2Environment Record <global> [outer] ==> null

在全局词法环境生成过程中,会经过以下步骤:

  1. JavaScript 启动,词法环境先收集到所有变量(这里仅有变量 a),此时它的Uninitialized状态。
  2. 所有变量会根据let、const声明做初始化,此时 a = undefined,并且只有在let、const声明后才能使用该变量。(var不一样,var 允许在声明前使用——“声明提升”)
  3. a被赋值成Hello

到目前为止,全局词法环境生成完毕了。

词法环境-函数声明

下面我们用函数声明来定义一个函数

js
1let a = 'Hello'
2function f(who) {
3  console.log(a + who)
4}

函数声明类似于var声明,它也有一个提升的过程,所以我们可以在函数声明前调用这个函数。

js
1x() // 'x'
2function x() {
3  console.log('x')
4}
5console.log(h) // undefined
6var h = 'world'

跟 var 声明的不同之处在于, 函数声明的初始化会被立即完成,所以上面 x 能被调用,而 h只是undefined。

下面这种函数的声明方式只能算是变量声明

js
1f() // Uncaught SyntaxError: Identifier 'f' has already been declared
2const x = function () {
3  console.log('x')
4}

当函数f声明完成后,全局词法环境的初始状态会马上变成这样:

js
1Environment Record <global> = {
2											a:'Uninitialized' ,
3											f:function
4										};
5Environment Record <global>[outer] ==> null

随后再对a变量初始化为undefined,再赋值为Hello

函数词法环境

现在我们要调用函数了。

js
1let a = 'Hello'
2function f(who) {
3  console.log(a + who)
4}
5f('World')

在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。

js
1Environment 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 这个例子。

js
1function makeCounter() {
2  let count = 0
3
4  return function () {
5    return count++
6  }
7}
8
9let counter = makeCounter()

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。

因此,我们有两层嵌套的词法环境,就像上面的示例一样:

image-20230216224701360

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。我们尚未运行它,仅创建了它。

所有的函数在“诞生”时都会记住创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用。

image-20230216224749080

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]] 引用在函数创建时被设置并永久保存。

稍后,当调用 counter() 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]:

image-20230216224906801

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

在变量所在的词法环境中更新变量。

这是执行后的状态:

image-20230216224929929

现在明白闭包产生的原因了吧?

因为一开始就有一个隐藏属性在引用着它们,所以我们可以利用这个隐藏属性一直去获取或者改变它们。

垃圾收集与闭包

通常,函数调用完成后,会将词法环境和其中的所有变量从内存中删除。因为现在没有任何对它们的引用了。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。

但是,如果有一个嵌套的函数在函数结束后仍可达,则它将具有引用词法环境的 [[Environment]] 属性。

js
1function f() {
2  let value = 123
3
4  return function () {
5    alert(value)
6  }
7}
8
9let g = f() // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用

此时f函数的返回值依然是可达的,即变量 g 仍然可以访问到它。

所有相应的词法环境对象也会保留在内存中,垃圾收集器并不会删除它们。

这是闭包能在内存中一直存在的主要原因——跟普通变量是一样的待遇,并没有什么特别

当词法环境对象变得不可达时,它就会死去。

在下面的代码中,嵌套函数被删除后,其封闭的词法环境(以及其中的 value)也会被从内存中删除:

js
1function 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。