JavaScript 闭包:从原理到实战应用
在 JavaScript 中,闭包(Closure)是一个既基础又深奥的概念,它是理解函数式编程、模块化开发和内存管理的关键。本文将通过通俗的语言和具体示例,带你深入理解闭包的本质、工作原理及实际应用场景。
一、什么是闭包?—— 从一个简单例子说起
1. 先看一段神奇的代码
function createCounter() {let count = 0; // 这是一个局部变量return function() { // 内部函数返回count++;console.log(count);};
}const counter = createCounter(); // 调用外部函数
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3
- 现象:外部函数
createCounter
执行完毕后,其内部的count
变量并未被销毁,而是被内部函数 “记住” 了。 - 本质:这里的内部函数(匿名函数)和它所引用的外部变量
count
共同构成了闭包。
二、闭包的定义与形成条件
1. 官方定义(MDN)
闭包是指有权访问另一个函数作用域中变量的函数,它通过在一个函数内部定义另一个函数实现,内部函数可以访问并操作外部函数的变量和参数。
2. 形成闭包的三个必要条件
- 函数嵌套:在一个函数内部定义另一个函数。
- 内部引用:内部函数引用了外部函数的变量或参数。
- 外部返回:外部函数返回了内部函数,使得内部函数可以在外部被调用。
3. 闭包的核心:作用域链的 “保鲜”
当外部函数执行完毕后,其作用域本应被销毁,但由于内部函数引用了其中的变量,JavaScript 引擎会延长该作用域的生命周期,直到内部函数不再被引用为止。
三、闭包的工作原理
1.词法作用域和闭包
JavaScript的作用域机制是基于词法作用域的,这意味着一个函数的作用域在它定义时就已经确定,而不是在函数调用时确定。当一个函数被定义时,它会捕获其所在作用域中的所有变量,并在闭包中保存这些变量的引用。
2. 执行上下文和作用域链
当JavaScript代码执行时,会创建一个执行上下文(Execution Context)。每个执行上下文都有一个与之关联的作用域链(Scope Chain),用于解析变量。当内部函数访问外部函数的变量时,实际上是通过作用域链来查找这些变量的。
3.闭包的内存管理
闭包会导致外部函数的变量被保存在内存中,即使外部函数已经执行完毕。这是因为内部函数对这些变量仍然存在引用,这种情况下,JavaScript的垃圾回收机制不会释放这些内存。因此,闭包的内存管理必须非常谨慎,否则会导致内存泄漏。
四、内存泄漏问题及其原因
1.什么是内存泄漏?
内存泄漏是指程序运行过程中,某些不再使用的内存没有被及时释放,导致可用内存逐渐减少,最终可能导致程序崩溃或性能严重下降。在JavaScript中,内存泄漏通常发生在闭包中,因为闭包会保持对外部函数作用域的引用,从而阻止垃圾回收机制回收这些变量的内存。
2.闭包导致内存泄漏的常见情况
⑴.不必要的全局变量
如果一个闭包不小心创建了不必要的全局变量,这些变量将一直存在于内存中,导致内存泄漏。
function createClosure() {var someLargeObject = new Array(1000000).fill('some data');return function() {console.log(someLargeObject);}
}var closure = createClosure();
在这个例子中,someLargeObject是一个占用大量内存的变量,而闭包closure会一直保留对它的引用,即使我们不再需要它,这样就会导致内存泄漏。
解释
基本类型 vs 内存占用的误区
有人可能认为:“字符串是基本类型,存储成本低,不会占用太多内存”。
误区点:单个基本类型(如 'a')确实占用很小,但海量基本类型的集合(如百万级数组)会累积成显著的内存开销。
类比:一滴水微不足道,但一百万滴水会汇聚成大量液体 —— 内存占用的本质是数据规模的累积效应。
闭包导致内存泄漏的关键:引用无法释放
1. 闭包对 someLargeObject 的持久引用
var closure = createClosure(); // 闭包函数被赋值给全局变量closure
当 createClosure 执行完毕后,其内部作用域本应被销毁,但由于返回的内部函数引用了 someLargeObject,JavaScript 引擎会保留整个作用域(包括 someLargeObject)的内存空间。
引用关系链:
window.closure(全局变量)→ 闭包函数 → 闭包作用域 → someLargeObject(数组)。
只要 closure 未被显式置为 null,这条引用链就会一直存在,导致 someLargeObject 无法被垃圾回收(GC)。
2. 内存泄漏的本质:无效数据长期驻留内存
- 如果
closure
不再被使用(例如业务中已不需要调用它),但未手动释放(如closure = null
),则:someLargeObject
作为闭包作用域内的变量,其占用的 40MB+ 内存会一直被闲置占用,无法被其他程序或数据复用。- 随着时间推移,若多次创建此类闭包,内存占用会持续累积,最终导致程序卡顿、崩溃等问题。
对比:若闭包不引用 someLargeObject
,会发生什么?
function createClosure() {var someLargeObject = new Array(1000000).fill('some data');return function() {// 闭包不引用someLargeObject,仅输出固定值console.log('hello'); };
}
var closure = createClosure();
- 此时,
someLargeObject
在createClosure
执行完毕后,因没有被闭包引用,其内存会被立即回收(GC 会释放该变量),不会产生内存泄漏。
这说明:内存泄漏的关键不是变量本身的大小,而是闭包是否错误地保持了对它的引用。
⑵.DOM元素引用
在处理DOM元素时,如果在闭包中引用了DOM元素,这些元素可能无法被正确回收,从而造成内存泄漏。
function bindEvent() {var element = document.getElementById('button');element.addEventListener('click', function() {console.log(element.id);});
}
在上述代码中,如果element
引用的DOM元素被删除,但闭包中的事件处理函数仍然引用该元素,那么这个元素的内存就不会被回收,导致内存泄漏
解释
- ① 引用DOM元素:element变量直接指向页面中的某个DOM节点(如按钮)。
- ② 事件处理函数:添加的click事件监听器是一个匿名函数,它形成了闭包,因为其内部引用了外部作用域(bindEvent函数的作用域)中的element变量。
- ③ 闭包依赖外部变量:事件处理函数需要访问element.id,因此必须保留对element的引用。
内存泄漏的触发条件:
- 假设:后续代码中移除了DOM元素(例如通过element.parentNode.removeChild(element))。
- 问题:虽然DOM元素被移出文档树,但事件处理函数(闭包)仍然存活,因为它可能被浏览器保留以响应未来的点击事件。
- 结果:闭包中的element变量仍然引用了已移除的DOM元素,导致该元素及其关联资源(如样式、事件监听器等)无法被垃圾回收器回收,形成内存泄漏。
⑶.计时器和异步操作
未清除的计时器(setInterval
、setTimeout
)或未完成的异步操作(如Promise、XHR请求)也可能导致闭包中的变量被长时间保留在内存中。
function startTimer() {var data = new Array(1000000).fill('data');setInterval(function() {console.log(data);}, 1000);
}
在这个例子中,data
会被定时器的闭包引用,直到定时器被清除,这会导致内存泄漏。
五、闭包的核心作用:“私有” 数据的封装与持久化
1. 实现数据私有化(封装)
闭包最典型的应用是在函数内部创建 “私有变量”,外部无法直接访问,但可以通过内部函数提供的接口间接操作。
案例:封装一个计数器模块
function Counter() {let count = 0; // 私有变量,外部不可直接访问return {increment: function() { // 公共方法,修改私有变量count++;},getCount: function() { // 公共方法,读取私有变量return count;}};
}const counter = Counter();
counter.increment();
console.log(counter.getCount()); // 1(无法直接访问count,但可通过接口操作)
2.保存变量状态(持久化)
闭包能记住外部函数执行时的变量值,每次调用内部函数时,操作的都是同一组变量的 “记忆副本”。
案例:多次调用生成不同的 “闭包实例”
function createAdder(x) {return function(y) { // 内部函数引用了外部的xreturn x + y;};
}const add5 = createAdder(5); // 闭包1:x=5
const add10 = createAdder(10); // 闭包2:x=10console.log(add5(3)); // 8(5+3)
console.log(add10(3)); // 13(10+3)
- 每个
createAdder
的调用都会生成独立的闭包,保存各自的x
值,互不干扰。
六、闭包的经典应用场景
1. 模块模式(Module Pattern)
通过闭包实现 “私有方法” 和 “公共接口”,模拟面向对象的封装特性。
案例:封装一个简单的用户模块
const UserModule = (function() {let users = []; // 私有数据return {addUser: function(name) { // 公共方法users.push({ name, id: users.length + 1 });},getUserCount: function() { // 公共方法return users.length;}// 私有方法无法直接访问,如需暴露需添加到返回对象中};
})();UserModule.addUser('Alice');
console.log(UserModule.getUserCount()); // 1
console.log(UserModule.users); // undefined(私有数据不可直接访问)
2. 函数防抖与节流(性能优化)
利用闭包保存定时器 ID 或时间戳,避免高频事件(如窗口 resize、输入搜索)触发时的性能问题。
防抖函数(Debounce):多次触发只执行最后一次
function debounce(func, delay) {let timer = null; // 闭包保存timerreturn function() {const context = this;const args = arguments;clearTimeout(timer); // 清除上一次定时器timer = setTimeout(() => {func.apply(context, args); // 延迟执行}, delay);};
}// 使用:搜索框实时搜索
inputElement.addEventListener('input', debounce(searchFunction, 300));
3.柯里化(Currying):函数参数的分步传递
将多参数函数转换为单参数函数链,增强函数的复用性。
案例:柯里化实现加法函数
function curryAdd(x) {return function(y) {return x + y;};
}const add10 = curryAdd(10); // 固定第一个参数x=10
console.log(add10(5)); // 15(等价于10+5)
4.模拟块级作用域(ES6 之前)
在 ES6 的let
和块级作用域
出现前,闭包常用于避免变量污染。
案例:循环中绑定事件(ES5 写法)
for (var i = 0; i < 5; i++) {(function(j) { // 立即执行函数创建闭包,保存每次的j值setTimeout(function() {console.log(j); // 输出0,1,2,3,4}, 100);})(i); // 传入当前i值,赋值给j
}
七、总结
1.作用域链:函数的 “变量地图”
⑴.作用域链的本质(创建时确定)
- 定义:作用域链是函数创建时形成的层级化变量访问路径,包含当前作用域和所有父级作用域的变量对象。
- 构建过程:
function outer() {let x = 10; // 外部作用域变量function inner() { // 内部函数创建时,作用域链 = [inner作用域, outer作用域, 全局作用域]let y = 20;console.log(x); // 沿作用域链向上查找x(outer作用域)}
}
- 每个函数的作用域链在定义时就已确定,由词法作用域(代码书写位置)决定,而非调用时。
⑵.普通函数的作用域链生命周期
- 当函数执行完毕,其作用域链会被销毁,变量被垃圾回收(无闭包时)。
function fn() {let temp = '临时变量';
}
fn(); // 执行后,temp的作用域链销毁,temp被回收
2.闭包:让作用域链 “延长寿命”
⑴. 闭包对作用域链的 “冻结” 效应
- 当内部函数被返回或引用到外部时,它会强制保留创建时的作用域链,即使外部函数已执行完毕。
function outer() {let x = 10;function inner() { // 内部函数(闭包)console.log(x); // 引用outer作用域的x}return inner; // 闭包被返回,作用域链 = [inner作用域, outer作用域, 全局作用域]
}const fn = outer(); // outer已执行完毕,但outer的作用域链因闭包fn的存在而保留
fn(); // 输出10(仍能访问outer作用域的x)
- 核心:闭包函数的作用域链中,outer 作用域的变量对象未被销毁,因为闭包持有对它的引用。
⑵.“延伸” 的本质:作用域链的跨函数存活
- 普通函数的作用域链是 “临时租用” 父级作用域,执行完即释放;
- 闭包的作用域链是 “永久绑定” 父级作用域,只要闭包存在,父级作用域就无法被回收。
3.经典案例:闭包如何依赖作用域链
案例 1:ES5 循环中的闭包 “共享变量” 问题
function createFunctions() {var arr = [];// 1. var声明的i是函数作用域变量,整个createFunctions作用域中只有1个i// 2. 循环过程:i依次变为0 → 1 → 2 → 3(最终值)for (var i = 0; i < 3; i++) { // 3. 每次循环向数组中添加的是闭包函数(未执行,仅定义)arr.push(function() { // 4. 闭包函数的作用域链包含createFunctions作用域中的i(唯一的i)// 5. 此时闭包未执行,只是「记住」了对i变量的引用(而非i的当前值)console.log(i); });// 6. 循环体执行完后i自增(i++),下一次循环i是新值}// 7. 循环结束后,i的最终值是3(因为i++执行了3次:0→1→2→3)return arr;
}const [f1, f2, f3] = createFunctions();
// 8. 调用闭包函数时,才会执行console.log(i)
// 9. 此时i已经是循环结束后的最终值3(所有闭包共享同一个i的引用)
f1(); // 3
f2(); // 3
f3(); // 3
- 作用域链分析:
三个闭包的作用域链都指向同一个createFunctions
作用域中的i
,循环结束后i=3
,导致所有闭包共享最终值。
案例 2:ES6 let 实现 “独立作用域链”
function createFunctions() {let arr = [];for (let i = 0; i < 3; i++) { // let定义的i是块级作用域变量,每次循环创建独立作用域arr.push(function() { // 闭包的作用域链指向当前循环迭代的块级作用域(含独立的i)console.log(i); // 输出0、1、2(每个闭包绑定自己的i)});}return arr;
}
- 关键区别:
let
在每次循环迭代中创建新的块级作用域,每个闭包的作用域链绑定独立的i
,形成 3 个互不干扰的闭包实例。
可参考JavaScript-ES5 循环中的闭包 “共享变量” 问题
ES5中只有function是作用域
ES5之前因为if和for都没有块级作用域的概念,所以在很多时候,我们都必须借助于function的作用域来解决应用外面变量的问题
ES6中加入了let,let它是有if和for的块级作用域
ES5中的var是没有块级作用域(if/for)
ES6中的let是有块级作用域的(if/for)
八、解释
function createFunctions() {var arr = [];for (var i = 0; i < 3; i++) {arr.push(function() {console.log(i); // 闭包引用外部的 i 变量});}return arr;
}const [f1, f2, f3] = createFunctions();
f1(); // 3
f2(); // 3
f3(); // 3
1.分步执行流程
步骤 1:调用 createFunctions()
函数
- 函数执行开始:进入
createFunctions
函数的作用域。 - 初始化变量
arr
:
var arr = [];
arr
是一个空数组,用于存储后续创建的函数。
步骤 2:进入 for
循环
- 初始化循环变量
i
:
for (var i = 0; i < 3; i++) {
var i
在createFunctions
函数的作用域内声明,因此整个函数中只有一个i
变量。- 初始值:
i = 0
。
步骤 3:循环的第一次迭代(i = 0)
- 条件判断:
i < 3
→0 < 3
→ 成立,进入循环体。 - 创建匿名函数:
function() { console.log(i); }
- 这个匿名函数是一个闭包,它捕获了外层
createFunctions
函数中的i
变量。 - 闭包的作用域链:包含
createFunctions
的作用域,因此它可以直接访问i
。
将函数推入数组 arr
:
arr.push(函数);
arr
现在变为[函数1]
。
自增 i
i++;
i
的值变为1
。
步骤 4:循环的第二次迭代(i = 1)
- 条件判断:
i < 3
→1 < 3
→ 成立,进入循环体。 - 创建第二个匿名函数
function() { console.log(i); }
- 这个函数同样捕获了外层的
i
变量(与第一次迭代的i
是同一个变量)。
将函数推入数组 arr
:
arr.push(函数2);
arr
现在变为[函数1, 函数2]
。
自增 i
:
i++;
i
的值变为2
。
步骤 5:循环的第三次迭代(i = 2)
- 条件判断:
i < 3
→2 < 3
→ 成立,进入循环体。 - 创建第三个匿名函数:
function() { console.log(i); }
- 同样捕获外层的
i
变量。
将函数推入数组 arr
:
arr.push(函数3);
arr
现在变为[函数1, 函数2, 函数3]
。
自增 i
:
i++;
步骤 6:循环结束条件判断
- 条件判断:
i < 3
→3 < 3
→ 不成立,退出循环。
步骤 7:返回数组 arr
- 函数
createFunctions
执行完毕,返回数组arr
return arr; // 返回 [函数1, 函数2, 函数3]
步骤 8:调用返回的函数
-
解构赋值:
const [f1, f2, f3] = createFunctions();
f1
是第一次迭代的匿名函数(函数1)。f2
是第二次迭代的匿名函数(函数2)。f3
是第三次迭代的匿名函数(函数3)。
调用函数
调用 f1()
:
f1(); // 执行函数1的代码:console.log(i);
此时 i 的值是 3(循环结束后最终值),因此输出 3。
调用 f2():
f2(); // 执行函数2的代码:console.log(i);
- 同样访问
i = 3
,输出3
。
调用 f3()
:
f3(); // 执行函数3的代码:console.log(i);
- 同样访问
i = 3
,输出3
。
2.关键点总结
1. var
的作用域问题
- 唯一变量
i
:
在createFunctions
函数中,var i
声明的变量i
是函数作用域的,所有循环迭代共享同一个i
。 - 闭包捕获的是变量本身:
所有匿名函数(闭包)捕获的都是同一个i
的引用,而非每次迭代时的值。
2. 闭包的延迟执行特性
- 闭包未立即执行:
在循环过程中,函数被创建并推入数组,但未立即执行。 - 闭包执行时的
i
值:
当闭包被调用时(如f1()
),i
的值已经是循环结束后的最终值3
。
3. 循环的最终值
- 循环执行了 3 次(
i
取值为0
、1
、2
),每次循环后i
自增。 - 循环结束后,
i
的最终值为3
(因为i++
执行了 3 次)。