闭包(Closure):JavaScript 里的 “记忆小助手”

闭包是 JavaScript 中比较容易让人困惑的概念 —— 它能让函数 “记住自己成长的环境”,哪怕后来跑到别的地方工作,也能找回当初的变量。今天就抛开复杂术语,用更通俗的方式拆解闭包,从原理到实战一次讲明白!

前置知识

如果对作用域链还不熟悉,建议先看下面这篇。理解函数如何查找变量后,再理解闭包会更顺畅~

一、闭包的本质:函数 + 诞生环境

1.1 到底什么是闭包(Closure)?

官方定义有点抽象:闭包是函数与其声明时所在词法环境的组合

换成人话就是:一个函数在 “老家”(声明时的作用域)定义时,记下了周围的变量;后来就算跑到 “外地”(其他作用域)执行,也能找到这些 “老家的变量”—— 这就形成了闭包。

举个直观的例子,你看这个 “带记忆的函数”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createMemory() {
// 这个变量是“老家的东西”,属于外部函数作用域
const secret = '我是函数在老家时记住的内容';

// 内部函数:相当于能找回“老家东西”的钥匙
return function getSecret() {
console.log(secret); // 访问“老家”的变量
};
}

// 取出这个“带记忆的函数”(此时外部函数已执行完,正常来说内部变量该消失了)
const myMemory = createMemory();
// 调用函数:居然还能找到secret!
myMemory(); // 输出:"我是函数在老家时记住的内容"

这里的getSecret就是闭包 —— 它记得自己 “老家” 的环境,所以就算createMemory执行完了,也能找回secret

1.2 闭包形成的三个关键条件

不是所有函数都是闭包,得满足这三个条件:

  1. 函数嵌套:内部函数定义在外部函数里面(比如getSecretcreateMemory里)
  2. 记着老家的变量:内部函数用到了外部函数的变量或参数(getSecret用了secret
  3. 离开老家:内部函数被返回、传递到外部函数作用域之外(getSecret被 return 出去,赋值给myMemory

这里有个容易误解的点: 闭包不是我们 “刻意创建” 的,而是函数定义时的自然结果!只要满足上面三个条件,闭包就会自动形成 —— 不用写特殊语法,它就在那了。

1.3 闭包的核心特点

  1. 变量持久化:相当于给变量 “延长了生命周期”。正常情况下,函数执行完,内部变量会被垃圾回收机制清理,但闭包能让变量一直 “活着”(比如上面的secret
  2. 变量私有权:外部无法直接碰闭包记住的变量。比如你没法直接改secret,只能通过getSecret这个 “接口” 访问 —— 这就实现了数据私有
  3. 作用域链保留:闭包不只是记着自己用的变量,而是记着整个 “老家的环境链”(外部函数作用域 → 全局作用域)。比如如果secret还引用了全局变量,闭包也能找到

二、拆解闭包:看看它 “记着” 哪些东西

2.1 经典案例:带闭包的计数器

用闭包实现计数器是最常见的场景,能清晰看到闭包如何 “保管” 变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function createCounter() {
let count = 0; // 被闭包“保管”的变量,外部拿不到

// 返回三个“操作接口”,都是闭包
return {
increment: () => count++, // 加1
decrement: () => count--, // 减1
getValue: () => count, // 查当前值
};
}

// 创建一个计数器
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.getValue()); // 输出 2

// 试着直接访问count:拿不到!
console.log(counter.count); // undefined!无法直接访问

这里的incrementdecrementgetValue都是闭包,它们共享同一个count变量 —— 不管调用哪个方法,操作的都是同一个计数器,就像几个人共用一个笔记本一样。

2.2 闭包的作用域链

闭包能找到变量,靠的是作用域链。拿上面的计数器举例,作用域链是这样的:

increment执行时,会先在自己的环境找count(没找到),再顺着作用域链往上找,直到在createCounter的环境里找到 —— 这就是闭包能访问外部变量的原理。

关键点:闭包会保留整个作用域链,而不仅仅是它用到的变量!

三、闭包的实战场景

闭包不是理论概念,实际开发中到处都有它的影子,分享几个我常用的场景:

3.1 数据封装:实现 “私有变量”

JavaScript 没有原生的 “私有变量” 语法,但用闭包就能模拟。比如实现一个银行账户,余额只能通过存款、取款接口操作,不能直接修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function createBankAccount(initialBalance) {
let balance = initialBalance; // 私有变量,外部无法直接访问

return {
// 存款:只能通过这个方法加余额
depositMoney: (amount) => (balance += amount),
// 取款:带校验逻辑,防止余额不足
drawMoney: (amount) => {
if (amount > balance) throw new Error('余额不足');
return (balance -= amount);
},
// 查询余额:只能看,不能改
getAmount: () => `余额:¥${balance}`,
};
}

// 创建账户,初始1000元
const myAccount = createBankAccount(1000);
// 存500
myAccount.depositMoney(500);
console.log(myAccount.getAmount()); // 余额:¥1500
// 试着取2000:会报错,因为有校验
myAccount.drawMoney(2000); // Uncaught Error: 余额不足

我在写组件状态管理时,经常用这种方式封装敏感数据,防止外部代码误改导致 bug。

3.2 函数工厂:批量生成 “定制化函数”

如果需要多个逻辑相似但参数不同的函数,用闭包做 “函数工厂” 特别方便。比如生成不同风格的问候语:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createGreeting(prefix) {
// prefix是“定制参数”,被闭包记住
return function (name) {
return `${prefix}${name}!`;
};
}

// 生成“正式问候”和“友好问候”两个函数
const formalGreeting = createGreeting('尊敬的');
const friendlyGreeting = createGreeting('嘿');

// 用的时候直接传名字就行,不用再重复写前缀
console.log(formalGreeting('王总')); // "尊敬的,王总!"
console.log(friendlyGreeting('小明')); // "嘿,小明!"

我之前做表单验证时,用这个方法生成了一堆校验函数(比如 “最小长度校验”“最大长度校验”),不用重复写逻辑,代码简洁了很多。

3.3 事件处理:保留 “上下文信息”

写事件绑定的时候,经常需要在回调里用外部变量,这时候闭包就派上用场了。比如给多个按钮绑定点击事件,输出各自的索引:

1
2
3
4
5
6
7
const buttons = document.querySelectorAll('button');

for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', () => {
console.log(`按钮 ${i} 被点击`);
});
}

这里的箭头函数就是闭包,它记住了循环时的i。不过,如果用var声明i(没有块级作用域),反而会出问题,得用 IIFE + 闭包兼容,不过现在有let就简单多了。

3.4 模块模式:早期前端模块化的基础

在 ES6 Module 出现之前,前端模块化基本靠闭包实现。比如封装一个工具模块,只暴露需要的接口,内部逻辑隐藏起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const myModule = (function () {
// 私有状态:外部看不到
let state = '初始化';

// 私有函数:只能在模块内部用
function privateMethod() {
console.log('执行内部操作');
}

// 公开接口:外部只能通过这些方法访问内部
return {
action: () => {
privateMethod();
state = '已更新';
return state;
},
getState: () => state,
};
})();

// 调用公开方法
myModule.action(); // "执行内部操作"
console.log(myModule.getState()); // "已更新"

// 试着访问私有成员:拿不到
console.log(myModule.state); // undefined
myModule.privateMethod(); // Uncaught TypeError: myModule.privateMethod is not a function

这种模式我在维护老项目时经常见到,虽然现在有 ES Module 了,但闭包的封装思想依然很有用。

四、闭包的坑:内存浪费与优化

闭包虽好用,但用不好容易出问题,最常见的就是 “内存浪费”(内存泄漏)。

4.1 为什么会内存浪费?

闭包会 “留住” 它记住的环境里的变量,不让垃圾回收机制(GC)清理。如果闭包引用了大对象(比如 DOM 元素、超大数组),又一直没被销毁,这些对象就会一直占着内存,导致内存越用越多。

举个反面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function heavyOperation() {
// 模拟一个10MB的大数组
const largeData = new Array(1000000).fill('占用内存的数据');

// 闭包引用了largeData
return function () {
console.log(largeData.length);
};
}

// 调用后,largeData被闭包记住,无法被清理
const memoryHog = heavyOperation();
// 就算后面不用memoryHog了,largeData也还在内存里

如果这种闭包多了,页面会越来越卡,甚至崩溃。

内存泄漏警告: 闭包会阻止其作用域链上所有变量被回收,不当使用会导致内存泄漏

4.2 优化技巧:避免内存浪费

分享几个我实战中用过的优化方法:

  1. 主动 “忘记” 变量:不用闭包时,手动把它记住的变量设为null,让 GC 能清理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createClosure() {
let data = '需要保留的数据';
const closure = () => console.log(data);

// 给闭包加个“清理方法”
closure.cleanup = () => {
data = null; // 解除引用,让闭包“忘记”data,GC能回收了
};

return closure;
}

const myClosure = createClosure();
// 使用完闭包后,调用清理方法
myClosure.cleanup();
  1. 只记需要的内容:如果只需要大对象的部分数据,就别引用整个对象
1
2
3
4
5
6
7
8
9
function createClosure() {
const largeData = new Array(1000000).fill('大数据');

// 只记需要的小数据(比如长度),不引用整个largeData
const metaData = { length: largeData.length };
return function () {
console.log(metaData.length);
};
}

4.3 闭包 vs ES6 块级作用域

有些场景下,ES6 的let/const(块级作用域)能替代闭包,代码更简洁。比如之前的循环绑定事件:

1
2
3
4
5
6
7
8
9
10
11
// 旧方法:用IIFE+闭包兼容var的问题
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}

// 新方法:用let的块级作用域,不用闭包
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100); // 输出0,1,2,3,4
}

因为let在循环里每次迭代都会创建一个新的作用域,定时器回调能直接拿到当前的i —— 这时候就不用再写闭包了。

五、底层原理:闭包为什么能 “记住” 环境?

其实闭包的底层就是 “作用域链” 的机制。再梳理下它的形成过程:

简单说:函数定义时会 “记下自己在哪出生”(词法作用域),执行时会顺着这个 “出生地链条” 找变量 —— 闭包就是利用了这个机制,让函数在别的地方执行时,还能找到 “老家” 的环境。

5.1 现代引擎的优化:不用怕闭包影响性能

很多人担心闭包性能差,但现代 JavaScript 引擎(比如 V8,Chrome 和 Node.js 用的)对闭包做了很多优化:

  • 按需保留:只保留闭包真正用到的变量,没用的变量会被 GC 清理(比如上面的largeData如果没被引用,就会被回收)
  • 缓存常用变量:频繁访问的闭包变量会被缓存,访问速度更快
  • 优化对象查找:对闭包引用的对象做特殊处理,减少属性查找时间

所以不用过度担心闭包的性能问题 —— 正常使用下,性能开销可以忽略不计。只有在循环里频繁创建闭包(比如每秒创建上千个),才需要注意优化。

六、闭包最佳实践:这些坑我踩过,你别踩

分享几个我实战中总结的经验,帮你避开闭包的坑:

  1. 少记无关内容:只让闭包记住必要的变量,别把不需要的东西也带在身上。比如上面的银行账户,只暴露depositMoney等方法,不暴露balance

  2. 能不用就不用:如果用块级作用域(let/const)能解决问题,就别用闭包。比如循环里用let替代var + 闭包,代码更简洁,也少了内存开销。

  3. 小心循环引用:闭包引用 DOM 元素时,容易形成 “闭包 →DOM→ 闭包” 的循环引用。解决方法是:事件不用时手动移除监听,或用WeakMap/WeakSet(弱引用)存储 DOM。

  4. 明确生命周期:用闭包封装组件时,尽量做成 “自包含” 的模块,不用时调用清理方法,释放内存。

七、常见问题解答

Q1:闭包是什么?举个例子

闭包是函数记住并访问其词法作用域的能力,哪怕函数在词法作用域之外执行。比如实现计数器:

1
2
3
4
5
6
7
function createCounter() {
let count = 0;
return () => count++;
}
const counter = createCounter();
counter(); // 0
counter(); // 1

Q2:闭包有什么优缺点?

优点

  • 实现数据封装(私有变量),防止外部误改
  • 保留变量状态(比如计数器),不用依赖全局变量
  • 批量生成定制化函数(函数工厂)
  • 是早期前端模块化的基础

缺点

  • 可能导致内存浪费(引用的变量无法清理)
  • 频繁创建闭包有轻微性能开销
  • 调试时不容易追踪变量来源(Chrome DevTools 里变量会标为Closure

Q3:如何避免闭包导致的内存浪费?

  • 只让闭包记住必要的变量,减少无关引用
  • 不用闭包时,主动把引用的变量设为null
  • 避免在闭包里引用大对象(比如大数组、整个 DOM 树)
  • WeakMap/WeakSet存储引用,让 GC 能清理
  • 明确闭包的生命周期,不用时执行清理逻辑(比如移除事件监听)

八、闭包总结:不是难题,是实用工具

最后用一张表总结闭包的核心特性,方便大家回顾:

特性 说明 应用场景
词法作用域 闭包的基础,函数定义时决定 “老家” 所有闭包场景
保留变量状态 让变量不被清理,持续可用 计数器、缓存工具
数据封装 变量私有,只能通过闭包访问 模块开发、模拟类
函数工厂 动态生成带定制参数的函数 表单验证、问候语生成
内存开销 保留作用域链,可能占内存 需要优化的场景
调试标识 Chrome DevTools 中变量标为Closure 调试闭包相关问题

很多人觉得闭包难,是因为它 “看不见摸不着”—— 但其实只要理解 “函数记住老家环境” 这个核心,再结合实际场景多练,很快就能掌握。

闭包不是需要害怕的难题,而是 JavaScript 里实用的工具。用好了它,你就能写出更安全、更优雅的代码,比如封装组件、管理状态、实现模块化 —— 现在就试着用闭包写个小工具吧!