在 JavaScript 编程的世界里,闭包是一个既强大又微妙的概念。它能为我们的程序带来诸多便利,但如果使用不当,也可能引发一些棘手的问题。
一、闭包的概念
闭包是函数和其词法作用域的组合。它使内部函数(返回的函数)能够访问并记住其外层函数作用域中的变量,即使是在外部函数执行结束且在其作用域之外进行调用时,依然可以使用这些变量。
来看一个简单的示例代码:
js
function createCounter() {
let count = 0;
function increment() {
count++;
console.log(count);
}
return increment;
}
let counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3
这个例子中,闭包中的函数为 increment
函数,它在 createCounter
函数内部定义,是内部函数,且作为 createCounter
函数的返回值。
词法作用域的概念
词法作用域,也叫静态作用域,由代码编写位置决定。在该规则下,变量和函数的作用域取决于其定义位置,而非调用位置。函数在定义时就记住了所在作用域,无论在何处调用,都能访问定义时所处作用域的变量。
词法作用域与闭包形成
increment
函数的词法作用域是 createCounter
函数的作用域。由于函数在定义时记住词法作用域,increment
函数在 createCounter
函数内定义,便记住了该作用域。
createCounter
函数执行时,创建局部变量 count
并初始化为 0
,同时定义 increment
函数,该函数引用了 count
变量。createCounter
函数返回 increment
函数后,increment
函数与 createCounter
函数的词法作用域结合形成闭包。
闭包对变量的影响
createCounter
函数执行结束,其执行上下文从调用栈移除,按常规作用域规则 count
变量应被销毁。但因 increment
函数形成的闭包对 count
有引用,count
变量所在内存空间不会释放。
将 increment
函数赋值给 counter
变量,在 createCounter
函数作用域之外调用 counter
函数(即 increment
函数),increment
函数仍能访问并修改 count
变量,每次调用 counter
函数,count
就增加 1
并打印。
二、闭包的用途
(一)封装私有变量
在传统的面向对象编程里,存在访问控制的概念,比如私有成员(private members),这些成员只能在类的内部被访问和修改。不过 JavaScript 原生并没有直接提供这样的私有成员机制,但借助闭包就能模拟实现。
以下是一个简单的封装示例:
js
function Person() {
let name = "John";
return {
getName: function() {
return name;
},
setName: function(newName) {
name = newName;
}
};
}
let person = Person();
console.log(person.getName()); // 输出 John
person.setName("Jane");
console.log(person.getName()); // 输出 Jane
-
Person
函数里定义了一个局部变量name
,并将其初始化为"John"
。这个变量仅存在于Person
函数的作用域内。 -
Person
函数返回了一个对象,该对象包含两个方法:getName
和setName
。这两个方法都形成了闭包,因为它们引用了Person
函数作用域中的name
变量。 -
外部代码调用
Person()
时,会得到返回的对象,并将其赋值给person
变量。 -
外部代码无法直接访问
name
变量,只能通过person.getName()
和person.setName()
这两个方法来获取和修改name
的值。这样就实现了对name
变量的封装,保护了数据的安全性和完整性。
(二)数据缓存
在软件开发中,有些计算操作的成本非常高,比如需要大量的 CPU 时间、内存或者网络资源等。如果在程序运行过程中,多次对相同的输入进行这些高成本的计算,会造成资源的浪费,降低程序的性能。而闭包可以用来实现数据缓存,避免重复计算,提高程序的运行效率。
考虑这样一个场景,我们需要频繁计算某个复杂数学函数的结果:
js
function expensiveCalculation() {
let cache = {};
return function(n) {
if (cache[n]) {
return cache[n];
} else {
let result = n * n * n; // 这里假设是一个复杂计算
cache[n] = result;
return result;
}
};
}
let calculate = expensiveCalculation();
console.log(calculate(5)); // 计算并输出 125
console.log(calculate(5)); // 从缓存中获取并输出 125
在这个例子中:
expensiveCalculation
函数内部定义了一个空对象cache
,用于存储已经计算过的结果。- 当调用
calculate(5)
时,首先检查cache
对象中是否已经有5
对应的计算结果。如果没有,就进行计算,并将结果存储到cache
对象中,然后返回计算结果。 - 当再次调用
calculate(5)
时,由于cache
对象中已经有5
对应的计算结果,所以直接从cache
对象中获取并返回该结果,避免了重复计算。
(三)实现函数柯里化
函数柯里化指的是把一个多参数函数转换为一系列单参数函数的过程。通过这种转换,函数可以逐步接收参数,利用闭包记住已传入的参数,从而实现更灵活的函数调用和复用。
-
假设去超市买东西,结账的时候需要做两件事:一是扫码商品,二是付款。原本这两件事可能需要一次性把所有商品信息(价格总和)和付款金额这两个 "参数" 提供给收银员,就像一个多参数函数一样。
-
而函数柯里化就像是把这个过程拆分开来。你可以先把商品扫码,记录下商品的总价,这就相当于先传入了一个参数;之后再去付款,提供付款金额,这相当于传入了另一个参数。这样原本需要一次性完成的两个操作,现在可以分步进行了。
以一个简单的加法函数为例,来展示如何通过闭包实现柯里化:
js
function add(a) {
return function(b) {
return a + b;
};
}
let add5 = add(5);
console.log(add5(3)); // 输出 8
console.log(add(2)(4)); // 输出 6
-
多参数函数的常规理解 :通常的加法函数可能是
function add(a, b) { return a + b; }
,调用时需要一次性传入两个参数,比如add(5, 3)
,就像在超市一次性把商品总价和付款金额告诉收银员一样。 -
函数柯里化的过程:
-
add
函数接收一个参数a
,此时,add
函数内部定义了一个新的函数,这个新函数引用了外部add
函数的参数a
,从而形成了闭包。它会把这个a
值保存起来,即使add
函数执行完毕,这个值也不会丢失。 -
add
函数返回一个新的函数,这个新函数等待接收另一个参数b
,就像你记录好商品总价后,等待付款时提供付款金额b
。由于闭包的存在,这个新函数能够随时获取之前记录的a
值。 -
let add5 = add(5);
这一步先传入了参数5
,得到了一个新的函数add5
。这里的add5
其实就是之前形成闭包的那个新函数,它通过闭包记住了之前传入的5
,即使add
函数已经完成了它的使命。 -
add5(3)
这一步,你传入了另一个参数3
,新函数add5
利用闭包的特性,把之前记住的5
和现在传入的3
相加,得到结果8
。闭包确保了add5
函数在任何时候都能准确地使用之前记录的5
这个值来进行计算。
三、闭包与内存管理
闭包虽功能强大,但在使用中需留意其对内存管理的影响。当闭包形成,内部函数对外部函数作用域变量的引用,会致使这些变量在外部函数执行完毕后依旧驻留在内存中。这一特性在某些场景下,可能会引发内存泄漏问题。
例如在处理 DOM 事件的闭包场景中,如果闭包持续持有对 DOM 元素的引用,即便该元素已从 DOM 树移除,元素所占用的内存也无法正常释放,进而导致内存占用不必要地增加。
避免闭包引发内存问题的方法
使用具名函数作为事件监听器
将事件监听器设为具名函数,而不是匿名函数。这样在需要移除监听器时,可以通过函数名精准操作。
- 匿名函数没有明确的标识符,当你想要移除它时,无法直接引用该函数。这就会导致即使你不再需要这个事件监听器,它依然会和元素绑定,无法被垃圾回收机制回收,进而造成内存泄漏。
javascript
// 具名函数作为事件监听器
function handleClick() {
console.log('按钮被点击');
}
const button = document.getElementById('myButton');
button.addEventListener('click', handleClick);
// 当不再需要该事件监听器时
button.removeEventListener('click', handleClick);
及时清理闭包引用
若闭包中存在对大对象或不再使用对象的引用,应在合适时机手动将这些引用设为null
,以便垃圾回收机制回收内存。如:
javascript
function outerFunction() {
let largeObject = { /* 一个大对象 */ };
function innerFunction() {
console.log(largeObject);
}
// 在某些条件下,当确定不再需要largeObject时
innerFunction();
largeObject = null;
return innerFunction;
}
控制闭包的生命周期
确保闭包仅在必要时段存在。闭包在函数内部创建时,如果它的功能仅在该函数执行期间有用,就要避免将其返回或赋值给全局变量,否则闭包会因被长期持有,而让其引用的外部变量一直占用内存。
例如:
javascript
function processData() {
let data = [1, 2, 3, 4, 5];
function inner() {
// 处理数据
return data.map(num => num * 2);
}
// 仅在processData函数内部使用inner闭包
let result = inner();
// inner闭包在processData函数执行结束后不再被引用,其引用的data等变量可被回收
return result;
}
在上述代码中,processData
函数内的 inner
函数形成闭包,它引用了外部的 data
变量。但 inner
仅在 processData
函数内部被调用,当 processData
执行完毕,inner
不再被引用,data
变量就可被垃圾回收机制回收。
与之相反,如果闭包被长期持有,情况就大不一样。比如:
javascript
let globalClosure;
function processData() {
let data = [1, 2, 3, 4, 5];
function inner() {
return data.map(num => num * 2);
}
// 将闭包返回并赋值给全局变量
globalClosure = inner;
return inner();
}
processData();
这里 inner
闭包被赋值给全局变量 globalClosure
,即便 processData
函数执行结束,inner
因被全局变量引用而持续存在,其引用的 data
变量也会一直占用内存,无法被释放,进而可能引发内存泄漏问题 。
使用 WeakMap
- 强引用
强引用是最常见的引用类型。当一个变量直接指向一个对象时,就形成了强引用。只要这个变量在作用域内存在且没有被重新赋值或销毁,那么它所引用的对象就会一直存在于内存中,垃圾回收机制不会回收该对象。
例如:
javascript
let person = {
name: "Alice",
age: 30
};
在上述代码中,person
变量对创建的对象 {name: "Alice", age: 30}
进行了强引用,只要 person
变量还在作用域内,这个对象就会一直占用内存空间。
- 弱引用
-
弱引用不会阻止对象被垃圾回收。当一个对象只被弱引用所指向,而没有其他任何强引用时,在垃圾回收机制运行时,这个对象就会被回收,无论它是否还在被使用。
-
需要注意的是,在 JavaScript 中,
WeakMap
是与弱引用密切相关的一种数据结构,它的键就是基于弱引用实现的。
例如:
javascript
const weakRefObj = {};
const weakMap = new WeakMap();
weakMap.set(weakRefObj, { data: "相关数据" });
weakRefObj = null;
-
在 JavaScript 中,变量是对对象的引用。当执行
const weakRefObj = {};
时,创建了一个对象{}
,并让weakRefObj
变量引用它。此时,对象有一个强引用,即weakRefObj
。 -
当执行
weakRefObj = null;
时,weakRefObj
不再指向之前创建的对象,而是被赋值为null
。这意味着原来对象的强引用被移除了,因为weakRefObj
这个唯一的强引用变量不再引用它。 -
由于
WeakMap
的键是弱引用,当对象的所有强引用(这里就是weakRefObj
)都被移除后,垃圾回收机制就会认为这个对象可以被回收了。并且,因为WeakMap
中该对象作为键所关联的整个键值对依赖于这个键对象,当键对象被回收时,与之对应的键值对也会从WeakMap
中被移除(在垃圾回收机制运行时) 。