闭包:函数 + 词法环境的精确定义
闭包 = 函数对象 + 创建时捕获的词法环境记录,是 JavaScript 中私有状态和模块化的基础
ECMAScript 规范从不使用"闭包"这个词——规范描述的是函数对象的 [[Environment]] 内部槽,它指向函数被创建时所在的词法环境记录(Environment Record)。"闭包"是对这一机制的通俗命名:一个函数带着它创建时的词法环境一起打包,即使外层函数已经执行完毕、其执行上下文已经出栈,那个词法环境记录依然被函数对象持有,不会被垃圾回收。
闭包的实用价值是精确的:它让函数能够访问定义时所在作用域的变量,无论该函数在何时何地被调用。这是 JavaScript 工厂函数、模块模式、事件处理器保存状态、memoize、柯里化等几乎所有高阶编程模式的基础机制。闭包也是内存泄漏最常见的隐性来源——理解它的内部机制是写出高性能代码的前提。
从语言设计的角度来看,闭包是词法作用域(第 17 章)与一等函数(函数是值,可以被传递和返回)这两个特性结合后的自然产物。词法作用域确保了变量查找路径在定义时固定,一等函数允许函数被传递到定义它的作用域之外去调用,两者结合就使得"在作用域之外访问作用域内的变量"成为可能——这正是闭包的本质。凡是同时具备这两个特性的语言(JavaScript、Python、Kotlin、Swift、Rust 等),都有闭包机制,尽管具体实现细节不同。
🔹 Level 1 · 你需要知道的
闭包的核心行为
闭包最直观的表现是:一个函数"记住"了它被创建时所在作用域的变量,即使该作用域已经"结束"了。这里的"结束"是指外层函数已经执行完毕、其执行上下文已从调用栈弹出——但这不意味着那个作用域里的变量就消失了,它们被内层函数的 [[Environment]] 指针持续引用,因此不会被垃圾回收器回收。
function makeCounter() {
let count = 0; // 这个变量在 makeCounter 执行完后仍然存活
return function increment() {
count += 1;
return count;
};
}
const counter = makeCounter(); // makeCounter 执行完毕,count 仍然活着
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
makeCounter 执行完毕后,它的执行上下文(EC)从调用栈弹出,但 count 变量所在的词法环境记录(ER)没有被销毁——因为 increment 函数对象持有着对这个 ER 的引用(通过 [[Environment]] 内部槽)。只要 counter 变量存活,这个 ER 就不会被 GC。
工厂函数:用闭包创建独立的私有状态
工厂函数(factory function)是闭包最典型的应用之一:每次调用工厂函数,都会创建一个新的词法环境记录(ER),其中的变量对该次调用是私有的,不同调用之间的状态完全独立,互不干扰。
function makeCounter(initialValue = 0) {
let count = initialValue;
return {
increment() { count++; },
decrement() { count--; },
value() { return count; }
};
}
const c1 = makeCounter(0);
const c2 = makeCounter(100);
c1.increment();
c1.increment();
console.log(c1.value()); // 2
console.log(c2.value()); // 100(c2 有自己独立的 count,不受 c1 影响)
c1 和 c2 各自持有不同的 makeCounter 调用产生的不同词法环境,因此 count 是完全独立的。这是在 ES Modules 普及之前,JavaScript 实现"私有变量"的核心模式。
常见闭包场景
事件处理器保存状态:
function setupButton(label) {
let clickCount = 0;
document.getElementById('btn').addEventListener('click', function() {
clickCount++; // 访问外层的 clickCount
console.log(`${label} 被点击了 ${clickCount} 次`);
});
}
setupButton('提交按钮'); // 每次点击都能访问最新的 clickCount
memoize(缓存函数结果):
function memoize(fn) {
const cache = new Map(); // cache 被所有被 memoize 的调用共享
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize(function(n) {
// 模拟耗时计算
return n * n;
});
console.log(expensiveCalc(10)); // 100(计算)
console.log(expensiveCalc(10)); // 100(从 cache 返回,不重新计算)
闭包与内存泄漏的关联
闭包是 JavaScript 中内存泄漏最常见的来源之一,但它并不是天然的"内存杀手"——问题在于持有的粒度。一个闭包持有的不是"它使用的那个变量",而是"整个词法环境记录(ER)"。ER 里可能有很多变量,即使闭包只用了其中一个,其他的也都无法被 GC。在不清楚 V8 优化行为(Level 2 会讲)的情况下,应该把这条规则当作保守估计来使用。
function createHandler() {
const bigData = new Array(1000000).fill('data'); // 1MB 数据
const smallValue = 42;
// 这个闭包只用了 smallValue,但它持有整个 ER
// bigData 因此无法被 GC
return function handler() {
return smallValue;
};
}
const handler = createHandler();
// 只要 handler 存活,bigData 就不会被释放
🔸 Level 2 · 它是怎么运行的
函数对象的 [[Environment]] 内部槽
闭包的物理实现依赖于函数对象的一个内部槽。要理解闭包,必须理解函数对象在 ECMAScript 规范里是如何存储"创建时的作用域"的。答案就是 [[Environment]] 内部槽——这是每个函数对象在创建时就被设置好的、不可从 JavaScript 代码直接访问的内部字段,它永久指向函数被定义时所在的那个 ER。
每个通过 function 关键字、箭头函数、方法定义创建的函数对象,都有一个 [[Environment]] 内部槽。这个槽存储的是函数被创建(定义)时所在的词法环境记录。
创建函数时:
当前运行的 EC 的 LexicalEnvironment → ER_outer
↓
新函数对象 fn
fn.[[Environment]] = ER_outer ← 捕获了创建时的词法环境
调用函数时,引擎执行 NewFunctionEnvironment,创建新的 FunctionEnvironmentRecord:
调用 fn() 时:
创建新的 FunctionEnvironmentRecord (ER_fn)
ER_fn.[[OuterEnv]] = fn.[[Environment]] ← 连接到闭包捕获的外层 ER
新建 EC,设置 LexicalEnvironment = ER_fn
压入 EC Stack
这就是作用域链形成闭包的物理机制:新 EC 的 ER 的外层是函数创建时的 ER,不是调用时的 ER。
闭包的内存结构示意图
┌─────────────────────────────────────────────────────────────────┐
│ 闭包的内存结构 │
│ │
│ 堆(Heap) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ makeCounter 的 ER (词法环境记录) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ bindings: │ │ │
│ │ │ count → 3 │ │ │
│ │ │ [[OuterEnv]] → Global ER │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ↑ │ │
│ │ │ [[Environment]] 内部槽引用 │ │
│ │ ┌────────┴───────────────────────────────────────────┐ │ │
│ │ │ increment 函数对象 │ │ │
│ │ │ [[Environment]] → makeCounter 的 ER ──────────────┘ │ │
│ │ │ [[Call]] → 函数代码 │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 栈(Stack)变量: │
│ counter → 指向 increment 函数对象 │
│ │
│ GC 可达性: │
│ counter(栈变量)→ increment 函数对象 → makeCounter 的 ER │
│ → count 变量(值 3) │
│ 只要 counter 存活,整条链都不会被 GC │
└─────────────────────────────────────────────────────────────────┘
循环中闭包的经典问题:var vs let
var 版本:所有闭包共享同一个 ER 里的同一个 i
const funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() { return i; });
}
// 此时 i = 3(循环结束后 i 仍然存在于函数作用域中)
console.log(funcs[0]()); // 3
console.log(funcs[1]()); // 3
console.log(funcs[2]()); // 3
var i 的作用域:函数(或全局)ER
i: 3(循环结束后的值)
↑
funcs[0].[[Environment]] ─┐
funcs[1].[[Environment]] ─┤ 都指向同一个 ER!
funcs[2].[[Environment]] ─┘
let 版本:每次迭代创建新的 ER,各闭包有独立的 i
const funcs = [];
for (let i = 0; i < 3; i++) {
funcs.push(function() { return i; });
}
console.log(funcs[0]()); // 0
console.log(funcs[1]()); // 1
console.log(funcs[2]()); // 2
迭代 0: ER₀ { i: 0 } ← funcs[0].[[Environment]]
迭代 1: ER₁ { i: 1 } ← funcs[1].[[Environment]]
迭代 2: ER₂ { i: 2 } ← funcs[2].[[Environment]]
(三个独立的 ER,三个独立的 i)
V8 对闭包的优化:只保留实际引用的变量
理论上,闭包捕获整个 ER 意味着 ER 里的所有变量都不能被 GC。但 V8 引擎(Chrome 和 Node.js 的 JavaScript 引擎)实现了一项重要的优化来缓解这个问题:闭包变量收集(closure variable analysis)。V8 的编译器在函数实例化时进行静态分析,确定哪些变量真正被内层函数引用,只把这些变量放进 ER 里,不被任何闭包引用的变量不进入 ER,可以直接 GC。
这个优化在没有 eval 和 with 的代码里工作得非常好,能显著降低闭包的内存开销。了解这个优化,能帮助你写出更"内存友好"的代码——关键就是避免 eval 和 with。
V8(Chrome/Node.js)有一项重要的优化:通过静态分析,只在闭包捕获的 ER 里保留真正被内部函数引用的变量,不被引用的变量不保留在 ER 里。
function outer() {
const bigData = new Array(1000000).fill(0); // 4MB
const smallValue = 42;
// 只引用了 smallValue,V8 的 ER 里只保留 smallValue
// bigData 可以被 GC(不被任何活着的引用持有)
return function inner() {
return smallValue;
};
}
const fn = outer();
// V8 中:bigData 已被 GC,只有 smallValue 活着
但这个优化有两个重要的例外,会阻止 V8 做优化:
eval的存在:如果闭包内部有eval,V8 无法静态分析哪些变量会被访问(eval可以访问任意变量名),因此保留整个 ERwith语句:同样阻止静态分析,V8 保留完整 ER
function withEval() {
const bigData = new Array(1000000).fill(0); // 4MB
const smallValue = 42;
return function inner(code) {
eval(code); // eval 的存在导致 V8 保留整个 ER,bigData 无法被 GC
return smallValue;
};
}
React Hooks 的 stale closure 问题
Stale closure(过期闭包/陈旧闭包)是 React Hooks 中最常见的 bug 类型之一,也是许多开发者从 class 组件迁移到函数组件时遭遇的主要困惑点。它的根本原因不是 React 的问题,而是 JavaScript 闭包机制的自然行为:每次函数组件渲染都创建新的词法环境,每次 useEffect 或 useCallback 的回调都在它们被创建时的词法环境里捕获变量。如果这些回调长期存活(如通过 setInterval、事件监听器),它们会持续持有旧的词法环境里的旧值,即使组件后续渲染更新了这些值,回调里看到的仍是旧的。
stale closure(过期闭包)是指闭包捕获了旧版本的变量值,而不是最新值:
// React 组件(演示 stale closure)
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
// 这个 effect 在 mount 时执行,捕获了当时的 count(值为 0)
const timer = setInterval(() => {
// 每秒执行,但 count 永远是 0(stale closure)
console.log('当前 count:', count); // 总是 0
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组:effect 只在 mount 时执行,从不更新
return <button onClick={() => setCount(c => c + 1)}>点击: {count}</button>;
}
正确做法 1:在依赖数组里加入 count(effect 每次 count 改变时重新执行):
React.useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // 正确:每次 count 改变时重新捕获
}, 1000);
return () => clearInterval(timer);
}, [count]); // 依赖 count
正确做法 2:使用 useRef 持有最新值(ref 不触发重渲染):
const countRef = React.useRef(count);
countRef.current = count; // 每次渲染更新 ref
React.useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', countRef.current); // 始终是最新值
}, 1000);
return () => clearInterval(timer);
}, []); // ref 不需要在依赖数组里
🔺 Level 3 · 规范怎么定义的
规范 10.2.1:[[Environment]] 内部槽
规范第 10.2.1 节(ECMAScript Function Objects)定义了函数对象的内部槽:
| 内部槽 | 类型 | 描述 |
|---|---|---|
[[Environment]] |
Environment Record | 函数被定义时所在的词法环境 |
[[PrivateEnvironment]] |
PrivateEnvironment Record | 类私有字段的环境 |
[[FormalParameters]] |
Parse Node | 形参列表的解析节点 |
[[ECMAScriptCode]] |
Parse Node | 函数体的解析节点 |
[[Realm]] |
Realm Record | 函数所属的 Realm |
[[HomeObject]] |
Object / undefined | 方法所属的对象(用于 super) |
[[IsClassConstructor]] |
Boolean | 是否是 class 构造函数 |
OrdinaryFunctionCreate 是创建普通函数对象的规范操作(简化版):
OrdinaryFunctionCreate(proto, sourceText, parameterList, body, thisMode, env, privateEnv):
1. 创建一个新的函数对象 F
2. F.[[Environment]] = env ← env 就是当前运行 EC 的 LexicalEnvironment
3. F.[[FormalParameters]] = parameterList
4. F.[[ECMAScriptCode]] = body
5. F.[[ThisMode]] = thisMode (lexical/strict/global,箭头函数是 lexical)
6. F.[[Realm]] = 当前 Realm
7. ... 其他初始化 ...
8. 返回 F
注意第 2 步:env 参数来自调用 OrdinaryFunctionCreate 时的当前 EC 的 LexicalEnvironment,这就是"函数在哪里定义,就捕获哪里的词法环境"的规范依据。
NewFunctionEnvironment:调用时创建新 ER
规范的 NewFunctionEnvironment(F, newTarget) 操作(第 9.1.2.4 节):
NewFunctionEnvironment(F, newTarget):
1. 创建新的 FunctionEnvironmentRecord env
2. env.[[FunctionObject]] = F
3. 如果 F.[[ThisMode]] 是 lexical → env.[[ThisBindingStatus]] = 'lexical'
否则 → env.[[ThisBindingStatus]] = 'uninitialized'
4. env.[[NewTarget]] = newTarget
5. env.[[OuterEnv]] = F.[[Environment]] ← 连接到闭包捕获的外层 ER!
6. 返回 env
第 5 步是关键:新 FunctionEnvironmentRecord 的 [[OuterEnv]] 指向 F.[[Environment]](函数创建时的 ER),不是当前调用位置的 ER。这是词法作用域在函数调用时的物理实现。
FunctionDeclarationInstantiation 中的闭包绑定
规范 10.2.1.1(FunctionDeclarationInstantiation)在函数体执行前进行初始化。内层函数声明的处理步骤中,引擎为每个内层函数调用 InstantiateOrdinaryFunctionObject,这个操作内部调用 OrdinaryFunctionCreate,传入当前 EC 的 env 作为 [[Environment]]——这是内层函数"捕获"外层函数环境的规范级描述。
模块中的 import 绑定(闭包的特殊形式):ModuleEnvironmentRecord 中的 import 绑定是"间接绑定"(Indirect Binding)——它存储的不是值,而是"某个其他 ER 中某个名称的引用"。当读取 import 绑定时,规范要求穿透到实际存储值的 ER 里读取。这使得 ESM 的 export 与 import 形成"实时连接",导出方改变值后,导入方立即看到新值(这不是闭包,但机制类似于闭包的引用传递,而非值复制)。
💎 Level 4 · 边界与陷阱
陷阱 1:闭包持有的是整个 ER,不只是用到的变量(V8 优化例外)
这个陷阱在日常开发中通常被低估,原因是它不会立刻导致错误,而是随着时间推移让内存使用量悄悄增长,直到某个时刻触发 OOM(内存不足)崩溃或浏览器标签页变得极度卡顿。在服务端 Node.js 应用里,这类问题会导致进程内存持续增长,最终被 OOM killer 杀死。理解这个陷阱并掌握诊断方法,是写出生产级 JavaScript 代码的必备能力。
function createLeak() {
const hugeArray = new Float64Array(10 * 1024 * 1024); // 80MB
let counter = 0;
// 这个闭包只用了 counter,不用 hugeArray
// 但在没有 V8 优化的情况下(有 eval 或 with 时),hugeArray 也无法被 GC
return {
increment() { counter++; },
value() { return counter; }
};
}
const obj = createLeak();
// obj 持有两个闭包,每个都持有 createLeak 的 ER
// ER 里有 hugeArray(80MB),hugeArray 无法被 GC
诊断工具:Chrome DevTools → Memory → Take Heap Snapshot。在快照里搜索 hugeArray 或查看 Closure 类型的对象,就能看到哪些变量被闭包持有而无法被回收。
解决方案:在不再需要大对象时,将其显式设置为 null:
function createSafe() {
let hugeArray = new Float64Array(10 * 1024 * 1024);
let counter = 0;
// 使用完大数据后显式释放
const result = processData(hugeArray);
hugeArray = null; // 断开引用,让 GC 可以回收
return {
increment() { counter++; },
value() { return counter; },
result() { return result; }
};
}
陷阱 2:eval 和 with 阻断 V8 闭包优化,导致意外内存泄漏
// 没有 eval:V8 可以优化,bigData 被 GC
function withoutEval() {
const bigData = new Uint8Array(50 * 1024 * 1024); // 50MB
const value = 1;
return () => value;
}
// 有 eval:V8 不优化,bigData 不能被 GC
function withEval() {
const bigData = new Uint8Array(50 * 1024 * 1024); // 50MB
const value = 1;
return (code) => {
eval(code); // 即使从不执行 eval,它的存在也阻止了优化
return value;
};
}
const fn1 = withoutEval(); // bigData 被 GC,约 0MB 持续占用
const fn2 = withEval(); // bigData 无法被 GC,50MB 持续占用
// 测量内存影响
const mem = performance.memory; // Chrome 提供
// fn2 存活期间,heapUsed 会多出约 50MB
工程实践:除非有明确的动态代码执行需求,永远不要在生产代码里用 eval 或 new Function(string)(new Function 也受同样的限制)。
陷阱 3:循环中重复添加事件监听器创建多个闭包持有 DOM 引用
// 错误示例:每次调用 setup 都会添加新的 listener,但旧的没有被移除
function setup() {
const button = document.getElementById('btn');
const heavyData = fetchHeavyData(); // 大对象
// 每次调用 setup,都会添加一个新闭包,持有 heavyData
button.addEventListener('click', function handler() {
process(heavyData); // 闭包持有 heavyData
});
}
// 如果 setup 被多次调用(如组件重挂载),会累积多个 handler
// 每个 handler 都持有一个 heavyData,内存持续增长
setup(); setup(); setup(); // 3 个闭包,3 份 heavyData
正确做法:移除旧 listener 后再添加新 listener,或使用 { once: true }:
let currentHandler = null;
function setup() {
const button = document.getElementById('btn');
const heavyData = fetchHeavyData();
// 先移除旧 handler
if (currentHandler) {
button.removeEventListener('click', currentHandler);
}
currentHandler = function handler() {
process(heavyData);
};
button.addEventListener('click', currentHandler);
}
陷阱 4:IIFE 解决 var 循环问题的原理
IIFE(Immediately Invoked Function Expression,立即调用函数表达式)是 ES2015 之前最重要的 JavaScript 编程模式之一,大量出现在 jQuery 时代的代码库、AMD 模块系统和早期的 SPA 框架里。理解 IIFE 解决循环闭包问题的原理,不仅帮助你维护旧代码,更能加深对闭包、函数作用域和 ER 创建机制的理解。
在 let 普及之前,解决循环 + 闭包问题的标准写法是 IIFE(立即调用函数表达式):
const funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(
(function(capturedI) { // IIFE 创建新的函数作用域
return function() { return capturedI; };
})(i) // 把当前 i 作为参数传入
);
}
console.log(funcs[0]()); // 0
console.log(funcs[1]()); // 1
console.log(funcs[2]()); // 2
IIFE 的原理:每次迭代,(function(capturedI) {...})(i) 立即被调用,创建一个新的 FunctionEnvironmentRecord,其中有独立的 capturedI 绑定(值为当时的 i)。内层函数 function() { return capturedI; } 的 [[Environment]] 指向这个 IIFE 的 ER,而不是外层的 var i 所在的 ER。这在规范层面等同于 let 为每次迭代创建新 ER 的效果。
现代代码里,let 是更清晰的解决方案——两者在语义上完全等价,但 let 不需要额外的函数调用开销。
陷阱 5:模块模式(IIFE 返回对象)是闭包的经典应用
模块模式(Module Pattern)是 JavaScript 历史上最重要的设计模式之一,在 ES Modules(ESM)普及之前(2015~2020 年),它是几乎所有 JavaScript 库和框架实现"封装"和"私有状态"的标准方式。jQuery、Lodash、早期的 Angular.js 都大量使用这个模式。它的核心机制就是 IIFE + 闭包。
// 模块模式:在 ESM 之前实现私有变量和公共接口的标准方式
const UserModule = (function() {
// 私有变量(外部不可直接访问)
let users = [];
let nextId = 1;
// 私有函数
function validate(user) {
return user.name && user.name.length > 0;
}
// 公共接口
return {
add(name) {
const user = { id: nextId++, name };
if (!validate(user)) throw new Error('无效用户名');
users.push(user);
return user.id;
},
get(id) {
return users.find(u => u.id === id);
},
count() {
return users.length;
}
};
})();
UserModule.add('Alice'); // 1
UserModule.add('Bob'); // 2
console.log(UserModule.count()); // 2
console.log(UserModule.users); // undefined(私有,不可访问)
模块模式的本质:IIFE 立即执行,创建一个 FunctionEnvironmentRecord(持有 users、nextId、validate),返回的对象里的每个方法都是闭包,它们的 [[Environment]] 都指向这个 ER。外部代码只能通过返回的公共接口访问,无法直接读写 users 和 nextId,实现了封装。
ESM 的 export / import 在语义上提供了更好的模块化(静态分析、tree shaking 支持、无需 IIFE),但在理解闭包时,模块模式是最直观的闭包综合示例。
附:闭包与柯里化(Currying)
柯里化(Currying)是函数式编程的重要技术,它将一个接受多个参数的函数转换为一系列只接受单个参数的函数。柯里化的实现依赖闭包:每次调用返回的函数都通过闭包"记住"了已经传入的参数。
// 手动柯里化
function curry(fn) {
return function curried(...args) {
// 如果已经传入了足够多的参数,直接调用原函数
if (args.length >= fn.length) {
return fn.apply(this, args);
}
// 否则,返回一个新函数,通过闭包保存已传入的参数
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
};
}
const add = curry((a, b, c) => a + b + c);
const add5 = add(5); // 闭包捕获 args = [5]
const add5and3 = add5(3); // 闭包捕获 args = [5, 3]
console.log(add5and3(2)); // 10 = 5 + 3 + 2
// 实际应用:创建特定用途的函数
const multiply = curry((a, b) => a * b);
const double = multiply(2); // 闭包:args = [2]
const triple = multiply(3); // 闭包:args = [3]
console.log([1, 2, 3, 4].map(double)); // [2, 4, 6, 8]
console.log([1, 2, 3, 4].map(triple)); // [3, 6, 9, 12]
柯里化创建的每个中间函数都是一个闭包,持有已传入的参数(以数组形式存储在外层 ER 里)。curry 函数本身的 ER 持有原始函数 fn,每次中间调用的 ER 持有已累积的参数 args。这就是闭包在函数式编程里的典型使用方式——通过闭包将"部分应用"(partial application)的参数保存起来,等待后续参数补全后再执行。
附:WeakRef 和 FinalizationRegistry 与闭包内存管理
ES2021 引入了 WeakRef(弱引用)和 FinalizationRegistry(终结器注册表),为手动内存管理提供了工具。在闭包持有大对象的场景里,可以用 WeakRef 持有大对象的弱引用,让 GC 在必要时回收它:
function createCachedProcessor() {
// 用 WeakRef 持有大对象的弱引用
// WeakRef 不阻止 GC 回收目标对象
let bigDataRef = null;
const registry = new FinalizationRegistry((key) => {
console.log(`大对象 ${key} 已被 GC 回收`);
bigDataRef = null; // 清理引用
});
function ensureData() {
let data = bigDataRef?.deref(); // 尝试获取弱引用的目标
if (!data) {
data = new Float64Array(10 * 1024 * 1024); // 重新创建
bigDataRef = new WeakRef(data);
registry.register(data, 'bigData'); // 注册终结器
}
return data;
}
return function process(index) {
const data = ensureData(); // 按需获取数据(如果被 GC 了就重新创建)
return data[index];
};
}
const processor = createCachedProcessor();
processor(100); // 使用数据
// 如果内存紧张,GC 可以回收 bigData(因为是 WeakRef 持有)
// 下次调用 processor 时会重新创建数据
WeakRef 是一个高级特性,适用于"数据可以重新生成、但希望在内存充足时缓存它"的场景(即缓存失效策略的底层实现)。在普通应用代码里,正确地释放强引用(置为 null、移除事件监听器、关闭 Generator)是更直接的内存管理方式。
小结
-
闭包是函数对象 + 创建时词法环境记录的组合:每个函数对象有
[[Environment]]内部槽,指向函数被定义时的 ER;调用函数时,新 FunctionEnvironmentRecord 的[[OuterEnv]]指向[[Environment]],形成跨越调用栈的作用域链。 -
循环中
var与let的差异:var的所有迭代闭包共享同一个 ER 里的同一个变量;let通过 CreatePerIterationEnvironment 为每次迭代创建独立的 ER,各闭包捕获独立的变量值。 -
闭包持有整个词法环境记录,即使只引用其中一个变量;V8 通过静态分析优化闭包(只保留实际被引用的变量),但
eval和with的存在会阻断这个优化,导致整个 ER(及其所有变量)都被保留,造成意外内存占用。 -
React Hooks 的 stale closure 问题本质是:useEffect 的回调在创建时捕获了当时的状态值,如果依赖数组不包含该状态,后续状态更新后闭包仍持有旧值;解决方案是将状态加入依赖数组,或使用 useRef 持有最新值。
-
主动释放闭包持有的大对象:在不再需要时将变量显式设置为
null,断开 ER 对大对象的引用;事件监听器要在不需要时removeEventListener,避免闭包长期持有 DOM 节点和大对象导致的内存泄漏。