闭包(Closure)是 JavaScript 中最容易"看似懂了、实则踩坑"的概念之一。它既是函数式编程的基石,也是大量内存泄漏问题的源头。本文从作用域链讲起,逐步拆解闭包的形成机制,并总结 5 种典型的内存泄漏场景与解决方案,帮助你写出更稳健的前端代码。
一、作用域链:理解闭包的前提
JavaScript 采用词法作用域(Lexical Scope),即变量的可见范围由它在源代码中书写的位置决定,而非调用时决定。当函数执行时,会创建一个执行上下文,其中包含一个变量对象(VO),并与其外层函数的变量对象形成一条作用域链。
查找变量时,引擎会沿着作用域链从内向外逐层查找,直到全局对象为止。这条链路正是闭包能够"记住"外部变量的根本原因。
// 作用域链示例
var globalName = 'mzph';
function outer() {
var outerVar = 'outer';
function inner() {
// inner 的作用域链:inner VO -> outer VO -> Global VO
console.log(outerVar); // 'outer'
console.log(globalName); // 'mzph'
}
return inner;
}
var fn = outer(); // outer 已执行完毕
fn(); // 仍能访问 outerVar —— 这就是闭包
二、闭包的形成机制
当一个内部函数被传递到它所在的词法作用域之外,并且仍然持有对原作用域变量的引用时,就形成了闭包。换言之,闭包是函数与其声明时所在的词法环境的组合体。
正常情况下,函数执行完毕后其局部变量会被垃圾回收器回收。但闭包打破了这一规律:由于内部函数引用了外部变量,垃圾回收器会保留这些变量,直到内部函数本身被释放。
// 经典计数器:每次调用产生独立闭包
function createCounter() {
let count = 0; // 自由变量,被闭包捕获
return {
increment() { count++; },
get() { return count; }
};
}
const c1 = createCounter();
const c2 = createCounter();
c1.increment();
c1.increment();
console.log(c1.get()); // 2
console.log(c2.get()); // 0 —— 互不影响
闭包的本质:函数记住了它出生时的环境,即使那个环境已经"离开",它依然能访问其中的变量。
三、5 种常见的闭包内存泄漏场景
闭包让变量长期存活,一旦使用不当,就会让本该回收的对象被长期持有,造成内存泄漏。以下是开发中最常踩的 5 个坑。
1. 意外的全局变量
函数内未用 let/const 声明的变量会挂到 window 上,永远不会被回收。
function leak() {
leakedData = new Array(1000000).fill('*'); // 漏写 let → 全局污染
}
leak();
// 修正:始终使用 let/const 声明
function noLeak() {
const data = new Array(1000000).fill('*');
// 仅在函数内使用,执行后即可回收
}
2. 被遗忘的定时器
定时器回调中引用了外部大对象,且未清理定时器,会导致对象无法释放。
function startTimer() {
const hugeData = new Array(1e6).fill(0);
const timer = setInterval(() => {
console.log(hugeData.length); // hugeData 被闭包持有
}, 1000);
return timer;
}
const t = startTimer();
// 组件卸载/不需要时必须清除
clearInterval(t);
3. 闭包持有 DOM 引用
事件监听器中引用了 DOM 节点,节点从文档移除后,由于闭包仍持有引用,导致无法回收。
function bind() {
const btn = document.getElementById('submit');
const cache = { heavy: new Array(1e6) };
btn.addEventListener('click', () => {
console.log(cache.heavy.length); // 闭包持有 cache 与 btn
});
}
// btn.remove() 后,监听器未解绑 → btn 与 cache 都泄漏
// 解决:移除节点前先 removeEventListener
4. 闭包中的循环引用
在老版本浏览器(IE 系列)中,DOM 与 JS 对象互相引用会触发引用计数算法的 bug。现代引擎已用标记清除算法规避,但仍建议避免循环引用。
5. 未取消的订阅与回调
发布订阅、WebSocket、ResizeObserver 等回调中持有外部数据,若不在适当时机取消订阅,会造成持续泄漏。
// 典型:单页应用路由切换后未清理订阅
class View {
constructor() {
this.data = new Array(1e6).fill(0);
window.addEventListener('resize', this.onResize);
}
onResize = () => { console.log(this.data.length); };
destroy() {
window.removeEventListener('resize', this.onResize); // 关键
this.data = null;
}
}
四、闭包内存泄漏的排查与解决
实践中有三类手段可以帮助定位泄漏:
- Chrome DevTools Memory 面板:用 Heap Snapshot 对比两次快照的 Retained Size,定位泄漏对象。
- Performance Monitor:观察 JS Heap Size 是否随时间持续上升而不回落。
- 代码审查:重点关注定时器、事件监听、订阅回调的清理时机。
解决原则可以归纳为一句话:谁创建,谁销毁。凡是注册了定时器、监听器、订阅者,都必须在对应的生命周期销毁方法中显式取消。
// 通用模式:成对出现的注册与清理
class Component {
setup() {
this.timer = setInterval(this.tick, 1000);
this.observer = new ResizeObserver(this.onResize);
this.observer.observe(document.body);
}
teardown() {
clearInterval(this.timer);
this.observer.disconnect();
}
}
五、合理使用闭包
闭包本身不是洪水猛兽,它带来了模块化、私有变量、柯里化、防抖节流等优雅模式。关键在于理解其持有引用的特性,做到"用完即释放"。
- 需要长期存活的变量才用闭包保存,临时变量不要被无意捕获。
- 大对象使用完毕后及时置为
null,主动断开引用。 - 在框架生命周期中严格遵守"注册—注销"的对称原则。
掌握闭包,不仅是掌握一个语法特性,更是建立起对 JavaScript 内存模型的完整认知。希望本文的 5 个场景能在你下次排查内存问题时提供思路。