闭包本身不导致内存泄漏,但当闭包被全局变量(如window、globalthis或模块顶层)长期引用时,其外层作用域中所有变量均无法被gc回收,尤其引用大数组、dom节点或响应式对象时retained size会异常升高。

闭包本身不导致泄漏,但闭包意外持有全局变量引用时,会阻止 GC 回收——关键不是“有没有闭包”,而是“谁在持有着它”。
为什么闭包会让全局变量积压而不释放
闭包的函数体若被挂到 window、globalThis 或模块顶层对象上(比如导出后被其他模块长期引用),其外层作用域中所有变量都会被“冻结”在内存里。尤其当闭包内引用了大数组、DOM 节点或响应式数据对象时,Retained Size 会异常高。
常见触发场景:
- 用
setTimeout或setInterval传入闭包,且未清理定时器 - 将闭包赋值给
window.xxxHandler用于跨模块通信 - Vue/React 中在
mounted或useEffect里定义闭包并绑定到全局事件,但没配对移除 - ESM 模块导出一个闭包工厂函数,调用后返回的函数被缓存进全局 Map
在 Heap Snapshot 中快速定位闭包引用链
打开 Memory 面板 → Take Heap Snapshot → 在 Summary 视图搜索 Closure 构造函数。按 Retained Size 降序排列,重点看那些 Retained Size > 1MB 且 Distance
点击某个 Closure 实例,在右侧查看 Retainers 标签,你会看到类似这样的路径:
Window → eventHandlers → myGlobalCallback → Closure → bigDataArray
这说明 bigDataArray 是被全局 myGlobalCallback 通过闭包捕获而滞留的。
注意:Closure 类型本身不显示变量名,需点开其 context 字段展开,才能看到实际被捕获的变量(如 data、config、ref)。
Allocation instrumentation on timeline 能暴露闭包创建源头
切换到 Allocation instrumentation on timeline → Start recording → 执行一次疑似泄漏的操作(比如点击按钮触发分页请求)→ Stop recording。
时间线中蓝色柱状图代表“分配后未被回收”的对象。点击任一蓝色段,左侧堆分配列表会跳转到对应函数 —— 很可能就是那个返回闭包的工厂函数,例如:
function createDataFetcher() { const cache = new Map(); return function(page) { /* ... */ }; }
如果这个 createDataFetcher 被反复调用且返回值被存进全局 window.fetchers,那每次调用都会新增一个闭包 + 一个 Map,而 Map 又持有大量数据。
此时检查该函数调用栈,确认它是否真的需要每次都新建闭包;如果不是,应改为单例复用,或在不再需要时手动清空 cache 并将引用置为 null。
容易忽略的清理盲区:匿名闭包 vs 具名引用
以下写法看似简洁,实则无法清理:
window.addEventListener('message', (e) => { handle(e); });
因为匿名函数无法被 removeEventListener 精确匹配。一旦绑定,这个闭包就永久挂在 window 上,连带捕获的所有变量都无法释放。
正确做法必须使用具名引用:
const handleMessage = (e) => { handle(e); }; window.addEventListener('message', handleMessage); // … later window.removeEventListener('message', handleMessage);
同理适用于 EventBus.$on、document.addEventListener、ResizeObserver.observe 等所有需要显式解绑的 API。
最危险的是“以为自己清理了,其实没清掉”——比如在组件卸载时调用了 removeEventListener,但传入的函数是每次渲染都新建的箭头函数,引用已变。










