Skip to content
闭包
概述
闭包的实质是函数实例与其创建时的词法环境引用的组合,而不是“函数里返回一个函数”这一种写法——尽管大多数示例确实长这样。
在 ECMAScript 规范中,每个函数对象都有一个内部槽 [[Environment]],指向该函数被创建时所在的作用域(词法环境)。函数被传递到定义它的作用域之外执行时,仍然能通过这一引用访问原先作用域中的标识符。这就是闭包。
这一机制在 JavaScript 引擎内部有精确的表示,并且直接影响垃圾回收行为——很多与内存相关的问题都与闭包有关。
基本概念
词法环境
规范定义的词法环境由两部分组成:
- Environment Record(环境记录):存储当前作用域中的变量绑定(标识符到值的映射)。
- Outer Reference(外部引用):指向外层词法环境的引用。
作用域链正是沿着 Outer Reference 逐级向外查找标识符。当函数被传递到其他位置执行时,其 [[Environment]] 内部槽始终指向当初创建它的词法环境,因此它能在任何地方“记住”创建时的变量。
工作原理
规范层面
ECMAScript 规定函数对象的 [[Environment]] 内部槽保存了创建该函数时的词法环境引用。一个函数执行时所使用的作用域链,由该函数自身的 [[Environment]] 和当前函数内新创建的环境记录组合而成。
V8 实现:Context 对象
在 V8 引擎中,词法环境被表示为 Context 对象。一个 Context 本质上是一个固定大小的数组,存放当前作用域中的变量值。
Global Context (索引 0–999)
└── Function Context (索引 1000–1010) ← outer() 的 Context
└── Block Context (索引 1011–1015) ← inner() 关联的闭包 Context当内部函数被创建时,V8 会:
- 生成一个
JSFunction对象,其context字段指向外层函数(例如outer)的Context; - 创建一个
SharedFunctionInfo,包含函数的字节码和元数据。
以这个写法为例:
js
function outer() {
let x = 10;
function inner() {
console.log(x);
}
return inner;
}
const fn = outer(); // outer 执行完毕,但 x 不能被释放outer() 执行完毕后,原则上其 Context 可以被回收。但 fn(即 inner)的 context 字段仍指向该 Context,而 fn 被全局变量引用着——因此 outer() 的 Context 驻留在堆上,其中的 x 也随之保留。
关键点:闭包保留的是整个 Context,而不只是被实际访问的变量。 即使内部函数只用到某个变量,同一个 Context 中的其他变量也一同被保留,直到闭包本身被回收。
作用域链的查找过程
当引擎遇到标识符引用(如 console.log(x))时,查找过程为:
- 从当前作用域的环境记录开始查找;
- 若未找到,则通过
[[Outer]]进入外层环境记录; - 重复上述过程,直到到达全局环境记录;
- 仍未找到时,抛出
ReferenceError。
注意,这里区分读写情境:读取未声明的变量在任何模式下都抛出 ReferenceError;非严格模式下对未声明的变量进行赋值,才会隐式创建全局变量。
闭包的作用域链并不比普通函数更长。一个嵌套三层的闭包,链的长度仍为三。变量查找是 O(N) 的链式遍历(N 为嵌套深度),但实际嵌套深度一般不大;V8 的 inline cache 也会缓存查找结果,使得连续访问同一变量接近 O(1)。
垃圾回收与闭包
保留整个 Context
闭包会使外部函数的整个 Context 保持可达,即使某些变量从未被闭包访问:
js
function outer() {
let bigData = new Array(10_000_000); // 大数组
let x = 10;
function inner() {
console.log(x); // 只访问 x
}
return inner;
}虽然 inner 只使用 x,但 bigData 所在的 Context 整体被保留,无法回收。这正是闭包引起内存占用膨胀的常见来源——真正需要的变量很少,但其它大对象也被拖住。
在 Chrome DevTools 的 heap snapshot 对比中,可以发现 bigData 被 inner.context 间接引用,导致未释放。
V8 的 TurboFan 优化编译器在确定一个变量没有被任何内部函数引用时,可能把它分配到栈上而非 Context 中(escape analysis),从而避免被闭包拖住。但如果作用域内存在 eval() 或 with,这种静态分析会失效。
嵌套闭包与共享 Context
js
function outer() {
let a = 1, b = 2;
return {
getA: () => a,
getB: () => b
};
}getA 和 getB 共享同一个 outer() 的 Context,不会为 a 和 b 创建两份副本。不过,如果其中一个闭包被释放而另一个仍被引用,整个 Context 依然会被保留。
eval() 和 with 的影响
由于无法静态确定 eval() 内会访问哪些外部变量,V8 会保守地保留所有外部变量,从而阻止 escape analysis 优化。with 语句同理,会关闭许多优化路径。
显式释放引用
将不再需要的闭包引用设为 null 可以让其变为不可达,后续由 GC 回收:
js
let heavyClosure = createHeavyClosure();
// ... 使用 heavyClosure
heavyClosure = null; // 释放引用更常见的问题在于清理不及时:全局挂载的事件监听器、未清除的定时器、持续增长的缓存等都会让闭包长期驻留。
内存泄漏场景
事件监听器
js
// 未清理的监听器
function setupHandler() {
const largeData = fetchLargeData();
const button = document.getElementById('btn');
button.addEventListener('click', () => {
console.log(largeData.length); // 闭包持有 largeData
});
// button 从 DOM 移除后,监听器依然存在,largeData 未能释放
}正确的做法是在合适的时机移除监听器:
js
function setupHandler() {
const largeData = fetchLargeData();
const button = document.getElementById('btn');
const handler = () => console.log(largeData.length);
button.addEventListener('click', handler);
return () => button.removeEventListener('click', handler);
}在单页应用中,如果组件卸载时没有清理监听器,闭包会持续持有组件 state、props 甚至 DOM 子树。
定时器
js
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// 缺少清理
}, []);应当在 effect 清理阶段清除定时器:
js
useEffect(() => {
const timer = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(timer);
}, []);没有淘汰策略的缓存
js
// 无限增长的缓存
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result); // 永不删除
return result;
};
}如果输入空间无限(如用户搜索词),缓存会单调增长直到耗尽内存。可以引入容量限制:
js
function memoize(fn, maxSize = 100) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey); // 仅删除最早插入的条目,并非 LRU
}
cache.set(key, result);
return result;
};
}循环中创建闭包
var 声明的循环变量在多次迭代中共享同一个绑定:
js
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), i * 100); // 全部输出 5
}使用 let 可以获得每次迭代独立的绑定:
js
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), i * 100); // 0 1 2 3 4
}IIFE 也可以为每轮迭代创建独立作用域:
js
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(() => console.log(j), j * 100);
})(i);
}let 的解决方案并非因为它不提升,而是 for 循环中的 let 有专门语义:每次迭代创建一个新的词法环境,并在其中创建新的 i 绑定。
常见误解
“函数执行完,局部变量就自动被回收。”
函数执行完仅表示执行上下文从调用栈弹出。若有闭包引用了该函数的 Context,Context 及其变量会留在堆上,直到闭包本身被回收。
“let 解决循环闭包问题是因为它不提升。”
let 同样存在提升(hoisting),但会进入暂时性死区。解决循环闭包的关键在于 for-let 的语义:每次迭代创建独立的绑定。
“箭头函数没有自己的 this,所以不创建闭包。”
箭头函数同样通过 [[Environment]] 保留创建时的词法环境,与普通函数在闭包行为上完全一致。它只是不绑定自己的 this、arguments、super 等。
性能特征
创建开销
下表为在 Chrome(Mac M1)上创建 100 万次的大致耗时(个体差异可能由 V8 版本和机器状态影响):
| 操作 | 耗时(1M 次) | 单次 |
|---|---|---|
创建普通对象 { fn() {} } | ~45 ms | ~45 ns |
创建闭包 () => x | ~55 ms | ~55 ns |
| 闭包引用 3 个外部变量 | ~65 ms | ~65 ns |
| 闭包引用 10 个外部变量 | ~90 ms | ~90 ns |
单个闭包的创建开销极小,但大量创建时仍会累积。如果在一个渲染帧(16.6 ms)内创建 10 万个闭包,仅创建开销就可能占去数毫秒。
内存占用
- 无闭包的普通函数:约 40 字节(
SharedFunctionInfo共享) - 关闭一个外部
Context的闭包:约 96 字节(额外增加Context对象) - 三层嵌套
Context的闭包:约 208 字节
一次性创建 10,000 个闭包大约占用 1–2 MB 内存,若这些闭包被长期持有(如挂在全局监听器上),内存会持续占用。
V8 的优化
- Inline Caching (IC):闭包内变量访问会被 IC 缓存,后续访问走快速路径。
- Escape Analysis:当 TurboFan 确定闭包变量不会逃逸出函数时,可将其分配在栈上,这是“零开销抽象”的关键优化。
- Context Allocation Optimization:如果某个外部变量仅被一个闭包引用,V8 可能将它从
Context提升到更紧凑的结构中。
触发去优化的常见情形包括在闭包内使用 eval()、arguments、通过 bind/call/apply 改变 this,以及 try-catch 块内的闭包。
设计选择:闭包、Class 与 Module
闭包与 Class 私有字段
js
// 闭包方案:状态完全封装在函数内部
function createCounter() {
let count = 0;
return {
increment: () => ++count,
get: () => count
};
}
// Class + 私有字段(ES2022)
class Counter {
#count = 0;
increment() { return ++this.#count; }
get() { return this.#count; }
}闭包方案的特点:
- 状态外部完全不可触及,无法被遍历或序列化(
JSON.stringify返回{})。 - 每调用一次
createCounter都会创建新的函数对象和新的Context;创建 10,000 个计数器时,会产生 10,000 个increment函数和对应数量的Context。 - 调试时状态不易直接查看。
Class 私有字段的特点:
#count在类外部同样不可访问。- 方法定义在原型上,所有实例共享方法引用,内存开销更小。
- 调试器中通常能观察到私有字段的值。
- 支持
instanceof检测和原型继承。
选择原则:
- 实例数量小且需要完全隐藏内部实现时,闭包更合适。
- 实例数量大或需要原型继承时,使用 Class。
- 两者可在同一项目中并存——用闭包实现高阶函数和工厂,用 Class 构建领域模型。
闭包与 Module
ES Module 的作用域天然隔离,可替代部分单例闭包场景:
js
// counter.js
let count = 0;
export const increment = () => ++count;
export const get = () => count;count 在模块外不可访问,且整个应用只有一个模块实例。当仅需一个实例时,Module 比工厂函数更直接。
难以替代的闭包场景
- 高阶函数:
debounce、throttle、once、memoize等模式本质上就是“函数 + 持久状态”,闭包最自然。 - 回调注册:事件监听、
Promise.then、setTimeout等需要访问注册时的上下文。 - 部分应用与柯里化:将多参数函数逐步转化为单参数函数链。
- React Hooks:
useState、useEffect等依赖闭包在多次渲染间保持状态引用。
使用 DevTools 诊断闭包相关问题
堆快照对比
- 打开 DevTools → Memory → Heap snapshot。
- 拍摄 Snapshot 1(初始状态)。
- 执行目标操作(打开弹窗、路由切换、滚动等)。
- 拍摄 Snapshot 2。
- 在 Snapshot 2 中选择 Comparison 视图,与 Snapshot 1 对比。
- 按 Delta(增量)降序排列,关注新增的大型对象。
- 查看对象的 Retainers(保留路径),定位到具体的闭包。
典型的闭包造成的保留路径:
Detached DOM node
→ event listener (closure)
→ context (外部函数变量)
→ large data array / string如果在 Retainers 链中看到 closure → context 引用,说明闭包正在阻止对象回收。
性能面板辅助
录制一段操作后,在 Memory 面板观察 JS heap 走势。如果操作结束后堆内存没有回落到操作前水平(锯齿状上升),可能意味着闭包或其他引用导致内存无法释放。
常见疑问
闭包和普通函数的本质区别是什么?
从引擎角度,两者的区别在于 [[Environment]] 所指向的词法环境是否来自一个已经执行完毕的外部函数。普通函数的 [[Environment]] 通常指向全局词法环境,即使形成链式查找,也不涉及对外部函数 Context 的保留。闭包的特殊之处在于:它的 [[Environment]] 指向一个调用栈上已消失的函数 Context,引擎必须将该 Context 保留在堆上,直到闭包本身被回收。
用 Map 管理缓存与用闭包封闭缓存的根本区别?
闭包将缓存封装在函数内部,外部完全感知不到缓存的存在,调用接口最简。代价是缓存生命周期与函数实例绑定,无法独立清理、共享或跨实例复用。将 Map 作为参数传入则把缓存暴露为独立对象,调用方可以自行管理清理、容量和复用,但需要承担额外的管理责任。选择取决于信息隐藏的程度与外部可控性之间的平衡。
为什么 for 循环中的 let 能解决 var 的闭包问题?
var 声明时整个循环只有一个变量绑定,所有闭包引用同一个 i 的存储位置,循环结束时 i 为 5,所有闭包打印 5。let 在 for 循环中有特殊语义(ECMAScript §13.7.4.8):每次迭代创建新词法环境,内含新的 i 绑定,并使用上一轮迭代的值进行初始化。因此,每次迭代的闭包都能捕获到本轮独立的 i。
箭头函数是否避免闭包?
不会。箭头函数同样通过 [[Environment]] 保留创建时的词法环境,在变量捕获上与普通函数行为一致。区别仅在于 this、arguments 等特殊绑定。
什么场景下会优先选择闭包而非 Class?
需要“一个函数 + 一点状态”且希望状态完全隐藏时,闭包非常自然。例如,实现一个请求去重器:
js
function createDeduplicator(dedupWindow = 200) {
const inflight = new Map();
return async function deduplicate(key, fetchFn) {
if (inflight.has(key)) return inflight.get(key);
const promise = fetchFn().finally(() => {
setTimeout(() => inflight.delete(key), dedupWindow);
});
inflight.set(key, promise);
return promise;
};
}这里 inflight 外部无法触及,接口只是一个函数。若需要额外暴露 cancel、size、clear 等方法,Class 会更合适——此时的状态已超出“一点状态”的范畴,成为一个有完整生命周期的对象。
