什么是闭包?
闭包(Closure)是JavaScript中一个核心且强大的概念。简单来说,闭包是函数和对其周围状态(词法环境)的引用捆绑在一起的组合。换句话说,当一个内部函数访问了它所在的外部函数作用域中的变量,即使外部函数已经执行完毕,这些变量仍然停留在内存中,供内部函数调用,这种机制就形成了闭包。
用《你不知道的JavaScript》中的经典定义来说,闭包 = 函数 + 词法作用域。这揭示了闭包的本质:它不是一个特殊的函数,而是函数在定义时的词法作用域被“捕获”并保留下来的一种现象。
形成闭包的条件:
- 函数嵌套函数: 必须存在一个内部函数。
- 内部函数引用外部函数的变量: 内部函数必须访问(或引用)其外部函数作用域中的变量(也称为“自由变量”)。
- 内部函数可以在外部访问: 内部函数被返回到外部,或者被赋值给一个外部变量,使其在外部函数执行完毕后仍然可以被调用。
常见的形成闭包的场景包括:函数作为返回值、函数作为参数传递、立即执行函数(IIFE)以及块级作用域(let/const)与定时器等的结合。
闭包的底层原理
理解闭包,需要深入到JavaScript的作用域和内存管理机制。
2.1 词法作用域(Lexical Scope)
JavaScript采用的是词法作用域,也称为静态作用域。这意味着函数的作用域在函数定义时就已经确定,而不是在函数调用时确定。函数能够访问哪些变量,取决于它在代码中被声明的位置,而不是它在哪里被调用。
当JavaScript引擎编译代码时,它会为每个函数创建一个词法环境(Lexical Environment),其中包含了该函数内部声明的变量、函数以及对外部词法环境的引用。这个外部词法环境的引用,就是作用域链的基础。
2.2 作用域链(Scope Chain)
当JavaScript引擎查找一个变量时,它会首先在当前执行上下文的词法环境中查找。如果找不到,就会沿着作用域链向上查找,直到找到该变量或者到达全局作用域。这个查找过程就是沿着词法环境的外部引用链进行的。
在闭包的场景中,内部函数的作用域链包含了外部函数的词法环境。因此,即使外部函数执行完毕,其词法环境(包括其中的变量)仍然被内部函数引用着,不会被垃圾回收。
[Global Scope] <-- (外部词法环境引用)
^
|
[Outer Function Scope] <-- (外部词法环境引用)
^
|
[Inner Function Scope]
2.3 变量持久化与垃圾回收(GC)
JavaScript引擎有自己的垃圾回收机制(Garbage Collection, GC),用于自动管理内存。当一个变量不再被任何地方引用时,GC会将其标记为可回收,并在适当的时候释放其占用的内存。
然而,由于闭包函数(内部函数)依然引用着外部函数的“自由变量”,GC会认为这些外部变量仍然“有用”,因此不会销毁它们,即使外部函数已经执行完毕并从调用栈中弹出。这就导致了这些外部变量的值能够持久存在于内存中,供闭包函数后续调用。
示例(1.js):数据私有化
function createCounter() {
let count = 0; // 自由变量,被内部函数引用
return {
inc: () => ++count, // 内部函数inc引用了count
get: () => count // 内部函数get引用了count
};
}
const counter = createCounter();
counter.inc(); // count变为1
counter.inc(); // count变为2
console.log(counter.count); // undefined,count是私有的,无法直接访问
console.log(counter.get()); // 2,通过闭包访问私有变量
在这个例子中,createCounter函数执行完毕后,其内部的count变量并没有被销毁,因为它被inc和get这两个闭包函数引用着。count变量对于外部是不可见的,实现了数据的私有化。
三、闭包的业务场景与实战应用
闭包在JavaScript开发中有着广泛的应用,是实现许多高级功能和优化模式的基础。
3.1 数据私有化与封装
如上例所示,闭包可以创建私有变量和方法,实现模块化和信息隐藏。这在构建复杂组件或库时非常有用,可以避免全局变量污染,并保护内部状态不被外部随意修改。
3.2 防抖(Debounce)与节流(Throttle)
防抖和节流是前端性能优化中常用的技术,用于控制高频事件的触发频率。它们的实现都依赖于闭包来“记住”定时器ID或上次执行时间。
-
防抖: 每次事件触发时清除上一个定时器,并重新设置定时器,确保事件在最后一次触发后的一段时间内只执行一次。
function debounce(func, delay) { let timeoutId; // 自由变量,被返回的函数引用 return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); }, delay); }; } -
节流: 在一定时间内只执行一次函数。通过闭包记录上次执行时间或定时器状态。
function throttle(func, delay) { let timeoutId = null; let lastArgs = null; let lastThis = null; return function(...args) { lastArgs = args; lastThis = this; if (!timeoutId) { timeoutId = setTimeout(() => { func.apply(lastThis, lastArgs); timeoutId = null; }, delay); } }; }
3.3 循环绑定事件
这是一个经典的闭包应用场景,尤其是在var时代。由于var声明的变量没有块级作用域,会导致循环变量在回调函数执行时已经变成了最终值。
// var 导致的问题:都会输出 3
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 3, 3, 3
}, 100);
}
// 解决方案一:使用闭包(立即执行函数 IIFE)
for (var i = 0; i < 3; i++) {
((index) => {
setTimeout(() => {
console.log(index); // 0, 1, 2
}, 100);
})(i); // 每次循环都传入当前的i值,并立即执行,形成独立作用域
}
// 解决方案二:使用 let (块级作用域,每次循环都会创建一个新的i)
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2
}, 100);
}
3.4 缓存/记忆化(Memoization)
闭包可以用于实现函数的记忆化,将函数的计算结果缓存起来,当下次输入相同的参数时,直接返回缓存的结果,避免重复计算,提升性能。
function memoize(fn) {
const cache = {}; // 自由变量,用于存储缓存结果
return function(key) {
if (cache[key]) {
return cache[key];
}
const result = fn(key);
cache[key] = result;
return result;
};
}
// 示例:计算斐波那契数列(耗时操作)
const fibonacci = memoize(function(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(10)); // 第一次计算并缓存
console.log(fibonacci(10)); // 直接从缓存获取
3.5 函数柯里化(Currying)与偏函数(Partial Application)
闭包是实现函数柯里化和偏函数的基础。
-
柯里化: 将一个接收多个参数的函数,转换为一系列只接受一个参数的函数链。
function curry(fn) { return function curried(...args) { if (args.length >= fn.length) { return fn.apply(this, args); } else { return function(...moreArgs) { return curried.apply(this, args.concat(moreArgs)); }; } }; } const add = (a, b, c) => a + b + c; const curriedAdd = curry(add); console.log(curriedAdd(1)(2)(3)); // 6 -
偏函数: 固定函数的一些参数,返回一个新函数,新函数接受剩余参数。
function partial(fn, ...fixedArgs) { return function(...remainingArgs) { return fn.apply(this, fixedArgs.concat(remainingArgs)); }; } const add = (a, b) => a + b; const add5 = partial(add, 5); console.log(add5(10)); // 15
四、setTimeout回调函数是闭包吗?
这是一个常见的面试陷阱问题。答案是:setTimeout的回调函数本身不一定是闭包,但它通常会形成闭包。
-
从定义上说: 如果
setTimeout的回调函数访问了其外部作用域中的自由变量,那么它就形成了闭包。这是因为回调函数在被创建时捕获了其外部词法环境,即使外部函数执行完毕,这些变量仍然被保留。 -
关键在于是否访问自由变量:
-
是闭包的例子:
function outer() { let count = 0; setTimeout(() => { console.log(count++); // 访问了自由变量count }, 1000); } outer(); // outer执行完毕,但count被回调引用,形成闭包 -
不是闭包的例子:
setTimeout(() => { console.log("Hello"); // 没有访问任何自由变量 }, 1000);
-
-
var与let在循环中的区别:- 使用
var声明的循环变量,由于var没有块级作用域,i是函数作用域或全局作用域的变量。setTimeout的回调函数引用的是同一个i,当回调执行时,i已经变成了最终值。这并不是闭包的典型应用,而是var作用域特性导致的问题。 - 使用
let声明的循环变量,每次循环都会创建一个新的块级作用域,i在每次迭代中都是一个独立的变量。setTimeout的回调函数捕获的是每次迭代中独立的i,这才是闭包的典型体现。
- 使用
总结
闭包是JavaScript中一个强大而精妙的特性,它是函数与其外部词法作用域的组合,使得函数即使在其外部作用域执行完毕后,仍然能够访问并操作该作用域中的变量。其底层原理基于JavaScript的词法作用域、作用域链以及垃圾回收机制对被引用变量的持久化。
理解闭包不仅是掌握JavaScript高级特性的标志,更是深入理解JavaScript运行机制、编写高质量代码的关键。