当前位置: 首页 > news >正文

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元素,导致该元素及其关联资源(如样式、事件监听器等)无法被垃圾回收器回收,形成内存泄漏。


⑶.计时器和异步操作

未清除的计时器(setIntervalsetTimeout)或未完成的异步操作(如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)

  1. 条件判断i < 3 → 0 < 3 → 成立,进入循环体。
  2. 创建匿名函数
function() { console.log(i); }
  • 这个匿名函数是一个闭包,它捕获了外层 createFunctions 函数中的 i 变量。
  • 闭包的作用域链:包含 createFunctions 的作用域,因此它可以直接访问 i

将函数推入数组 arr

arr.push(函数);
  • arr 现在变为 [函数1]

自增 i

i++;
  • i 的值变为 1

步骤 4:循环的第二次迭代(i = 1)

  1. 条件判断i < 3 → 1 < 3 → 成立,进入循环体。
  2. 创建第二个匿名函数
function() { console.log(i); }
  • 这个函数同样捕获了外层的 i 变量(与第一次迭代的 i 是同一个变量)。

将函数推入数组 arr

arr.push(函数2);
  • arr 现在变为 [函数1, 函数2]

自增 i

i++;
  • i 的值变为 2

步骤 5:循环的第三次迭代(i = 2)

  1. 条件判断i < 3 → 2 < 3 → 成立,进入循环体。
  2. 创建第三个匿名函数
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 取值为 012),每次循环后 i 自增。
  • 循环结束后,i 的最终值为 3(因为 i++ 执行了 3 次)。

相关文章:

  • 单片机 + 图像处理芯片 + TFT彩屏 进度条控件
  • Nacos 客户端 SDK 的核心功能是什么?是如何与服务端通信的?
  • Qt界面控件中点击触发处理耗时业务的方法
  • 【MySQL】详细介绍(两万字)
  • 基于大模型的腹股沟疝全流程预测与诊疗方案研究报告
  • 掌握常见 HTTP 方法:GET、POST、PUT 到 CONNECT 全面梳理
  • Transformer中Post-Norm和Pre-Norm如何选择?
  • 影像数据处理
  • P5670 秘籍-反复异或 Solution
  • 日语学习-日语知识点小记-构建基础-JLPT-N4阶段(8): - (1)复习一些语法(2)「~ています」
  • C++中函数的实现写在头文件内
  • 第 6 篇:衡量预测好坏 - 评估指标
  • 机器视觉lcd屏增光片贴合应用
  • unity基础自学2.3:移动和抓握物品
  • Qt项目——汽车仪表盘
  • Git SSH 密钥多个 Git 来源
  • 研究夜间灯光数据在估计出行需求方面的潜力
  • MySQL 按照日期统计记录数量
  • python 练习
  • 基于LoRA的Llama 2二次预训练实践:高效低成本的大模型领域适配
  • 广西三江通报“网约车司机加价”:对网约车平台进行约谈
  • 为什么要读书?——北京地铁春季书单(2025)
  • GDP增长4.1%,一季度广东经济数据出炉
  • 解放日报:128岁的凤凰自行车“双轮驱动”逆风突围
  • 平均25岁,天津茱莉亚管弦乐团进京上演青春版《春之祭》
  • 用8年还原曹操墓鉴定过程,探寻曹操墓新书创作分享会举行