我从来不理解 JavaScript 闭包,直到有人这样向我解释它
Category(分类): JavaScript Status: 已整理
为了保证可读性,本文保留意译和背包比喻,但把规范抽象与引擎实现分开说明。
正如标题所说,JavaScript 闭包对我来说一直有点神秘。看过很多文章,在工作中也使用过闭包,却没有真正把“函数、作用域和调用”联系起来。下面仍然从执行上下文、词法作用域、返回函数和计数器开始。
准备:执行上下文是什么
代码运行时需要一组规范状态,ECMAScript 把它抽象为执行上下文。执行上下文包含当前代码执行所需的词法环境、变量环境、函数状态等;它是规范模型,不要求对应某个具体的“内存盒子”或机器调用栈。
对普通同步函数,可以先用这个教学模型理解:
- 调用函数时,创建这一次调用的函数执行上下文。
- 参数、局部声明和当前执行状态进入相应的环境记录。
- 执行上下文被推入执行上下文栈,函数体按顺序运行。
- 函数正常返回、抛出异常或完成其他控制流后,上下文不再继续执行。
到达 } 是正常完成的一种方式,但 throw 等 abrupt completion 也会结束函数;生成器和 async function 还可能挂起后恢复。所以下面关于“入栈、返回、离开”的描述,主要针对普通同步函数的正常路径。
函数执行上下文结束后会从执行上下文栈中移除,但这不等于其中的词法环境立即被物理删除。若仍有返回的函数、对象、任务等可达引用,环境中的绑定必须继续存在;只有在相关对象和环境不可达后,它们才可能被垃圾回收,GC 的具体时间由引擎决定。
基础例子
let a = 3
function addTwo(x) {
const ret = x + 2
return ret
}
const b = addTwo(a)
console.log(b)
可以这样理解:
- 在当前脚本的全局词法环境中建立
a绑定,并赋值为3。 - 函数声明会创建函数对象并把它绑定到
addTwo;函数体此时没有被调用。 - 调用
addTwo(a)时,先在当前环境中读取a,得到3。 - 这次调用创建
addTwo的函数执行上下文,参数x得到3。 const ret先创建为未初始化绑定(处于 TDZ),计算初始化表达式后直接初始化为5。- 函数返回
ret,上下文结束,调用结果5被赋给b。
这里的“变量离开上下文”是教学说法。x 和 ret 没有被其他可达函数捕获时,相关状态通常可以回收;规范并不要求引擎在某一行结束时立即释放一块可见内存。
词法作用域:按定义位置找变量
看下面的代码:
let val1 = 2
function multiplyThis(n) {
const ret = n * val1
return ret
}
const multiplied = multiplyThis(6)
console.log('example of scope:', multiplied)
multiplyThis 找不到 val1 时,会沿函数定义时建立的外层词法环境链寻找,而不是沿调用者的局部变量寻找。ECMAScript 用 Environment Record 和 [[OuterEnv]] 表示这条链,函数对象在创建时保存定义环境的 [[Environment]] 引用。
下面的反例能排除“动态作用域”的误解:
let value = 'global'
function read() {
return value
}
function caller() {
let value = 'caller'
return read()
}
console.log(caller())
// global:read 按定义位置查找,不会借用 caller 的局部 value
在经典 Script 中,顶层 let 是全局词法绑定,通常不等同于 globalThis.value;在 ESM 中,顶层绑定属于模块环境。这个差异不改变“按定义位置查找”的核心。
返回函数的函数
原文的例子先返回一个只使用自身参数的函数:
let val = 7
function createAdder() {
function addNumbers(a, b) {
return a + b
}
return addNumbers
}
const adder = createAdder()
const sum = adder(val, 8)
console.log('example of function returning a function:', sum)
这段代码当然可以输出 15,但 addNumbers 没有读取 createAdder 的局部变量,因此它还没有展示闭包捕获外层状态的关键效果。改成下面这样更直观:
function createAdder(x) {
return function addNumbers(y) {
return x + y
}
}
const add5 = createAdder(5)
console.log(add5(2))
// 7
createAdder(5) 创建一次调用环境,其中有绑定 x = 5;返回的函数对象的 [[Environment]] 指向这个环境。调用 add5(2) 时,当前函数环境中有 y,再沿外层环境链找到 x。这就是闭包在实际代码中的作用。
严格说,闭包不是“调用者上下文”,也不是把每个值复制出来的背包。背包是帮助记忆的比喻;更准确的说法是:函数对象保存定义时的词法环境引用,标识符解析沿环境链进行,捕获的是绑定而不是固定值快照;绑定是否可变取决于 let、const 等声明。
最后,一个闭包:计数器
function createCounter() {
let counter = 0
const increment = function () {
counter += 1
return counter
}
return increment
}
const increment = createCounter()
const c1 = increment()
const c2 = increment()
const c3 = increment()
console.log('example increment:', c1, c2, c3)
输出是:
example increment: 1 2 3
常见的错误推演
有一种错误解释是:调用 increment() 时,本地和全局都找不到 counter,于是引擎会给它一个默认数值并创建新的局部变量。这个解释不适用于这段代码:
counter在createCounter()的那次调用环境中已经声明并初始化为0。increment函数对象创建时保存了指向该环境的[[Environment]]引用。- 如果所有环境都找不到一个标识符,读取它会抛出
ReferenceError,不会自动创建局部变量。 - 非严格脚本中对未声明标识符的赋值可能污染全局对象,那是历史兼容行为,也不是闭包机制。
按活绑定理解
createCounter()的第一次调用创建一个新的counter绑定,值为0。- 返回的
increment函数保存这个环境引用;函数执行上下文结束后,环境因仍然可达而继续存活。 - 第一次调用
increment()沿环境链读到同一个counter绑定,把它改为1。 - 第二次和第三次调用继续读写同一个绑定,因此得到
2和3。 - 这里保存的是活绑定,不是每次调用时复制一个值,也不是每次调用都创建一个新的函数实例。
如果再调用一次 createCounter(),会得到另一个独立的 counter 绑定:
const add1 = createCounter()
const add2 = createCounter()
console.log(add1(), add1())
console.log(add2(), add2())
// 1 2
// 1 2
两个返回函数不共享各自工厂调用中的绑定;同一个工厂调用也可以返回多个函数,让它们共同访问同一环境。
这就是闭包:函数对象与其定义位置的词法环境组合在一起。 函数调用时创建的是新的函数执行上下文;闭包环境和函数对象不是同一个“上下文”。
闭包并不只在返回函数时出现
在全局或模块作用域中创建的函数也有定义环境引用,只是它主要指向全局/模块环境,通常不需要特别强调。只有当函数访问了外层局部绑定,或者该函数被返回、传递给任务、保存到对象中时,闭包保留状态的效果才特别明显。
部分应用是一个简洁例子:
const addX = (x) => (n) => n + x
const addThree = addX(3)
console.log(addThree(4))
// 7
addThree 访问的 x 来自 addX(3) 的环境,参数 n 则来自 addThree(4) 这次调用。箭头函数不是闭包的必要条件,普通函数也有同样的词法环境机制。
小结:背包比喻的精确版本
我仍然喜欢背包的比喻:函数被创建时,好像带上了定义位置的环境。只是要记住几件事:
- 背包不是值的副本;严格说是函数对象的
[[Environment]]环境引用。 - 环境里的绑定是活的,多个闭包可能共享一个绑定。
- 调用函数会创建一次新的执行上下文,但不会创建一个新的函数实例。
- 外层函数返回后,上下文不再执行;如果返回函数仍可达,闭合环境仍然可访问。
- 不可达对象和环境可能被 GC 回收,但回收时机不可预测。
- 闭包不是必然的性能问题。是否保留状态、创建多少函数和对象,应该结合场景、内存快照与基准测试判断。
原文归属
原文转载/意译来源:SegmentFault《我从来不理解 JavaScript 闭包,直到有人这样向我解释它》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。