【每日一面】深入理解闭包
闭包是作用域链查找与垃圾回收共同作用的结果:内部函数让外部变量持续存活,用于数据私有、状态保持与防抖节流,但也要提防意外的内存泄漏。
基础问答
问:什么是闭包?闭包有什么作用?
答:闭包(Closure)是指有权访问另一个函数作用域中变量的函数。简单来说,当一个内部函数引用了外部函数的变量时,即使外部函数已经执行完毕并出栈,这些被引用的变量仍然会存活在内存中,不会被垃圾回收机制回收,内部函数和这些变量共同构成了闭包。
闭包的主要作用:
-
数据私有化:封装变量,避免全局污染,实现模块化
-
延长变量生命周期:让函数执行完毕后,变量仍然存活
-
保存状态:在异步操作、回调函数中保留上下文
-
函数柯里化:利用闭包实现参数复用
扩展延伸
闭包是 JavaScript 中最核心的概念之一,也是面试中必问的高频题。要真正理解闭包,需要先搞清楚作用域、作用域链和执行上下文这几个前置概念。
概念铺垫
作用域
作用域是变量和函数的可访问范围,决定了代码中变量的可见性和生命周期。JavaScript 中有三种作用域:
-
全局作用域:最外层的作用域,在代码任何位置都能访问
-
函数作用域:函数内部声明的变量和参数,仅在函数内部可访问
-
块级作用域:ES6 引入,
let/const声明的变量在{}内形成独立作用域
执行上下文与作用域链
每当 JavaScript 引擎执行一段代码时,都会创建对应的执行上下文(Execution Context)。每个执行上下文都包含:
-
变量对象(Variable Object):存储变量、函数声明、函数参数
-
作用域链(Scope Chain):用于变量查找的链式结构
-
this指向
当函数被调用时,会创建新的执行上下文并压入执行栈;函数执行完毕后,执行上下文出栈。但闭包是个例外——如果内部函数仍然引用着外部函数的变量,那么外部函数的变量对象不会被销毁。
核心机制
闭包的形成本质上是作用域链查找和垃圾回收机制共同作用的结果。
下面的流程图展示了闭包的形成过程:
flowchart TD
A[外部函数 outer 被调用] --> B[创建 outer 执行上下文]
B --> C[outer 变量对象 VO 创建,包含变量 x 和内部函数 inner]
C --> D[inner 函数定义时,保存对 outer VO 的引用]
D --> E[outer 执行完毕,执行上下文出栈]
E --> F{inner 是否被外部引用?}
F -->|是| G[outer 的变量对象不被回收<br>形成闭包]
F -->|否| H[outer 的变量对象被垃圾回收]
G --> I[调用 inner 时,沿作用域链<br>仍可访问 outer 的变量]
闭包的内存模型
graph LR
subgraph 栈内存
A[全局执行上下文<br>Global EC]
B[inner 函数引用]
end
subgraph 堆内存
C[inner 函数对象]
D[outer 变量对象 VO<br>x = 10]
E[全局变量对象 GO]
end
A --> E
B --> C
C -->|"[[Scope]]"| D
D -->|"parent scope"| E
style D fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#9f9,stroke:#333,stroke-width:2px
从内存模型图可以看出:inner 函数对象内部的 [[Scope]] 属性保存了对 outer 函数变量对象的引用,这条引用导致垃圾回收器无法回收 outer 的变量对象,从而形成了闭包。
代码示例
最简单的闭包示例:
function outer() {
let x = 10;
function inner() {
console.log(x); // 内部函数引用外部变量 x
}
return inner;
}
const fn = outer(); // outer 执行完毕,按理应销毁 x
fn(); // 输出:10 —— 但 x 仍然存在,这就是闭包一个更实用的例子——计数器:
function createCounter() {
let count = 0; // 私有变量,外部无法直接访问
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.count); // undefined —— 外部无法直接访问 count上面的代码中,count 变量被封装在闭包中,只能通过暴露的三个方法操作,实现了数据私有化。
对比表格
| 对比维度 | 普通函数调用 | 闭包 |
|---|---|---|
| 变量生命周期 | 函数执行完,局部变量随即被回收 | 被引用的外部变量持续存活,直到闭包被销毁 |
| 变量访问方式 | 只能访问自身作用域和全局作用域的变量 | 可以访问外部函数作用域的变量,即使外部函数已执行完毕 |
| 内存占用 | 函数执行完即释放,内存开销小 | 保留外部函数的变量对象,内存开销较大 |
| 数据封装性 | 无封装,变量容易被外部修改 | 可实现私有变量,数据安全性高 |
| 适用场景 | 普通计算、一次性操作 | 模块化封装、状态保持、防抖节流、函数柯里化 |
| 常见陷阱 | 无 | 内存泄漏、循环中引用同一变量导致意外结果 |
闭包常见应用场景决策
flowchart TD
Q[需要使用闭包吗?] --> Q1{是否需要封装私有变量?}
Q1 -->|是| A1[模块化 / 立即执行函数 IIFE]
Q1 -->|否| Q2{是否需要保持函数状态?}
Q2 -->|是| Q3{是延迟执行还是连续调用?}
Q3 -->|延迟执行| A2[防抖 / 节流]
Q3 -->|连续调用| A3[函数柯里化 / 偏函数]
Q2 -->|否| Q4{是否需要在回调中保留上下文?}
Q4 -->|是| A4[事件处理器 / 异步回调]
Q4 -->|否| A5[不需要闭包,直接使用普通函数]
面试追问
闭包会导致内存泄漏吗?如何避免?
不一定。闭包本身不会必然导致内存泄漏,它只是让变量的生命周期延长了。内存泄漏指的是不再需要的变量仍然占用内存,无法被回收。
闭包导致内存泄漏通常发生在以下场景:
-
闭包引用了大量数据,且闭包被长期持有(如挂载到全局变量、DOM 事件上)
-
循环引用:闭包引用 DOM 元素,DOM 元素又通过属性引用闭包中的函数
避免方式:
-
及时解除引用:将闭包函数置为
null,断开引用链 -
减少不必要的变量捕获:只捕获真正需要的变量
-
使用 WeakMap / WeakSet 存储数据,让垃圾回收更主动
// 错误示范:闭包持有大对象,且 fn 被全局引用
let fn;
function badExample() {
const bigData = new Array(1000000).fill('*'); // 大数组
fn = function() {
console.log(bigData.length);
};
}
badExample();
// bigData 永远无法被回收,因为全局 fn 引用着它
// 正确做法:用完后解除引用
fn = null; // 断开引用,bigData 可被回收下面这段代码输出什么?如何修改让它输出 0、1、2、3、4?
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}输出结果: 1 秒后连续输出 5 个 5。
原因: var 声明的 i 是全局变量,没有块级作用域。循环结束后 i 的值已经是 5,而 setTimeout 的回调函数形成闭包,它们共享同一个 i。当 1 秒后回调执行时,沿作用域链找到的 i 已经是 5 了。
修改方式一:使用 let 声明(ES6 块级作用域)
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}let 在每次循环中都会创建一个新的绑定,每个回调捕获的是当前迭代的 i,所以输出 0、1、2、3、4。
修改方式二:使用立即执行函数(IIFE)创建闭包
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}每次循环立即执行 IIFE,将当前的 i 作为参数 j 传入,每个 IIFE 都有自己的 j 变量,形成独立的闭包。
什么是自由变量?自由变量的查找规则是什么?
自由变量是指在当前作用域中使用、但不是在当前作用域中声明的变量。例如:
const a = 10;
function fn() {
const b = 20;
console.log(a + b); // a 是自由变量,b 是局部变量
}自由变量的查找遵循作用域链规则:
-
先在当前作用域的变量对象中查找
-
如果找不到,沿着作用域链到父级作用域查找
-
一直追溯到全局作用域
-
如果全局作用域也找不到,抛出
ReferenceError
注意:自由变量的查找是在函数定义时的作用域中进行的,而不是调用时的作用域。这就是 JavaScript 的词法作用域(静态作用域)特性。
const x = 10;
function fn1() {
console.log(x);
}
function fn2() {
const x = 20;
fn1(); // fn1 定义在全局,所以找的是全局的 x = 10,而非 fn2 中的 x = 20
}
fn2(); // 输出:10手写一个使用闭包实现的防抖函数(debounce)
防抖的核心思想:在事件触发后延迟 n 秒执行回调,如果在这 n 秒内事件又被触发,则重新计时。常用于搜索框输入联想、窗口 resize 等场景。
function debounce(fn, delay) {
let timer = null; // timer 被闭包保存,每次调用共享同一个 timer
return function(...args) {
// 如果已经有定时器了,清除掉,重新计时
if (timer) {
clearTimeout(timer);
}
// 重新设置定时器
timer = setTimeout(() => {
fn.apply(this, args); // 修正 this 指向,透传参数
timer = null; // 执行完毕后重置
}, delay);
};
}
// 使用
const handleSearch = debounce(function(keyword) {
console.log('搜索:', keyword);
}, 300);
// 连续调用时,只有最后一次会在 300ms 后执行
handleSearch('a');
handleSearch('ab');
handleSearch('abc'); // 300ms 后输出:搜索:abc延伸思考:如果面试官追问「怎么实现立即执行版的防抖?」,可以在上述基础上加一个 immediate 参数,第一次触发时立即执行,之后进入防抖逻辑。
下面代码中,两个 counter 的 count 是共享的还是独立的?为什么?
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1()); // ?
console.log(counter1()); // ?
console.log(counter2()); // ?
console.log(counter2()); // ?输出结果:
1
2
1
2两个 counter 的 count 是独立的,互不影响。
原因: 每次调用 createCounter() 都会创建一个新的执行上下文,产生一个新的 count 变量和一个新的闭包函数。counter1 和 counter2 分别引用了两次不同调用产生的闭包,它们各自的作用域链中保存的是不同的 count 变量,因此计数互不干扰。
这也是闭包实现模块化和实例化的基础——每次调用工厂函数,都会生成一份独立的私有状态。