内存与资源:自动回收不等于自动收尾
JavaScript 会自动回收不再需要的内存,但它无法替你判断“这个订阅在业务上已经没用了”。只要某条强引用仍能从根到达对象,对象就可能继续存活。泄漏经常不是完全不可访问的垃圾,而是程序仍然握着不再需要的东西。
沿引用找到保留者
const archive = [];
function remember() {
const draft = { text: '一段已经读完的说明' };
archive.push(() => draft.text);
}
remember();
console.log(archive[0]()); // 一段已经读完的说明函数结束后,draft 仍被闭包引用,闭包又被 archive 持有。这里是否泄漏取决于是否仍需要保存它;若 archive 无限增长而这些内容永远不再使用,就是值得检查的保留链。
把局部变量设为 null 不会切断其他地方已有的引用。反过来,一群对象即使互相引用,只要从根完全不可达,也可以被回收;“有环一定泄漏”不是现代追踪式回收的通用规则。
资源清理应有明确入口
下面 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 的边界
let item = { label: '可重新计算的缓存值' };
const ref = new WeakRef(item);
console.log(ref.deref()?.label); // 当前可读取
item = null;
// 不写“下一行一定是 undefined”:回收时机没有这种承诺deref 可能返回对象,也可能在已回收后返回 undefined。不要在跨异步边界后假定此前的弱引用仍可用;需要使用时重新读取并持有本次结果。
FinalizationRegistry 可在目标被回收后安排清理回调,但回调可能延迟,也可能根本没有机会执行。它适合不影响正确性的辅助工作,不能承担支付确认、必须释放的锁或唯一的数据持久化。
注册时持有值会被注册表强引用,因此不要让它间接指回目标,否则可能保住本想观察回收的对象。也不能用一个断言规定“五十毫秒内 finalizer 必须触发”来测试程序正确性。
把注册与取消注册做成确定的实验
先学习我们能控制的部分。下面在支持 FinalizationRegistry 的 Node .mjs 或现代浏览器中运行,不依赖强制垃圾回收:
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;原本要观察的目标反而无法因此释放。
要观察不确定的部分,可以在另一份独立程序中登记一个局部对象而不保存它:
const registry = new FinalizationRegistry(label => console.log(label));
(() => {
const temporary = { title: '临时索引' };
registry.register(temporary, '临时索引可能已回收');
})();
console.log('业务继续执行');确定会输出“业务继续执行”。辅助信息出现与否都符合约定;程序退出前可能完全不发生回调。不要给这条信息安排截止时间,也不要把“尚未输出”当作泄漏证据。实际程序应长期持有需要使用的注册表,显式清理负责正确性,登记只辅助诊断。
先证明旧订阅还在工作,再讨论回收
“移除页面以后仍打印旧名字”比“内存曲线看起来高一点”更容易验证。下面用 EventTarget 模拟一个长期存在的事件源,现代浏览器或支持 EventTarget 的 Node .mjs 都可运行。
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 行为,后者依赖实现自主安排。
再用堆快照找到实际保留路径
若行为已停止但怀疑对象仍被无用地保留,可以进行相同次数的创建与清理,再比较堆快照:
- 先运行一次流程完成必要初始化,避免把一次性缓存当成每轮泄漏。
- 重复创建/销毁固定数量的对象,查看预期应消失的对象实例是否持续累积。
- 对目标对象查看保留者链,找到来自全局集合、监听器、定时器或缓存的路径。
- 修复实际那条保留关系,用同样流程重测;不要同时改一堆无关代码后猜哪个起效。
快照说明当时还有哪些引用,不能仅凭对象名字判断是不是泄漏。缓存可能有合理的容量与过期策略;关键是它是否被有意保留、增长是否有界、是否仍有用途。
调试器本身也会影响观察:控制台保存的对象、选中的元素、断点局部环境都可能延长生命周期。先清理这些实验干扰,记录可重复的步骤。一次垃圾回收前后的曲线变化不能证明所有资源都正确收尾;网络订阅、Worker 与定时器还要检查各自的停止行为。
练习
DOM 节点移除后,为什么仍可能被窗口监听器保留?
为什么不能让 FinalizationRegistry 负责保存用户尚未提交的草稿?
为什么验证dispose后旧页面不再收到事件,比断言对象一秒内被GC更可靠?
用 subscribe 写回归测试:旧、新订阅同时工作,清理旧订阅两次,再派发事件;最后清理新订阅再派发。消息应依次是“旧,新”“新”“空”。另外给 FinalizationRegistry 实验增加不同 token 的第三项目,证明取消一个 token 不取消另一个。