什么是闭包?闭包有哪些应用和问题?
简化版
闭包是函数和它能访问的外层词法环境的组合。即使外层函数执行完,内部函数仍然可以访问外层变量。它常用于封装私有变量、函数工厂、回调保存状态,但滥用可能导致内存无法及时释放。
详细版
示例:
function createCounter() {
let count = 0;
return function () {
return ++count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
createCounter 执行完后,count 没有被销毁,因为返回的内部函数仍然引用它。
应用场景:
- 私有状态封装。
- 防抖、节流保存定时器。
- 函数柯里化和函数工厂。
- 事件回调保留上下文信息。
风险是闭包引用了大对象或 DOM 后,如果回调长期存在,相关内存也会被保留。
完整版教学
一、闭包来自词法作用域
JavaScript 使用词法作用域,函数能访问哪些变量,在函数定义时就确定了,而不是调用时才确定。
内部函数定义在外部函数里面,所以它天然能访问外部函数的局部变量。当内部函数被返回或传到外部时,这种访问能力仍然存在。变量解析会沿函数定义位置对应的环境链向外查找,因此把函数换到另一个调用位置并不会改成动态作用域。
二、闭包不是特殊语法
闭包不是某个关键字,而是一种运行时现象:
function outer() {
const name = 'AIGC';
function inner() {
console.log(name);
}
return inner;
}
inner 和它引用的 name 一起形成闭包。
三、循环闭包经典问题
使用 var 时,循环变量是函数作用域,多个回调共享同一个变量:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3 3 3
使用 let 后,每次循环都有新的块级绑定:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0 1 2
四、闭包和内存泄漏
闭包本身不是内存泄漏。问题出在闭包长期引用不再需要的数据,比如已经移除的 DOM 节点、很大的数组、定时器回调等。
处理方式是及时解绑事件、清除定时器、让不再需要的引用断开。现代垃圾回收器能够处理不可达的循环引用,所以真正要排查的是从全局对象、监听器等根到闭包环境的可达路径,而不是引用是否成环。
五、面试追问与工程落地
闭包常见追问是“闭包一定会造成内存泄漏吗?”答案是否定的。闭包只是让函数保留外层变量引用,只有当这些引用长期不释放,并且保留了不再需要的大对象或 DOM 时,才可能造成泄漏。
另一个高频追问是模块化中的私有变量。早期 JS 没有 class private fields 时,常用闭包封装私有状态。现在也可以用闭包写函数工厂、组合式逻辑、React hooks、Vue composables。它不是过时技巧,而是 JS 作用域模型的基础能力。
工程里使用闭包要注意生命周期。比如防抖函数保存 timer、请求函数保存 token、事件回调保存组件状态,都要确认这些闭包什么时候被释放。组件卸载时如果监听器没解绑,闭包里引用的状态也会继续活着。
六、用环境记录推演闭包的生命周期
执行 createCounter() 时,引擎会为这次调用建立词法环境,count 绑定属于该环境。返回函数内部仍保存着解析 count 所需的外层环境引用,所以调用栈弹出不等于环境立即可回收;只有返回函数以及其他可达引用都消失后,相关环境才可能被垃圾回收。
全局环境
└─ counter ──> 返回函数
└─ [[Environment]] ──> { count: 2 }
若分别执行两次 createCounter(),会得到两个函数和两套独立绑定。调用顺序为 a()、a()、b() 时结果是 1、2、1,因为闭包捕获的是各次调用创建的绑定环境,而不是共享同一个名为 count 的全局变量。
| 场景 | 捕获的绑定 | 生命周期风险 |
|---|---|---|
| 函数工厂 | 每次外层调用的新环境 | 实例过多会保留多份状态 |
for (let ...) 回调 | 每轮新的迭代绑定 | 回调数量仍决定保留时长 |
| 防抖函数 | timer 与最近参数 | 未取消的回调会延长引用生命周期 |
| 全局事件监听器 | 监听器可达的外层数据 | 不解绑可能长期保留组件对象 |
闭包保留的是词法环境中的绑定访问能力,不是简单地把变量当前值复制一份;判断输出时要看多个函数是否引用同一个绑定。
七、常见误区与追问
- 误区:只要函数嵌套就一定产生需要长期存活的闭包。 嵌套函数具备访问外层绑定的词法条件,但只有函数逃逸或被后续调用时,长期保留现象才对程序可观察。
- 误区:外层函数返回后,它的全部局部变量都会立刻销毁。 垃圾回收看可达性;仍被内部函数访问的环境不能仅因调用栈退出就回收。
- 误区:闭包捕获的是变量创建那一刻的值副本。 闭包通常访问同一个绑定,所以外层代码之后修改该绑定,闭包读取到的是修改后的值。
- 追问:为什么
var循环回调常打印同一个最终值?var使用同一个函数或全局作用域绑定,所有回调在循环结束后读取的都是同一变量。 - 追问:为什么
let能得到每轮不同的值?for的词法声明会为每次迭代创建新的绑定环境,使每个回调关联各自那一轮的i。 - 追问:怎样判断闭包是否造成内存泄漏? 看持有闭包的根引用是否超出业务生命周期,以及其环境是否保留了本应释放的大对象,而不是看到闭包就判泄漏。
- 追问:闭包与对象属性保存状态有什么区别? 两者都能保存状态;闭包通过词法作用域隐藏绑定,对象属性则更便于枚举、序列化和显式管理。
八、加强记忆
闭包可以记成“函数背着出生地的变量包”。它让函数离开定义位置后还记得外层变量。好处是保存状态,坏处是状态可能被保存太久。