或者用 IIFE(立即执行函数)保存当前值。如果这篇文章对您有益,可以点赞收藏关注哦,谢谢!
目录
一、从核心定义出发
闭包(Closure) 是指一个函数能够记住并访问其词法作用域(Lexical Scope) 中的变量,即使这个函数是在其词法作用域之外被执行。
这个定义有点抽象,我们把它拆解成三个关键点:
-
函数:闭包的产生离不开函数。
-
词法作用域:函数在声明时所在的作用域,而不是调用时所在的作用域。JavaScript 采用的就是词法作用域(静态作用域)。
-
访问外部变量:即使外部函数已经执行完毕并返回,内部函数仍然可以“记住”并读写外部函数中的变量。
概念(一句话)
闭包 = 函数 + 创建该函数时的词法环境(它能够访问的外部词法作用域里的变量)。当一个函数可以访问其外层作用域中的变量,即使外层函数已经返回,这个“访问能力”就称为闭包。
关键点:闭包依赖词法作用域(编写代码的位置决定了可访问的外部变量),不是运行时调用栈。
即使外层函数已经执行结束,它里面的变量依然可以被内部函数访问和操作。
二、从一个简单的例子开始理解
javascript
function outer() {
let count = 0; // 这是一个局部变量,存在于 outer 的函数作用域中
function inner() {
count++; // inner 函数访问了其外部(outer)作用域中的变量 count
console.log(count);
}
return inner; // 将 inner 函数返回
}
const myFunc = outer(); // outer() 执行完毕,理论上它的作用域应该被销毁
注解:虽然 outer 已经执行完毕,但因为其内部函数 inner 被返回并赋值给了
全局变量 myFunc,而 inner 又引用了 outer 中的变量 count,所以
JavaScript 引擎不会销毁 outer 的作用域。count 变量依然“活着”,
只是被“封闭”在了 inner 函数形成的闭包中。
myFunc(); // 输出:1
myFunc(); // 输出:2
myFunc(); // 输出:3
神奇的事情发生了:
当 outer()
执行完成后,我们通常会认为它的整个作用域(包括变量 count
)会被垃圾回收机制销毁。然而,当我们调用 myFunc()
(它实际上就是 inner
函数)时,它仍然可以访问并修改 count
变量。
这就是闭包的力量。
三、深度原理:闭包是如何形成的?
要理解闭包,必须结合 作用域链(Scope Chain) 和 垃圾回收机制(Garbage Collection) 来看。
-
函数的作用域链在定义时确定
当一个函数被创建时,它会保存一个对其父级作用域链的引用。这个引用就像一条链子,将函数与其所有祖先作用域连接起来。inner
函数在创建时,它的作用域链上就包含了outer
的作用域。 -
被引用的变量不会被回收
JavaScript 的垃圾回收机制采用了一种叫做标记清除(Mark-and-Sweep) 的算法。如果一个对象(或变量)不再被任何地方引用,它就会被回收。
在我们的例子中,虽然outer
已经执行完毕,但因为其内部函数inner
被返回并赋值给了全局变量myFunc
,而inner
又引用了outer
中的变量count
,所以 JavaScript 引擎不会销毁outer
的作用域。count
变量依然“活着”,只是被“封闭”在了inner
函数形成的闭包中。
你可以把闭包想象成一个“背包”:
当一个函数被创建并传递(或从另一个函数返回)时,它会随身携带一个“背包”。这个“背包”里装满了所有在函数创建时所在作用域中能被它访问到的变量。
四、为什么需要闭包?—— 闭包的用途
闭包非常强大,因为它允许我们关联数据(词法环境)与操作数据的函数。这在编程中极其有用。
1. 创建私有变量(数据隐藏)
这是闭包最经典和重要的用途。JavaScript 本身没有“私有”成员的概念(ES6 的类有了私有字段 #
),但闭包可以完美模拟。
javascript
function createCounter() {
let privateCount = 0; // 这是一个“私有”变量,外部无法直接访问
return {
increment: function() {
privateCount++;
},
decrement: function() {
privateCount--;
},
getValue: function() {
return privateCount;
}
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.getValue()); // 输出:2
console.log(counter.privateCount); // 输出:undefined (根本无法访问!)
privateCount
变量被安全地“封闭”在 createCounter
的作用域内,只能通过返回的三个公共方法来修改和访问。这实现了面向对象编程中的封装性。
2. 实现函数柯里化(Currying)和工厂模式
闭包允许我们预先配置一些参数。
javascript
// 一个简单的乘法工厂
function makeMultiplier(x) { // x 被闭包“记住”
return function(y) {
return x * y;
};
}
const double = makeMultiplier(2); // 配置 x=2
const triple = makeMultiplier(3); // 配置 x=3
console.log(double(5)); // 输出:10 (2 * 5)
console.log(triple(5)); // 输出:15 (3 * 5)
3. 在异步编程和事件处理中保持状态
这是前端开发中最常见的场景。
javascript
// 假设我们要为三个按钮添加点击事件,输出对应的索引
for (var i = 0; i < 3; i++) {
// 使用 IIFE 创建一个新的函数作用域,将当前的 i 值“锁”在闭包里
(function(index) {
document.getElementById(`button-${index}`).addEventListener('click', function() {
console.log(`You clicked button ${index}`);
});
})(i); // 立即传入当前的 i
}
如果不使用 IIFE(立即调用函数表达式) 和闭包,由于 var
没有块级作用域,所有点击事件处理函数共享同一个全局的 i
,最终都会输出 i
的最终值 3。通过闭包,每个回调函数都记住了自己对应的 index
值。
注意:使用
let
声明循环变量i
可以更简单地解决这个问题,因为let
具有块级作用域,每次循环都会创建一个新的绑定,本质上也是为每个回调函数创建了一个新的词法环境(闭包)。
五、潜在的陷阱与注意事项
闭包非常强大,但使用不当也会带来问题。
1. 内存泄漏
因为闭包会长期持有对外部变量的引用,所以如果闭包本身是长期存在的(例如全局变量、缓存等),那么它引用的所有数据也会一直存在,无法被垃圾回收。如果这些数据很大(比如一个巨大的数组或对象),就会导致内存泄漏。
解决方法:在不再需要闭包时,主动断开对它的引用(例如,将持有闭包的变量设置为 null
)。
2. 不必要的闭包性能开销
创建和作用域链的遍历会带来轻微的性能损失。但在现代 JavaScript 引擎中,这个开销已经非常小,除非你在性能关键的循环中创建大量闭包,否则通常不需要担心。
六、如何解决闭包的缺点?
主要是正确使用闭包,避免滥用:
-
减少不必要的闭包:
仅在需要“保存状态/私有变量”时用闭包。普通逻辑不要乱写闭包。 -
手动解除引用:
如果闭包里引用了不再需要的对象,可以赋值null
,帮助垃圾回收。let dom = document.getElementById("btn"); function handler() { console.log(dom.id); } dom.onclick = handler; dom = null; // 断开引用,避免泄漏
-
注意循环和异步中的变量共享问题:
使用let
关键字(ES6+ 最推荐、最简单的方式):
let
声明的变量具有块级作用域。每次循环都会创建一个新的块级作用域和一个新的变量i
,闭包捕获的是这个属于本次循环的i
。
for (let i = 0; i < 3; i++) { // 把 var 改成 let
setTimeout(function() {
console.log(i); // 输出 0, 1, 2
}, 100);
}
或者用 IIFE(立即执行函数)保存当前值。
for (var i = 0; i < 3; i++) {
(function(j) { // j 是形参,接收此时 i 的值
setTimeout(function() {
console.log(j); // j 是 IIFE 作用域内的变量,不会变
}, 100);
})(i); // i 是实参,立即传入
}
-
避免长期持有大对象:
如果需要缓存,可以考虑WeakMap
,它不会阻止对象被垃圾回收。
-
使用
setTimeout
的第三个参数:
setTimeout
函数的第三个及以后的参数会作为回调函数的参数传入。这也是一种创建“副本” 的方式。
javascript
for (var i = 0; i < 3; i++) {
setTimeout(function(j) {
console.log(j);
}, 100, i); // 第三个参数 i 会作为回调函数的参数 j 传入
}
总结
特性 | 解释 |
---|---|
核心 | 函数 + 对其词法作用域的引用 |
关键 | 内部函数在其定义的作用域之外被调用时,依然能访问定义时的词法作用域 |
机制 | 作用域链和垃圾回收(被引用的变量不会销毁) |
主要用途 | 创建私有变量、实现柯里化、模块模式、在异步回调中保持状态 |
注意事项 | 可能引起内存泄漏,需注意管理 |
(面试一口气答法)
闭包就是函数能记住并访问外层作用域的变量,即使外层函数已经执行完毕。
它产生的原因是:词法作用域 + 函数可以作为返回值传递。
闭包的缺点是:会占用内存,容易导致内存泄漏,同时调试和维护也比较麻烦。
解决方法是:谨慎使用闭包,避免在不必要的地方创建闭包,注意及时解除对不再使用对象的引用,并在循环和异步中使用 let
或其他方法隔离变量。