JavaScript 闭包的底层运行机制
Category(分类): JavaScript Status: 已整理(2026)
本文保留原译文关于词法作用域、作用域链、闭包共享状态、
var循环陷阱和垃圾回收的教学主线。原文使用了[[scope]]、Scope object 和“作用域对象在堆中”等较早的实现示意;这些内容已标注为历史模型,并按现代 ECMAScript 的 Environment Record、函数[[Environment]]和可达性模型重新表述。
原文译自:How do JavaScript closures work under the hood
一、什么是闭包
闭包通常可以理解为:函数与它定义时能够访问的词法环境结合在一起的结果。当函数被传递、返回或保存到定义它的外层作用域之外时,它仍然可以访问那些外层绑定,这就是闭包最容易观察到的行为。
function createCounter(initial = 0) {
let value = initial
return {
increment(step = 1) {
value += step
},
getValue() {
return value
}
}
}
const counter = createCounter(100)
console.log(counter.getValue()) // 100
counter.increment(5)
console.log(counter.getValue()) // 105
createCounter 已经返回,但 increment 和 getValue 仍然能访问 value。外部代码不能直接读写这个词法绑定,只能通过返回的方法操作它,因此闭包常被用于实现模块私有状态。
1.1 不是所有对象都是闭包
原文总结“所有 JavaScript 对象都是闭包”,这是不准确的。普通对象、数组、字符串包装对象并不会因为是对象就拥有一个外层词法环境。更准确的说法是:函数对象会保存与其定义环境相关的内部状态;当这个函数被保留下来时,相关环境也可能继续可达。
闭包是一个语义概念,不要求引擎真的创建一个名为“闭包”的对象,更不要求每个环境都对应一个可观察的 JavaScript 对象。
二、词法作用域和环境记录
JavaScript 使用词法作用域:变量能否被访问,主要由代码的书写位置决定,而不是由函数最终在哪里调用决定。
const globalValue = 'global'
function outer() {
const outerValue = 'outer'
function inner() {
const innerValue = 'inner'
return [globalValue, outerValue, innerValue]
}
return inner
}
const innerFunction = outer()
console.log(innerFunction()) // ['global', 'outer', 'inner']
调用 innerFunction() 时,标识符查找大致从当前函数的环境开始,再沿外部环境链接向上查找:
inner自己的参数和局部绑定;outer创建的词法环境;- 全局环境;
- 如果仍然找不到,抛出
ReferenceError。
这与原型链查找有“逐层向上”的相似之处,但两者不是同一条链:
- 词法环境链用于解析标识符,例如
outerValue; - 原型链用于查找对象属性,例如
object.toString; - 词法环境中找不到变量会得到
ReferenceError; - 原型链中找不到普通属性通常得到
undefined。
2.1 规范术语与历史术语
原文多次使用 Scope object 和函数内部属性 [[scope]]。这套说法可以帮助初学者画图,但需要明确:
[[scope]]不是可从 JavaScript 访问的标准内部属性;- 对相关 ECMAScript function object,现代规范用内部槽
[[Environment]]表示函数创建时关联的环境;这不是所有可调用对象都以同样方式拥有的公开属性; - 规范中的 Environment Record 是用于描述绑定的抽象机制,不要求引擎实现成普通 JavaScript 对象;
LexicalEnvironment、VariableEnvironment和执行上下文是规范模型,不等同于“每个变量都放在堆对象属性上”。
因此,下面的图示应当看作历史/实现示意,不是引擎内存布局或可以通过反射读取的对象结构。
三、全局环境
原文用 var foo、var bar 说明全局作用域。现代代码需要区分经典脚本、ES module 和不同宿主:
// 在浏览器经典脚本中,var 全局声明通常创建全局对象属性。
var scriptVar = 1
// let/const/class 的顶层绑定不等同于 globalThis 属性。
let lexicalValue = 2
const constantValue = 3
console.log(scriptVar, lexicalValue, constantValue)
在浏览器经典脚本中,var、函数声明和全局对象存在特殊关系;全局 let/const/class 则属于声明式绑定。ES module 顶层代码自动处于严格模式,并拥有 Module Environment Record:
// module-example.js
export const moduleValue = 1
const privateValue = 2
export function readModuleValue() {
return moduleValue + privateValue
}
模块变量不会自动成为 globalThis 属性。不要把“全局环境”简单等同于一个 Global Object。

图片来源:原文图示,已本地化;图中的 [[scope]] 应按本文现代说明理解。
四、函数创建时保存什么
当普通 ECMAScript 函数声明或函数表达式被创建时,函数对象会关联它定义位置的环境。绑定函数、Proxy 等其他可调用对象可能遵循不同的内部结构。用规范化的伪代码表示,可以理解为:
FunctionCreate(parameters, body, scope)
→ 创建函数对象
→ 将函数的 [[Environment]] 设置为 scope
这并不意味着可以运行 function.[[Environment]],它仍然是规范内部槽。函数执行时,会以该外部环境为起点创建当前调用的执行上下文。
const topLevel = 'top'
function readTopLevel() {
return topLevel
}
console.log(readTopLevel()) // top

图片来源:原文图示,已本地化。
五、函数调用与环境链
普通函数被调用时,会创建一个新的函数环境,其中可能包含参数、局部变量、arguments、函数声明和 this 相关信息:
function myFunc(input) {
const localValue = input * 2
return localValue
}
console.log(myFunc(3)) // 6
执行 myFunc(3) 时,input 和 localValue 属于这次调用的环境。函数返回后,如果没有任何可达函数或对象继续引用它们,相关存储就可能在未来被回收。

图片来源:原文图示,已本地化。
5.1 环境不是一定在堆或栈
原文说 JavaScript 的作用域对象“在堆中创建”,这可以作为解释闭包生命周期的直觉,但不是 ECMAScript 的实现要求。引擎可以把没有逃逸的局部变量优化到寄存器或栈,也可以把逃逸变量提升到其他存储结构;只要可观察行为符合规范即可。
因此应说:闭包使某些绑定在语义上保持可访问,具体存储位置和优化由引擎决定。
六、嵌套函数与闭包保留
下面是修复原文语法后的嵌套函数示例:
function makeGreeting(prefix) {
function greet(name) {
return `${prefix}, ${name}`
}
return greet
}
const greet = makeGreeting('Hello')
console.log(greet('Ada')) // Hello, Ada
greet 被返回后仍然可以读取 prefix。从外部看不到这个绑定,只能调用函数得到结果。
更完整的计数器示例:
function createCounter(initial) {
let counter = initial
function increment(value) {
counter += value
}
function get() {
return counter
}
return { increment, get }
}
const myCounter = createCounter(100)
console.log(myCounter.get()) // 100
myCounter.increment(5)
console.log(myCounter.get()) // 105

图片来源:原文图示,已本地化。
返回对象中的两个方法都能访问同一份 counter 绑定,因此 increment 修改后,get 读取到的是修改后的值:

图片来源:原文图示,已本地化。
调用 myCounter.increment(5) 时,会新增一个 increment 调用环境。value 在当前环境中找到,counter 则沿外部环境链接找到:

图片来源:原文图示,已本地化。
6.1 作用域链不是每次复制
函数调用不会把整个外部环境复制一份。多个闭包可以引用同一个外部绑定,因此一个闭包修改变量,其他闭包能够看到变化:
function createPair() {
let value = 0
return {
add() {
value += 1
},
read() {
return value
}
}
}
const pair = createPair()
pair.add()
console.log(pair.read()) // 1
如果想要相互隔离的状态,需要分别执行外层工厂函数:
function createCounter(initial) {
let value = initial
return {
increment(step) {
value += step
},
get() {
return value
}
}
}
const counter1 = createCounter(100)
const counter2 = createCounter(200)
counter1.increment(3)
console.log(counter1.get()) // 103
console.log(counter2.get()) // 200
两个返回函数的源代码可以相同,但它们关联的是不同的外层环境:

图片来源:原文图示,已本地化。
七、闭包实现“私有变量”
闭包可以隐藏状态,但它不是安全边界:如果返回了对象引用,外部仍可能通过该引用修改内部对象。
function createStore() {
const state = { count: 0 }
return {
increment() {
state.count += 1
},
getState() {
return { ...state }
}
}
}
const store = createStore()
store.increment()
const snapshot = store.getState()
snapshot.count = 100
console.log(store.getState().count) // 1:返回了浅复制快照
如果状态中含有嵌套对象,应根据领域需求使用深复制、不可变数据或只返回必要字段。闭包只是封装方式之一,现代 class 还可以使用语言级私有字段:
class Counter {
#value = 0
increment() {
this.#value += 1
}
get value() {
return this.#value
}
}
八、经典的循环回调陷阱
原文使用 var 循环注册点击事件:
const elements = document.getElementsByClassName('item')
for (var i = 0; i < elements.length; i += 1) {
elements[i].addEventListener('click', function () {
console.log(i)
})
}
这些回调共享同一个函数作用域中的 i。循环结束时 i 通常已经等于 elements.length,所以点击时不会得到各自的索引。
现代写法优先使用 let,每次循环迭代会形成独立的词法绑定:
for (let i = 0; i < elements.length; i += 1) {
elements[i].addEventListener('click', () => {
console.log(i)
})
}
也可以使用 forEach:
Array.from(elements).forEach((element, index) => {
element.addEventListener('click', () => {
console.log(index)
})
})
旧环境中常见的 IIFE 修复方式仍然有效,但属于历史兼容写法:
for (var i = 0; i < elements.length; i += 1) {
(function (index) {
elements[index].addEventListener('click', function () {
console.log(index)
})
})(i)
}
事件监听器还应在不需要时移除,或者使用 AbortController:
const controller = new AbortController()
elements[0].addEventListener('click', () => {
console.log('clicked')
}, { signal: controller.signal })
controller.abort()
九、闭包与垃圾回收
ECMAScript 不规定具体的垃圾回收算法和时间。更准确的表述是:
- 仍可从根对象、活动执行上下文、全局对象、事件监听器、定时器或其他可达对象访问的函数和环境,不能被安全回收;
- 当相关引用都不可达时,存储空间具备被回收的条件;
- “具备回收条件”不代表下一行代码就会立即回收;
- 闭包本身不是内存泄漏,长期保留不必要的大对象引用才可能造成问题。
例如:
function createHandler(largeData) {
return () => largeData.id
}
let handler = createHandler({ id: 1, payload: new Array(1000).fill('data') })
console.log(handler())
// 当不再需要时解除引用,之后是否及何时回收由引擎决定。
handler = null
浏览器中的全局事件监听器、Node.js 中的定时器、缓存和模块级变量都可能延长闭包的生命周期。排查内存问题应使用 DevTools heap snapshot、allocation timeline 等工具,而不要仅凭“函数返回了”下结论。
十、this 不是普通标识符绑定
闭包保存的是词法变量,this 通常由函数的调用形式决定。规范中的 Function Environment Record 可以保存 [[ThisValue]],但 this 不是通过普通标识符解析规则取得的变量;普通函数和箭头函数的规则不同:
const object = {
value: 1,
method() {
return this.value
},
arrow: () => this.value
}
console.log(object.method()) // 1
console.log(object.arrow()) // 通常不是 1:箭头函数捕获定义处的 this
如果需要在回调中保留对象接收者,可以使用箭头闭包或 bind:
const objectWithCallback = {
value: 1,
start() {
setTimeout(() => {
console.log(this.value)
}, 0)
}
}
不要把 this 简化成“作用域链中的一个普通变量”。它由 ResolveThisBinding 等调用/执行语义处理;箭头函数会从外部词法上下文捕获 this,但这仍不等同于普通变量查找。
十一、模块也是闭包边界
ES module 的顶层变量位于模块环境中,导出的函数可以继续访问它们:
// counter-module.js
let value = 0
export function increment() {
value += 1
}
export function read() {
return value
}
// app.js
import { increment, read } from './counter-module.js'
increment()
console.log(read()) // 1
模块导入是 live binding:如果导出方修改了绑定,导入方在读取时可以看到更新。模块状态通常只在同一模块图中共享;不同 Worker、iframe、Realm 或 Node loader 不应假定它们拥有同一份状态。
十二、总结
- 闭包是函数与其定义处词法环境结合后的可观察语义;
- 词法作用域由代码书写位置决定,函数调用会新增当前执行环境;
- 函数的
[[Environment]]是规范内部槽,旧文中的[[scope]]只是历史实现示意; - Environment Record 不是必须存在的普通 JavaScript 对象,具体堆/栈位置由引擎决定;
- 多个闭包可以共享外层绑定,分别执行工厂函数则拥有独立绑定;
let可以解决经典var循环回调问题,IIFE 是旧环境的替代方案;- GC 依据可达性,函数返回并不意味着环境会立即销毁;
- 闭包不是内存泄漏,但事件监听器、定时器和缓存可能让闭包长期可达;
this的绑定规则与词法变量不同,箭头函数捕获外部this;- ES module 也形成模块级封装,但模块缓存边界取决于宿主、模块图和 Realm。
图片与来源
原文图示已下载到 tech_article/JavaScript/images/:
images/37-image-01.png:原始图片images/37-image-02.png:原始图片images/37-image-03.png:原始图片images/37-image-04.png:原始图片images/37-image-05.png:原始图片images/37-image-06.png:原始图片images/37-image-07.png:原始图片
原目录中的 JavaScript闭包的底层运行机制/image.png 也保留为历史附件;它不是正文中的运行示意图。