摘要:闭包(Closure)是函数与其词法环境(Lexical Environment)的组合——一个函数可以"记住"并访问它被创建时所处的外部作用域中的变量,即使它在该作用域之外被执行。一句话记忆:函数打包了出生地的变量,走到哪都能用。核心要点:① 内部函数引用外部变量即形成闭包;② 闭包实现变量私有化/数据封装/柯里化;③ 潜在风险是内存泄漏和循环引用;④ 避免泄漏需手动解除引用和及时移除监听器。
一、什么是闭包
1.1 定义
一个函数可以"记住"并访问它被创建时所处的外部词法作用域,即使它在该作用域之外被执行,这种现象就叫做闭包。
// === 最简单的闭包示例 ===
function test() {
const name = 'nana'; // 外部变量
function t2() { // 内部函数 —— 这就是闭包!
console.log(name); // 访问了外部变量 name
}
return t2; // 把内部函数返回到外部
}
const fn = test(); // test() 执行完毕,它的作用域本该销毁
fn(); // 但 t2 仍然能访问到 name → "heyi"
// 为什么?因为 t2 形成了闭包,把 name "打包带走"了
1.2 本质:词法作用域 + 函数作为一等公民
闭包的产生需要两个条件同时满足:
| 条件 | 说明 |
|---|---|
| ① 内部函数引用了外部变量 | 必须有变量被"捕获" |
| ② 内部函数在外部被调用 | 超出了定义时的作用域范围 |
// 不是闭包的情况:
function foo() {
const x = 1;
console.log(x); // 只是普通的作用域查找,没有形成闭包
}
foo();
// 是闭包的情况:
function bar() {
const x = 1;
return function() { // 内部函数引用了 x
return x; // 并且被返回到外部使用
};
}
const fn = bar(); // fn 持有对 x 的引用 → 闭包形成
二、闭包的核心应用场景
2.1 变量私有化(数据封装)
这是闭包最经典的应用——通过闭包创建"私有变量",外部无法直接修改:
// === 计数器:变量私有化 ===
function createCounter() {
let count = 0; // 私有变量,外部无法直接访问!
// 返回一个访问器函数(闭包)
return function() {
count++;
return count;
};
}
const counter1 = createCounter(); // 创建第一个计数器实例
const c1 = counter1(); // 1
const c2 = counter1(); // 2
const c3 = counter1(); // 3
const counter2 = createCounter(); // 创建第二个计数器实例(独立!)
console.log(counter2()); // 1(从 0 开始,不受 counter1 影响)
// 关键点:
// 1. count 是私有的,外部无法直接改写 counter1.count = 999 ❌
// 2. 每次 createCounter() 都创建新的闭包,count 互不影响
// 3. 只有通过返回的函数才能间接操作 count
2.2 模块模式(Module Pattern)
在 ES6 Module 普及之前,闭包是实现模块化的核心手段:
// === 用闭包实现模块 ===
const UserModule = (function() {
// 私有变量
let _users = [];
let _idCounter = 0;
// 私有函数
function _generateId() {
return ++_idCounter;
}
// 公共 API(通过闭包暴露)
return {
add(name) {
const user = { id: _generateId(), name };
_users.push(user);
return user;
},
getById(id) {
return _users.find(u => u.id === id);
},
getAll() {
return [..._users]; // 返回副本,防止外部篡改
},
get count() { // getter
return _users.length;
}
};
})();
UserModule.add('Alice');
UserModule.add('Bob');
console.log(UserModule.count); // 2
console.log(UserModule._users); // undefined(私有!)
2.3 函数柯里化(Currying)
柯里化是闭包的经典应用——将多参数函数转化为一系列单参数嵌套函数:
// === 普通多参数函数 ===
function add(a, b, c) { // Identifier 'add' has already been declared
return a + b + c;
}
add(1, 2, 3); // 6
// === 柯里化版本:add(1)(2)(3) ===
function add(a) {
return function(b) {
return function(c) {
return a + b + c; // a 和 b 通过闭包被"记住"
};
};
}
add(1)(2)(3); // 6
// 实际应用:预设参数
const add10 = add(10); // 预设 a = 10
add10(5)(3); // 18
const add10And20 = add10(20); // 预设 a=10, b=20
add10And20(5); // 35
2.4 其他常见场景
// === 场景 1:防抖 / 节流 ===
function debounce(fn, delay) {
let timer = null; // 闭包保存 timer 状态
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// === 场景 2:React Hooks 的本质就是闭包 ===
function useState(initialValue) {
let state = initialValue; // 闭包持有状态
function setState(newValue) {
state = typeof newValue === 'function' ? newValue(state) : newValue;
render(); // 触发重新渲染
}
return [state, setState]; // 返回状态和更新函数
}
// === 场景 3:迭代器 / 生成器模拟 ===
function createIterator(arr) {
let index = 0; // 闭包保存游标位置
return {
next() {
return index < arr.length
? { value: arr[index++], done: false }
: { value: undefined, done: true };
},
reset() { index = 0; }
};
}
三、闭包的底层原理
3.1 执行上下文与作用域链
理解闭包需要先理解 JS 的执行模型:
┌─────────────────────────────────────────────┐
│ 全局执行上下文 (Global EC) │
│ ┌───────────────────────────────────────┐ │
│ │ 函数 createCounter 的执行上下文 │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 匿名返回函数的执行上下文 (闭包) │ │ │
│ │ │ │ │ │
│ │ │ 变量查找顺序: │ │ │
│ │ │ 自身 → 外层(createCounter) → 全局│ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ │ │
│ │ Variable Environment: { count } │ │
│ └───────────────────────────────────────┘ │
│ │
│ Variable Environment: { createCounter, ...}│
└─────────────────────────────────────────────┘
3.2 闭包的本质:[[Environment]] 引用
每个 JS 函数对象内部都有一个隐藏属性 [[Environment]],指向其定义时的词法环境:
// 伪代码表示闭包的内部结构
function createCounter() {
let count = 0;
// 返回的匿名函数内部大致长这样:
// {
// code: function() { count++; return count; },
// [[Environment]]: { // ← 词法环境引用
// count: 0, // ← 被捕获的变量
// outer: <GlobalEnv>
// }
// }
return function() {
count++;
return count;
};
}
关键点:只要存在对闭包函数的引用,[[Environment]] 就不会被垃圾回收,里面的变量就一直存活。
四、闭包可能导致的问题
4.1 内存泄漏(最常见问题)
闭包会阻止外部变量的垃圾回收——如果不再需要的闭包没有被释放,内存就会持续增长:
// === 泄漏示例 1:超大闭包 ===
function leakyHandler() {
const hugeData = new Array(100000).fill('x'); // ~400KB 数据
// hugeData 被闭包捕获,只要 handler 存在就不会被回收
return function handler() {
console.log('clicked'); // 根本没用 hugeData,但它还是被闭包持有!
};
}
const handler = leakyHandler();
document.getElementById('btn').addEventListener('click', handler);
// 结果:hugeData (~400KB) 在页面生命周期内永远无法释放
// === 泄漏示例 2:循环中的闭包(经典面试题)===
// ❌ 错误:所有回调共享同一个 i(var 的作用域问题)
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出:3, 3, 3(不是 0, 1, 2!)
}, 100);
}
// 原因:var i 是函数作用域,三个闭包引用的是同一个 i,循环结束后 i=3
// ✅ 修复方式 1:let 块级作用域(每次循环创建新绑定)
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出:0, 1, 2 ✅
}, 100);
}
// ✅ 修复方式 2:IIFE 创建独立作用域
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 输出:0, 1, 2 ✅
}, 100);
})(i);
}
4.2 循环引用(IE 旧时代的噩梦)
// === 循环引用导致 DOM 无法回收 ===
function setup() {
const element = document.getElementById('myDiv');
element.onclick = function() {
// 这个闭包引用了 element
// element 又通过 onclick 引用了这个函数
// → 循环引用!
console.log(element.id);
};
}
// 在现代 V8/SpiderMonkey 中,GC 能处理这种循环引用
// 但在旧版 IE(<9)中会导致 DOM 元素永远不被回收
4.3 性能开销
闭包不是免费的:
| 开销项 | 说明 |
|---|---|
| 额外内存 | 每个闭包都携带 [[Environment]] 引用链 |
| 查找耗时 | 变量访问需要沿作用域链逐层查找(V8 有优化但非零成本) |
| 优化限制 | V8 对闭包变量的某些优化(如内联缓存)受限 |
// 性能敏感场景下避免不必要的闭包
// ❌ 循环中反复创建闭包
function slow(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) {
result.push(() => arr[i] * 2); // 每次迭代创建新闭包
}
return result;
}
// ✅ 如果不需要闭包,就不要用
function fast(arr) {
return arr.map(x => x * 2); // 无闭包,更轻量
}
五、如何避免闭包导致的内存泄漏
5.1 手动解除引用(= null)
function setupLeakFree() {
const element = document.getElementById('myDiv');
const hugeData = new Array(100000).fill('x');
element.onclick = function handleClick() {
console.log(element.id); // 只用到 element,不用 hugeData
// hugeData 不小心被闭包捕获了
};
// ✅ 解决:手动切断不需要的外部变量引用
// 方法 A:赋值 null
hugeData = null; // 释放大对象的引用
// 方法 B:用局部变量缩小闭包范围
// const id = element.id; // 只提取需要的值
// element.onclick = () => console.log(id);
}
// 更彻底的方式:用完后主动置空
let handler = setupLeakFree();
// ... 页面卸载或组件销毁时
handler = null; // 断开引用 → GC 可以回收
5.2 及时移除事件监听器
// === 最佳实践:成对出现(添加 ↔ 移除)===
class Component {
constructor(el) {
this.el = el;
this.handleClick = this.onClick.bind(this);
el.addEventListener('click', this.handleClick);
}
onClick() {
console.log('clicked');
}
destroy() {
// ⚠️ 关键:移除监听器!否则 this.el 和 this 永远不释放
this.el.removeEventListener('click', this.handleClick);
this.el = null; // 断开 DOM 引用
this.handleClick = null; // 断开函数引用
}
}
// React 中对应的是 useEffect cleanup
useEffect(() => {
const handler = () => { /* ... */ };
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler); // 清理!
}, []);
5.3 避免不必要的闭包
// === 原则:不需要访问外部变量就不要放在闭包位置 ===
// ❌ 不必要的闭包
function processData(items) {
const config = { debug: true }; // 被闭包捕获但不一定需要
return items.map(function(item) {
if (config.debug) { // 闭包引用了 config
console.log(item);
}
return item * 2;
});
}
// ✅ 把不变量提到参数或常量
const DEBUG = true; // 模块级常量,不在闭包链上
function processData(items) {
return items.map(item => {
if (DEBUG) console.log(item);
return item * 2;
});
}
5.4 WeakMap 解决强引用问题
// === WeakMap:弱引用,不阻止 GC ===
const privateData = new WeakMap();
class User {
constructor(name) {
this.name = name;
privateData.set(this, { // 以实例为 key(弱引用)
createdAt: Date.now(),
token: generateToken()
});
}
getToken() {
return privateData.get(this)?.token;
}
}
// 当 User 实例被 GC 回收时,privateData 中对应的条目自动清除
// 这是闭包 + 私有数据的现代化替代方案
六、方案对比
6.1 闭包 vs 其他实现私有变量的方式
| 方案 | 私有性 | 继承共享 | 内存效率 | 复杂度 |
|---|---|---|---|---|
| 闭包(工厂函数) | ✅ 真正私有 | ❌ 每实例独立副本 | 一般(每实例一套闭包) | 低 |
| # 私有字段(ES2022) | ✅ 真正私有 | ✅ 原型共享 | 高(最优) | 极低 |
| _ 约定(下划线) | ❌ 假私有(可访问) | ✅ 原型共享 | 最高 | 最低 |
| WeakMap | ✅ 真正私有 | ✅ 外部 Map 管理 | 高 | 中 |
| Symbol | ❌ 可通过 Reflect.ownKeys 发现 | ✅ | 高 | 中 |
// ES2022 # 私有字段(推荐现代项目使用)
class Counter {
#count = 0; // 真正私有,外部完全不可访问
increment() { return ++this.#count; }
get value() { return this.#count; }
}
const c = new Counter();
c.#count; // SyntaxError! 私有字段
c.increment(); // 1
6.2 闭包的优缺点总结
| 维度 | 详情 |
|---|---|
| 优点 | ① 变量私有化 / 数据封装 ② 实现柯里化 / 偏函数 ③ 模块模式 ④ 防抖节流 / 状态保持 ⑤ React Hooks 的基础 |
| 缺点 | ① 内存泄漏风险(外部变量无法被回收)② 循环引用(旧 IE 问题)③ 变量查找性能开销 ④ 调试困难(闭包变量不易观察) |
| 适用场景 | 需要保持状态的工厂函数、事件回调、异步回调、模块封装 |
| 避免场景 | 简单工具函数、性能热点代码、不需要外部变量的纯函数 |
七、完整流程串联
下面用一个完整的例子走通「闭包创建 → 使用 → 泄漏 → 修复」的全过程:
【步骤 1】定义外部函数
function factory() {
let secret = 'xxx'; // 将被闭包捕获的变量
return {
get() { return secret; }, // 闭包 A:读取 secret
set(v) { secret = v; }, // 闭包 B:写入 secret
reset() { secret = null; } // 闭包 C:清空 secret
};
}
【步骤 2】调用工厂,获得闭包实例
const instance = factory();
// factory() 执行完毕,但其内部的 secret 被 3 个闭包引用
// → secret 不会随 factory 的执行上下文销毁而消失
【步骤 3】通过闭包操作私有数据
instance.get(); // 'xxx'
instance.set('new value'); // 修改成功
instance.get(); // 'new value'
【步骤 4】不再需要时,手动释放
instance = null; // 3 个闭包函数全部断开引用
// → secret 现在没有任何引用可达
// → GC 下一次运行时回收 secret
【步骤 5】如果忘记释放(泄漏!)
// instance 被全局变量 / DOM 事件 / 定时器长期持有
// → secret 永远不释放
// → 如果 secret 是大对象 → 内存持续增长
最小可运行的完整 Demo
<!DOCTYPE html>
<html>
<body>
<button id="btn">点击计数</button>
<span id="display">0</span>
<script>
// 1. 用闭包创建一个安全的计数器
function createSafeCounter(displayEl) {
let count = 0; // 私有变量
function increment() { // 闭包函数
count++;
displayEl.textContent = count;
console.log(`count = ${count} (私有变量,外部无法直接改)`);
}
return increment; // 返回闭包
}
// 2. 绑定到按钮
const btn = document.getElementById('btn');
const display = document.getElementById('display');
const counter = createSafeCounter(display); // 创建闭包实例
btn.addEventListener('click', counter);
// 3. 演示安全性和独立性
const counter2 = createSafeCounter(Object.assign({}, display));
// counter 和 counter2 各自拥有独立的 count
// 4. 页面卸载时清理(防泄漏)
window.addEventListener('unload', () => {
btn.removeEventListener('click', counter);
// counter 的引用在这里断开 → GC 可回收
});
// 验证私有性:
// console.log(count); // ReferenceError! 外部无法访问
// counter.count = 999; // 无效!count 不在 counter 上
</script>
</body>
</html>
八、面试高频 Q&A
Q1:什么是闭包?用一句话解释。
A:闭包是函数 + 它被定义时的词法环境的组合。当一个函数能够"记住"并访问它所在作用域的变量,即使在其定义的作用域之外执行,就形成了闭包。本质是函数内部的 [[Environment]] 指向了外部词法环境。
Q2:闭包有哪些实际用途?
A:五大用途——① 变量私有化(createCounter 工厂函数);② 模块模式(IIFE 封装私有 API);③ 函数柯里化(add(a)(b)(c));④ 状态保持(防抖/节流/React setState);⑤ 回调函数(事件处理/异步操作中访问外层变量)。
Q3:闭包为什么会导致内存泄漏?如何解决?
A:因为闭包保持了对外部变量的引用,使得这些变量无法被垃圾回收。解决方案:(1) 手动置空 variable = null;(2) 及时移除事件监听器 removeEventListener;(3) 组件销毁时清理(React useEffect return 清理函数);(4) 避免不必要的闭包(不需要外部变量就不放在闭包位置);(5) 用 WeakMap 替代强引用。
Q4:循环中的闭包为什么输出全是一样的值?(经典题)
A:var 是函数作用域,循环中的 var i 只有一个,所有 setTimeout 回调共享同一个 i,当回调执行时循环已结束,i 已是最终值。修复方法:(1) 用 let 替代 var(let 是块级作用域,每次迭代创建新绑定);(2) 用 IIFE (function(j){...})(i) 为每次迭代创建独立作用域。
Q5:ES2022 的 # 私有字段和闭包实现的私有变量有什么区别?
A:# 私有字段是语言级别的真私有(语法层面禁止外部访问),且存储在原型上(所有实例共享一份方法),内存效率更高;闭包私有变量是运行时层面的(通过作用域隐藏),每实例各自持有一份闭包副本。新项目优先用 # 字段,需要动态私有数据或兼容旧环境时用闭包。
九、记忆口诀
- 函数打包出生地:闭包 = 函数 + 出生时的词法环境,走到哪都能用
- 私有变量靠它藏:createCounter 让 count 成私有,外部改不了
- 循环 var 要注意:var 共享同一个 i,改 let 或 IIFE 就行
- 不用赶紧断引用:
removeEventListener+= null,别让大对象赖着不走 - 能用 # 号就用 #:ES2022 私有字段更省内存,闭包留给需要动态封装的场景
本笔记由「前端面试学习笔记」skill 生成,配套 SVG 图解见正文。