Skip to content

内存与资源:自动回收不等于自动收尾 ​

JavaScript 会自动回收不再需要的内存,但它无法替你判断“这个订阅在业务上已经没用了”。只要某条强引用仍能从根到达对象,对象就可能继续存活。泄漏经常不是完全不可访问的垃圾,而是程序仍然握着不再需要的东西。

沿引用找到保留者 ​

js
const archive = [];
function remember() {
  const draft = { text: '一段已经读完的说明' };
  archive.push(() => draft.text);
}
remember();
console.log(archive[0]()); // 一段已经读完的说明

函数结束后,draft 仍被闭包引用,闭包又被 archive 持有。这里是否泄漏取决于是否仍需要保存它;若 archive 无限增长而这些内容永远不再使用,就是值得检查的保留链。

把局部变量设为 null 不会切断其他地方已有的引用。反过来,一群对象即使互相引用,只要从根完全不可达,也可以被回收;“有环一定泄漏”不是现代追踪式回收的通用规则。

资源清理应有明确入口 ​

下面 HTML 可直接打开。每秒更新计数,点击停止后计数固定,重复点击停止也安全。

html
<!doctype html>
<meta charset="utf-8">
<title>可清理的计数</title>
<output id="count">0</output>
<button id="stop" type="button">停止</button>
<script>
  function mountCounter(output) {
    const controller = new AbortController();
    let count = 0;
    let stopped = false;
    const timer = setInterval(() => {
      output.textContent = String(++count);
    }, 1000);
    window.addEventListener('resize', () => {
      output.title = `当前宽度 ${window.innerWidth}`;
    }, { signal: controller.signal });
    return () => {
      if (stopped) return;
      stopped = true;
      clearInterval(timer);
      controller.abort();
    };
  }
  const dispose = mountCounter(document.querySelector('#count'));
  document.querySelector('#stop').addEventListener('click', dispose);
</script>

定时器回调持有 output,窗口监听器也通过闭包持有它。仅把 DOM 节点 remove 不会自动取消这些注册。dispose 同时清理定时器与监听器,幂等设计避免调用两次带来意外。

观察器 disconnect、Worker terminate、请求 abort、数据库连接 close 也属于不同资源的显式生命周期。文件和网络等资源的关闭语义由各自 API 决定,垃圾回收没有统一代办服务。

WeakMap 不会替你移除事件监听 ​

WeakMap 适合给对象关联元数据,同时不因键本身的存在阻止回收。例如给 DOM 节点记录计算结果:节点被其他地方完全释放时,元数据也不必靠手写 delete 才具备释放条件。

但是如果 window 上的监听闭包仍引用节点,WeakMap 没有能力切断那条强引用。选择弱集合前先画出引用关系,不要把 Weak 这个单词当作内存问题的橡皮擦。

WeakRef 与 FinalizationRegistry 的边界 ​

js
let item = { label: '可重新计算的缓存值' };
const ref = new WeakRef(item);
console.log(ref.deref()?.label); // 当前可读取
item = null;
// 不写“下一行一定是 undefined”:回收时机没有这种承诺

deref 可能返回对象,也可能在已回收后返回 undefined。不要在跨异步边界后假定此前的弱引用仍可用;需要使用时重新读取并持有本次结果。

FinalizationRegistry 可在目标被回收后安排清理回调,但回调可能延迟,也可能根本没有机会执行。它适合不影响正确性的辅助工作,不能承担支付确认、必须释放的锁或唯一的数据持久化。

注册时持有值会被注册表强引用,因此不要让它间接指回目标,否则可能保住本想观察回收的对象。也不能用一个断言规定“五十毫秒内 finalizer 必须触发”来测试程序正确性。

把注册与取消注册做成确定的实验 ​

先学习我们能控制的部分。下面在支持 FinalizationRegistry 的 Node .mjs 或现代浏览器中运行,不依赖强制垃圾回收:

js
const registry = new FinalizationRegistry(label => {
  console.log(`辅助诊断:${label} 未显式取消登记`);
});
const first = { title: '闭包' };
const second = { title: '原型' };
const token = {};
registry.register(first, '第一份临时记录', token);
registry.register(second, '第二份临时记录', token);
console.log(registry.unregister({})); // false:不是原来的对象
console.log(registry.unregister(token)); // true:移除两项登记
console.log(registry.unregister(token)); // false:已经没有匹配项
console.log(first.title, second.title); // 闭包 原型

register 的三个参数分别是被观察目标、将来传给回调的说明值、用来取消登记的令牌。令牌按身份匹配,同一个令牌可以取消多项登记。unregister 的 true 表示至少移除了一项登记,既不表示对象被销毁,也不替你关闭任何资源。

这里的说明值是字符串,回调也没有捕获 first 或 second,因此不会通过登记说明形成额外强引用。若改成 { target: first } 作为说明值,注册表会强持有这个说明对象,再经 target 保住 first;原本要观察的目标反而无法因此释放。

要观察不确定的部分,可以在另一份独立程序中登记一个局部对象而不保存它:

js
const registry = new FinalizationRegistry(label => console.log(label));
(() => {
  const temporary = { title: '临时索引' };
  registry.register(temporary, '临时索引可能已回收');
})();
console.log('业务继续执行');

确定会输出“业务继续执行”。辅助信息出现与否都符合约定;程序退出前可能完全不发生回调。不要给这条信息安排截止时间,也不要把“尚未输出”当作泄漏证据。实际程序应长期持有需要使用的注册表,显式清理负责正确性,登记只辅助诊断。

先证明旧订阅还在工作,再讨论回收 ​

“移除页面以后仍打印旧名字”比“内存曲线看起来高一点”更容易验证。下面用 EventTarget 模拟一个长期存在的事件源,现代浏览器或支持 EventTarget 的 Node .mjs 都可运行。

js
function subscribe(bus, name, report) {
  const state = { name };
  const listener = () => report(state.name);
  bus.addEventListener('refresh', listener);
  let active = true;
  return () => {
    if (!active) return;
    active = false;
    bus.removeEventListener('refresh', listener);
  };
}
const bus = new EventTarget();
const messages = [];
const disposeOld = subscribe(bus, '旧页面', name => messages.push(name));
const disposeNew = subscribe(bus, '新页面', name => messages.push(name));
bus.dispatchEvent(new Event('refresh'));
console.log(messages.join(',')); // 旧页面,新页面
messages.length = 0;
disposeOld();
disposeOld(); // 重复清理无副作用
bus.dispatchEvent(new Event('refresh'));
console.log(messages.join(',')); // 新页面
disposeNew();

第一次输出两项,说明旧监听器仍注册在 bus 上。如果旧页面本应结束,这已经是一条确定的生命周期错误证据。bus 持有监听器,监听器通过闭包持有 state;把页面元素移走或把调用方某个变量设成 null,并不改变这个注册关系。

removeEventListener 需要对应的函数对象;重新写一个 () => report(state.name) 不是原来的函数。捕获标志也必须匹配。因此返回一个保存确切 listener 的清理函数,比让外面重新拼装移除逻辑可靠。用 AbortSignal 注册一组监听器也能集中结束对应生命周期。

修复后可自动断言“停止后不再收到旧消息”,而不是断言“对象必须在一秒内被回收”。前者属于 API 行为,后者依赖实现自主安排。

再用堆快照找到实际保留路径 ​

若行为已停止但怀疑对象仍被无用地保留,可以进行相同次数的创建与清理,再比较堆快照:

  1. 先运行一次流程完成必要初始化,避免把一次性缓存当成每轮泄漏。
  2. 重复创建/销毁固定数量的对象,查看预期应消失的对象实例是否持续累积。
  3. 对目标对象查看保留者链,找到来自全局集合、监听器、定时器或缓存的路径。
  4. 修复实际那条保留关系,用同样流程重测;不要同时改一堆无关代码后猜哪个起效。

快照说明当时还有哪些引用,不能仅凭对象名字判断是不是泄漏。缓存可能有合理的容量与过期策略;关键是它是否被有意保留、增长是否有界、是否仍有用途。

调试器本身也会影响观察:控制台保存的对象、选中的元素、断点局部环境都可能延长生命周期。先清理这些实验干扰,记录可重复的步骤。一次垃圾回收前后的曲线变化不能证明所有资源都正确收尾;网络订阅、Worker 与定时器还要检查各自的停止行为。

练习 ​

  1. DOM 节点移除后,为什么仍可能被窗口监听器保留?

  2. 为什么不能让 FinalizationRegistry 负责保存用户尚未提交的草稿?

  3. 为什么验证dispose后旧页面不再收到事件,比断言对象一秒内被GC更可靠?

  4. 用 subscribe 写回归测试:旧、新订阅同时工作,清理旧订阅两次,再派发事件;最后清理新订阅再派发。消息应依次是“旧,新”“新”“空”。另外给 FinalizationRegistry 实验增加不同 token 的第三项目,证明取消一个 token 不取消另一个。

查看本节答案

用心记录每一步成长