在 3.4 节中我们讨论了作用域与作用域链,它们为闭包提供了土壤。闭包是 JavaScript 中最重要也最容易被误解的概念之一。理解它的底层原理,不仅能解释许多代码行为,也是写出高效、可维护 JavaScript 的基础。
3.5.1 闭包的形成条件与本质
闭包的定义
闭包是指一个函数能够记住并访问其定义时的词法作用域,即使这个函数在其词法作用域之外执行。
形成条件
- 函数嵌套:一个函数内部定义了另一个函数。
- 内部函数引用了外部函数的变量。
- 内部函数被返回或以其他方式传递到外部作用域执行。
function outer() {
let count = 0; // 外部函数的变量
return function inner() {
count++; // inner 引用了 count
console.log(count);
};
}
const counter = outer(); // inner 被返回到外部
counter(); // 1
counter(); // 2
inner 在 outer 执行完毕后仍然能够访问 count,这就构成了闭包。
本质:作用域链的保留
当函数创建时,它的内部属性 [[Environment]](在早期规范中称为 [[Scope]])会记录下创建该函数时的作用域链。执行 outer() 时,会产生一个包含 count 变量的函数执行上下文。inner 在创建时,其 [[Environment]] 指向这个上下文中的词法环境(包含 count)。
正常情况下,outer 执行结束后,其执行上下文会从调用栈弹出,如果没有其他引用,其词法环境会被垃圾回收。但 inner 保留了对其词法环境的引用,因此垃圾回收器不会回收这个环境,变量 count 得以存活下来。闭包本质上就是函数 + 其定义时所在词法环境的组合。
3.5.2 闭包的内存表现与生命周期
内存结构
每个函数对象都有一个内部引用指向创建它的词法环境,而这个词法环境又可能包含对外部词法环境的引用(作用域链)。形成闭包后,这些词法环境对象被保留在堆内存中,不会被回收。
生命周期
- 创建阶段:外部函数执行,内部函数被创建并捕获外部变量。
- 存活阶段:内部函数仍被任何可达引用持有,其引用的词法环境就不会被 GC 回收。
- 销毁阶段:当持有闭包的引用被解除(比如设置为
null),且没有其他闭包共享同一词法环境时,这部分内存最终被垃圾回收。
function createClosure() {
let hugeData = new Array(1000000).fill('*'); // 大块内存
return function() {
console.log(hugeData[0]);
};
}
const closure = createClosure();
// hugeData 不会被回收,因为闭包仍然持有引用
closure();
closure = null; // 解除引用,hugeData 可以被回收
注意
如果闭包共享同一个外部词法环境,即使某个闭包没有显式引用某个变量,该变量也可能因为被其他闭包引用而驻留。V8 等现代引擎会对闭包进行优化,只保留被引用的变量,但不应完全依赖这种优化,而应在设计时谨慎管理闭包的生命周期。
3.5.3 闭包的经典应用场景与潜在风险
经典应用场景
1. 数据私有化(模块模式)
闭包可以创建私有变量,外部只能通过暴露的方法访问,实现封装。
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
getValue: () => count
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.count); // undefined,外部无法直接访问
2. 函数柯里化与偏函数
利用闭包预先保存部分参数,返回一个接收剩余参数的新函数。
function multiply(a) {
return function(b) {
return a * b;
};
}
const double = multiply(2);
console.log(double(5)); // 10
3. 回调与事件处理
事件监听、定时器中的回调函数经常形成闭包,访问外层状态。
function setupButton(buttonId) {
const btn = document.getElementById(buttonId);
let clickCount = 0;
btn.addEventListener('click', function() {
clickCount++;
console.log(`Clicked ${clickCount} times`);
});
}
4. 循环中的闭包(常见面试题)
经典问题:循环中用 var 定义的回调共享同一个变量引用。
// 错误示例:所有回调打印的值都是循环结束时的 i
for (var i = 1; i <= 5; i++) {
setTimeout(() => console.log(i), i * 1000);
}
// 修正方法:使用 let(块级作用域)或立即执行函数创建独立闭包
for (let i = 1; i <= 5; i++) {
setTimeout(() => console.log(i), i * 1000);
}
潜在风险
1. 内存泄漏
如果闭包被持续持有(比如挂载在 DOM 元素上或全局对象上),其引用的外部变量无法被回收,可能导致内存占用持续增长。特别是在单页应用中,反复创建和遗忘闭包而不清理,可能引发性能问题。
2. 意外的变量共享
多个闭包共享同一个外部词法环境时,修改变量会影响所有闭包的行为,可能导致难以追踪的副作用。
function createCounters() {
let count = 0;
return [
() => ++count,
() => count = 0
];
}
const [inc, reset] = createCounters();
inc(); inc();
reset(); // 将共享的 count 重置为 0
3. 引用过期(过时的闭包)
在异步操作中,闭包捕获的变量值可能与当前实际状态不一致。例如请求回调中引用某个外部变量,而该变量在请求发起后已被修改,导致处理结果错误。这通常需要利用函数参数或更新的作用域来解决。
最佳实践
- 明确闭包的生命周期,在不需要时手动解除引用(如移除事件监听、清理定时器)。
- 在循环或异步场景下,优先使用
let或const利用块级作用域隔离变量。 - 如果闭包不需要访问外部状态,考虑使用纯函数替代,减少不必要的内存持有。
- 使用开发者工具(如 Chrome Memory Profiler)分析内存快照,定位闭包引起的内存问题。
闭包既是强大的工具,也是一把双刃剑。理解其底层原理——词法作用域下的引用保持,你就能自信地驾驭它,写出既灵活又健壮的代码。