Skip to content

第5章:闭包与内存泄漏 —— Context 的生命周期管理

“闭包不是魔法,它是 [[Environment]]Context 的长期引用。”
—— 当你看到“内存快照”,你就看穿了泄漏的根源。

闭包的本质 —— 物理视角

我们已经知道:

  • 每个函数对象都有一个内部指针:[[Environment]]
  • 它指向函数创建时的词法环境(即当时的 Context 对象)
  • Context 存储着外层函数的变量

所以,闭包的物理本质是

一个函数对象 + 一个指向堆上 Context[[Environment]] 指针

只要这个函数还存在,[[Environment]] 就会阻止 GC 回收它所指向的 Context,进而阻止其中所有变量被回收。

内存泄漏场景:一个真实案例

js
// 一个巨大的数据对象
let heavyData = new Array(1e6).fill('leak-data');

function dataProcessor() {
  // 模拟一些配置
  let config = { version: '1.0', debug: true };

  return function process(id) {
    // 这个闭包无意中捕获了整个外层作用域
    console.log(`Processing ${id} with config`, config);
    console.log(`Data length: ${heavyData.length}`); // ❌ 捕获了 heavyData!
    return heavyData[id % heavyData.length];
  };
}

// 创建处理函数
const processor = dataProcessor();

// 模拟调用
processor(123);
processor(456);

// ... 程序继续运行 ...

// 问题:dataProcessor 已执行完,但 memory 未释放

内存结构分析

[堆内存]
Context_dataProcessor:
  variables_[0]: config = { ... }
  variables_[1]: heavyData = [ ..., 'leak-data', ... ] (1M items)
  previous_: → 全局 Context

[processor 函数对象]
  [[Environment]] → Context_dataProcessor  ← 强引用!

[全局变量]
  processor → 指向函数对象

即使 dataProcessor() 执行结束,Context_dataProcessor 依然存在,
因为 processor.[[Environment]] 还指着它,而 processor 是全局变量。

结果heavyData 被长期持有,占用 ~几十 MB 内存。

为什么开发者容易犯这个错误?

  1. 误以为只有“显式使用”的变量才会被捕获
    • 实际上,只要在同一个作用域声明,就可能被闭包“连坐”
  2. 不了解 Context 是按作用域整体分配的
    • V8 不会为每个变量单独创建 Context
    • 整个 dataProcessor 函数体共享一个 Context

解决方案:如何避免闭包内存泄漏

1. 主动断开引用

js
// 使用完毕后,主动释放
processor = null;

// 此时:
// processor → null
// processor.[[Environment]] 断开
// Context_dataProcessor 可被 GC 标记为不可达
// heavyData 被回收

2. 避免在循环中创建闭包捕获大对象

危险代码:

js
for (let i = 0; i < 1000; i++) {
  const bigData = getHugeDataset(); // 每次都创建大对象
  buttons[i].onclick = function() {
    console.log(bigData[i]); // 每个闭包都捕获 bigData
  };
}
// 结果:1000 个闭包,每个都持有大对象,内存爆炸

安全做法:

js
// 提取闭包外部
const sharedData = getSharedDataset(); // 小且共享

for (let i = 0; i < 1000; i++) {
  buttons[i].onclick = function() {
    console.log(sharedData[i]); // 只捕获小数据
  };
}

3. 使用 WeakMap 存储私有数据

js
const privateData = new WeakMap();

function createUser(name) {
  const user = {};
  privateData.set(user, { name, secret: generateSecret() });
  return {
    getName() {
      return privateData.get(user).name;
    }
  };
}

// WeakMap 的 key 是弱引用
// 当 user 对象被回收时,privateData 中的条目自动消失

4. 拆分作用域,减少 Context 大小

优化原例:

js
let heavyData = new Array(1e6).fill('data');

// 把 heavyData 移到外层,不放在闭包的作用域内
function createProcessor() {
  let config = { version: '1.0' };

  // 只返回需要的数据
  return function process(id, data) {
    console.log(`Processing ${id} with config`, config);
    return data[id % data.length];
  };
}

const processor = createProcessor();
processor(123, heavyData); // 显式传参,不捕获

如何诊断闭包内存泄漏?

工具:Chrome DevTools Memory Snapshot

  1. 打开 DevTools → Memory 面板
  2. 拍摄堆快照(Take Heap Snapshot)
  3. 查找 Closure 对象
  4. 展开查看其 (closure)(context) 条目
  5. 检查是否有大对象被意外持有

关键观察点:

  • Shallow Size:对象自身大小
  • Retained Size:该对象阻止回收的总内存
  • 如果 Retained Size 很大,说明它“绑架”了很多内存

本章核心结论

闭包的代价是内存,收益是状态。明智地使用它。

  • 闭包通过 [[Environment]] 指针“绑架”外层 Context
  • Context 在堆上,GC 无法回收被引用的对象
  • 内存泄漏往往源于“无意的捕获”或“忘记释放”
  • 解决方案:断开引用、拆分作用域、使用 WeakMap