在 JavaScript 里,闭包和垃圾回收是两个经常一起出现的话题。闭包决定了变量能被“保留”多久,GC 则决定了这些变量什么时候可以被回收。理解这两者的关系,能帮助我们更准确地判断内存占用、对象存活时间,以及一些常见的性能问题。
下面这张图可以先把关系看清楚:
闭包不是一种特殊语法,而是函数访问其外层词法作用域的能力。
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
这里返回的函数仍然可以访问 count,即使 createCounter() 已经执行结束。这就是闭包最核心的表现。
从 V8 的实现角度看,函数如果捕获了外层变量,这些变量通常会进入可被函数继续访问的上下文结构中。只要闭包还被引用,这些上下文中的变量就会继续存活。
这里有三个容易混在一起的概念:
把这三者分开,后面看 V8 的实现就会清楚很多。
为了让外层变量在函数返回后依然可用,V8 会把需要被延续访问的绑定保存到堆上的上下文结构里。换句话说:
这是一种帮助理解的简化说法。实际实现里,V8 会根据变量是否逃逸、优化状态和运行时情况,决定它们的具体存放方式。
还有一个很重要的细节:不是每个闭包都会真的分配上下文。V8 在某些场景下可以通过逃逸分析把上下文分配省掉;也就是说,闭包概念存在,不代表一定会产生可观察的额外堆分配。
这也是为什么“写了闭包”不必天然紧张,真正要看的是它是否长期持有了不该保留的数据。
function outer() {
let largeArray = new Array(1000000).fill('*');
return function inner() {
console.log(largeArray[0]);
};
}
const innerFunc = outer();
在这个例子里,largeArray 仍然能被 innerFunc 访问,所以它不会立刻被回收。
V8 的 GC 核心目标很简单:回收不可达对象。
一般可以理解为:
需要注意的是,V8 的真实实现比“标记清除”更复杂,它有分代回收、并发标记和整理等机制。但对理解闭包来说,抓住“可达就活着,不可达就回收”这条主线已经足够。
闭包之所以会影响 GC,是因为它让某些变量继续保持可达。只要闭包对象还在,闭包捕获到的数据就可能一直留在内存里。
更具体一点,V8 的堆通常可以理解为分成年轻代和老年代:
对象如果在多次 GC 后仍然存活,就可能从年轻代晋升到老年代。闭包捕获的数据如果被长期引用,也更容易进入这条路径。
官方文档里也提到,V8 会使用并发标记和增量标记来减少停顿时间。你可以把它理解为:GC 不只是“清一下就完了”,而是一套尽量少打断主线程的回收系统。
闭包本身不会自动造成内存泄漏,真正的问题通常是:闭包意外持有了不再需要的大对象。
function createHandler() {
let hugeData = new Array(1000000).fill('data');
return function handler() {
console.log(hugeData[0]);
};
}
const handler = createHandler();
只要 handler 还在,hugeData 就可能一直占着内存。
window.handler = function () {
// ...
};
如果这个引用一直挂在全局对象上,那么相关闭包及其捕获的数据也会一直存活。
这类场景在前端里很常见:闭包本身没问题,但外部引用没被移除,导致对象链条一直断不开。
function mountButton() {
const state = { text: 'click me' };
function onClick() {
console.log(state.text);
}
button.addEventListener('click', onClick);
return function unmount() {
button.removeEventListener('click', onClick);
};
}
如果 unmount() 没有被调用,onClick 和它捕获的 state 就可能一直活着。
如果你想确认某个对象是不是被闭包长期持有,最直接的方法是:
有些对象确实会被长时间保留,但这不等于内存泄漏。只要它还在业务链路里被使用,这就是正常的“保活”。
真正值得警惕的是:对象在业务上已经不再需要了,但引用链还没断。
如果你在写组件、Hook、工具函数或缓存逻辑,这几条尤其值得优先检查。
function createLogger(data) {
const firstItem = data[0];
return function logFirst() {
console.log(firstItem);
};
}
如果闭包只需要其中一个字段,就不要把整个对象都留住。
可以先把这件事理解得简单一点:
闭包捕获的变量之所以更容易影响内存占用,就是因为它们往往会进入堆上的上下文结构,生命周期不再只受单次函数调用控制。
function example() {
let obj = { data: new Array(1000000) };
return function () {
console.log(obj.data.length);
};
}
const fn = example();
只要 fn 还被引用,obj 这条链路通常就不会被回收。
但这仍然不代表它一定是泄漏。只要 fn 是业务上还需要的引用,这就是正常保活,不是问题。
如果你想把“感觉上像泄漏”变成“证据链”,可以按这个顺序查:
这里最有价值的不是“对象多大”,而是“它为什么还活着”。
如果你只看 Shallow Size,很容易低估一个对象背后真正牵着多少内存。
理解闭包和 GC 的关系,能让你更清楚地判断内存为什么没有及时释放,也更容易写出稳定、可维护的前端代码。闭包不是敌人,失控的引用才是。