故事的开始:实习生小王的"灵异事件"
周五下午 5 点 50,大家正准备收拾东西下班。
突然,测试群里炸了锅:
测试小李:优惠券批量发放功能有问题!选了 3 个用户群体发券,结果 3 个群体收到的全是最后一个群体的券,前两个群体一张券都没收到!
产品经理:???这不是上周还好好的吗?
测试小李:而且只在批量操作时出问题,单个群体发券是正常的。
所有人的目光齐刷刷转向了今天刚提交代码的实习生小王。
小王打开自己的代码,满脸困惑:
js
// 批量发放优惠券
function batchSendCoupon(groupIds) {
var handlers = [];
for (var i = 0; i < groupIds.length; i++) {
handlers.push(function () {
sendCouponToGroup(groupIds[i]);
});
}
return handlers;
}
小王盯着屏幕看了 10 分钟,百思不得其解:我明明用了 i 啊,为什么每次都是最后一个?"i 从 0 开始,每次 +1,循环了 3 次,怎么发券目标全是 undefined?"
旁边的老张端着保温杯走过来,看了一眼,叹了口气:
"把 var 换成 let,然后我给你讲讲闭包。"
一、问题的根源:闭包捕获的是「变量绑定」,不是「值」
1.1 小王的代码到底发生了什么?
让我们把小王的代码拆开来看:
js
function batchSendCoupon(groupIds) {
var handlers = [];
for (var i = 0; i < groupIds.length; i++) {
handlers.push(function () {
sendCouponToGroup(groupIds[i]);
});
}
return handlers;
}
// 假设 groupIds = ['新用户', '活跃用户', '沉睡用户']
const fns = batchSendCoupon(['新用户', '活跃用户', '沉睡用户']);
fns[0](); // undefined ------ 而非 '新用户'
fns[1](); // undefined ------ 而非 '活跃用户'
fns[2](); // undefined ------ 而非 '沉睡用户'
为什么全是 undefined?
因为 var i 只有一个变量绑定,它存在于 batchSendCoupon 的函数作用域中。循环结束后,i 的值变成了 3(3 < 3 为 false,循环退出)。此时 groupIds[3] 不存在,所以是 undefined。
3 个闭包函数共享的是同一个 i------就像 3 个人共用一张门禁卡,最后一个人把卡改了,所有人刷出来的都是改后的结果。
1.2 用图理解:var vs let 的内存模型
var 的情况------所有人共享一张卡:
css
┌──────────────────────────────────────┐
│ batchSendCoupon 的作用域 │
│ │
│ i ──────► [3] ← 只有一个 i │
│ ↑ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ fn[0] fn[1] fn[2] │
│ 读 i 读 i 读 i │
│ 得到 3 得到 3 得到 3 │
└──────────────────────────────────────┘
let 的情况------每人一张独立的卡:
css
┌──────────────────────────────────────┐
│ 第 1 次迭代的作用域 i₀ ──► [0] │
│ fn[0] 读 i₀,得到 0 │
├──────────────────────────────────────┤
│ 第 2 次迭代的作用域 i₁ ──► [1] │
│ fn[1] 读 i₁,得到 1 │
├──────────────────────────────────────┤
│ 第 3 次迭代的作用域 i₂ ──► [2] │
│ fn[2] 读 i₂,得到 2 │
└──────────────────────────────────────┘
let 在每次循环迭代时,V8 引擎会创建一个新的词法环境(LexicalEnvironment) ,每个闭包捕获的是自己那一次迭代的 i,互不干扰。
1.3 三种修复方式对比
js
// ✅ 方式一:let(推荐,最简洁)
for (let i = 0; i < groupIds.length; i++) {
handlers.push(function () {
sendCouponToGroup(groupIds[i]); // 每次迭代有独立的 i
});
}
// ✅ 方式二:IIFE 立即执行函数(手动创建闭包作用域)
for (var i = 0; i < groupIds.length; i++) {
(function (j) {
handlers.push(function () {
sendCouponToGroup(groupIds[j]); // j 是 IIFE 参数,每次调用都是新绑定
});
})(i);
}
// ✅ 方式三:forEach(回调参数天然是独立绑定)
groupIds.forEach(function (groupId) {
handlers.push(function () {
sendCouponToGroup(groupId); // groupId 是回调参数,每次迭代都是新的
});
});
经验法则 :在
for循环中,永远用let,不要用var。这不是风格偏好,是避免 bug 的硬性规则。
二、闭包捕获的是「引用」,不是「快照」
小王修完发券的 bug 后,心想:"闭包就是函数记住外层变量嘛,我懂了。"
然后他又踩了一个坑。
2.1 闭包中的变量会"跟着变"
js
function createShoppingCart() {
let totalPrice = 0;
const getTotal = () => totalPrice;
const setTotal = (val) => {
totalPrice = val;
};
return { getTotal, setTotal };
}
const { getTotal, setTotal } = createShoppingCart();
console.log(getTotal()); // 0
setTotal(299.9);
console.log(getTotal()); // 299.9 ← 闭包里的 totalPrice 变了!
关键理解 :闭包不是在创建时"拍照"保存变量的值,而是持有一条指向变量绑定的引用线。变量变了,闭包读到的也变了。
这就像你保存了朋友的手机号(引用),朋友换了号码,你打过去就是新号码。你不是保存了"号码的副本"(快照),你保存的是"找到朋友的途径"(绑定)。
2.2 如果需要"快照"怎么办?
电商场景中很常见------下单时需要"冻结"商品价格,不能因为后续价格变动而影响已下单的金额:
js
function createOrderPriceTracker(originalPrice) {
let currentPrice = originalPrice;
const getCurrentPrice = () => currentPrice;
const updatePrice = (val) => {
currentPrice = val;
};
// 用中间变量做值拷贝,"冻结"下单时的价格
const getOrderPrice = (() => {
const frozenPrice = currentPrice; // 此时 currentPrice=originalPrice,frozenPrice 是独立拷贝
return () => frozenPrice;
})();
return { getCurrentPrice, updatePrice, getOrderPrice };
}
const tracker = createOrderPriceTracker(199);
console.log(tracker.getCurrentPrice()); // 199
console.log(tracker.getOrderPrice()); // 199 ← 下单时冻结的价格
tracker.updatePrice(299); // 商品涨价了
console.log(tracker.getCurrentPrice()); // 299 ← 当前价格跟着变了
console.log(tracker.getOrderPrice()); // 199 ← 但下单价格不受影响
三、let 和 var 混用------同一个闭包,两种命运
这个例子最能说明问题:在同一个闭包中,let 变量和 var 变量的行为完全不同。
js
// 批量生成商品标签
const funcs = [];
for (let i = 0; i < 3; i++) {
var tag = `标签-${i}`; // var 提升到外层,只有一个绑定
funcs.push(function () {
console.log(`商品${i}, ${tag}`);
});
}
funcs[0](); // 商品0, 标签-2 ← i 是 let,每次独立;tag 是 var,共享最终值
funcs[1](); // 商品1, 标签-2
funcs[2](); // 商品2, 标签-2
解读:
let i:每次循环迭代创建新的词法环境,funcs[0]捕获的是i=0那次迭代的绑定var tag:被提升到循环外层的函数作用域,只有一个绑定,循环结束时tag='标签-2',所有闭包共享
同一个闭包函数里,一个变量"各回各家",另一个变量"共享一张卡"------这就是 let 和 var 在闭包中的本质区别。
四、V8 的 Lazy Compilation------报错也能迟到
4.1 函数定义了但不调用,错误不会暴露
js
function loadProductPage() {
const productId = 'SKU-001';
// 这个函数从未被调用
function neverCalled() {
console.log(productId);
return productId + relatedSku; // ReferenceError: relatedSku is not defined
}
const relatedSku = 'SKU-002'; // relatedSku 在 neverCalled 之后声明
return neverCalled;
}
const fn = loadProductPage();
// 👆 运行到这里,没有任何报错!
fn(); // 👈 现在才报错:ReferenceError: relatedSku is not defined
为什么?
V8 采用懒编译(Lazy Compilation)策略:函数只有在首次被调用时 才会被完整编译。loadProductPage 被调用时,V8 只对 neverCalled 做了预解析(pre-parse)------快速扫描语法结构,记录哪些变量被引用,但不做完整的语义分析和代码生成。
所以 relatedSku is not defined 这个错误,在 neverCalled 被实际调用之前,根本不会被发现。
4.2 预解析器的"保守策略"------可能影响内存
js
function loadProductDetail() {
const productImages = new Array(1000000).fill('image_url'); // 大量商品图片数据
function getProductName() {
// 故意不引用 productImages
return 'iPhone 18 Pro Max';
}
return getProductName;
}
const fn = loadProductDetail();
// productImages 会被 GC 回收吗?
预解析器在扫描 getProductName 时,必须保守地记录变量引用情况。在旧版 V8 中,预解析器可能无法精确判断 getProductName 是否真的捕获了 productImages,导致 productImages 被闭包的 Context Chain 保留,无法被垃圾回收。
新版 V8 已经做了优化 ,能更精确地检测到 getProductName 没有引用 productImages,从而允许 GC 回收。但这个知识点提醒我们:闭包的变量捕获不是"你写了什么",而是"引擎认为你捕获了什么"。
4.3 如何验证?用 Chrome DevTools
- 打开 Chrome DevTools → Memory 面板
- 运行上面的代码
- 点击"Take Heap Snapshot"
- 搜索
Array,观察大数组是否还在内存中 - 对比新旧版 Chrome 的结果差异
五、闭包与内存------你以为不用,其实还在
5.1 闭包会保留整个作用域,而不只是你用到的变量
js
function createProductFilter() {
const allProducts = new Array(1000000).fill({ name: '商品', price: 99 }); // 8MB+
const filterName = '手机';
return function () {
return filterName; // 只用了 filterName,但 allProducts 也被闭包的 Context 保留了
};
}
const fn = createProductFilter();
// allProducts 无法被 GC,即使我们只需要 filterName
原因 :闭包保留的是整个词法环境(LexicalEnvironment) ,而不是只保留被引用的变量。虽然现代 V8 做了优化(只保留被闭包引用的变量),但在某些场景下(如 eval、with、debugger 存在时),V8 无法确定哪些变量会被使用,只能保守保留全部。
5.2 用块作用域打破关联
js
function createProductFilter() {
let getFilterName;
{
// 块作用域
const allProducts = new Array(1000000).fill({ name: '商品', price: 99 });
// allProducts 只在这个块内可见
} // 块结束后,allProducts 没有任何引用,可以被 GC
const filterName = '手机';
getFilterName = function () {
return filterName; // 闭包只捕获 filterName,不捕获 allProducts
};
return getFilterName;
}
const fn = createProductFilter();
// allProducts 已被 GC ✓
5.3 隐蔽的泄漏------arguments 对象
js
function createOrderHandler() {
return function () {
return arguments[0]; // 只用了第一个参数
};
}
const fn = createOrderHandler(hugeOrderList, hugeUserList, hugeLogList);
// 即使只用 arguments[0],hugeUserList 和 hugeLogList 也被 arguments 对象保留,无法 GC
修复方式 :用剩余参数替代 arguments:
js
function createOrderHandler(first, ...rest) {
return function () {
return first; // 只捕获 first,rest 如果没被引用可以被 GC
};
}
六、闭包与 this------两个独立的问题
小王修完发券 bug 后,又遇到了一个"灵异"问题:
js
const shop = {
name: '旗舰店',
promotions: [],
init() {
for (var i = 0; i < 3; i++) {
this.promotions.push(function () {
console.log(this.name, i);
});
}
},
};
shop.init();
shop.promotions[0](); // undefined 3 ← this 丢了,i 也是共享的
两个问题,两个原因:
| 问题 | 原因 | 解决方案 |
|---|---|---|
this 是 undefined |
普通函数的 this 取决于调用方式,promotions[0]() 是普通调用,this 为 undefined(严格模式) |
用箭头函数 |
i 是 3 |
var i 只有一个绑定,所有闭包共享 |
用 let |
双重修复:
js
const shop = {
name: '旗舰店',
promotions: [],
init() {
for (let i = 0; i < 3; i++) {
this.promotions.push(() => {
console.log(this.name, i); // 箭头函数修复 this,let 修复 i
});
}
},
};
shop.init();
shop.promotions[0](); // 旗舰店 0 ✓
shop.promotions[1](); // 旗舰店 1 ✓
shop.promotions[2](); // 旗舰店 2 ✓
记忆口诀 :箭头函数管 this,let 管变量绑定,两个问题要分开治。
七、for...of 也不能幸免
for...of 看起来比 for 更"现代",但配上 var,一样翻车:
js
const categories = ['数码', '服饰', '食品'];
const fns = [];
// ❌ for...of + var
for (var category of categories) {
fns.push(() => category);
}
console.log(fns[0]()); // '食品' ← 共享同一个 category
console.log(fns[1]()); // '食品'
console.log(fns[2]()); // '食品'
// ✅ for...of + let
const fns2 = [];
for (let category of categories) {
fns2.push(() => category);
}
console.log(fns2[0]()); // '数码' ✓
console.log(fns2[1]()); // '服饰' ✓
console.log(fns2[2]()); // '食品' ✓
// ✅ forEach(回调参数天然独立)
const fns3 = [];
categories.forEach((category) => {
fns3.push(() => category);
});
console.log(fns3[0]()); // '数码' ✓
console.log(fns3[1]()); // '服饰' ✓
console.log(fns3[2]()); // '食品' ✓
原理 :forEach 的回调函数每次调用都是新的执行上下文,category 是参数,天然是独立绑定。而 for...of + var 中,var category 被提升到外层,只有一个绑定。
八、亲手验证------Chrome DevTools 中的闭包世界
这是最直观的验证方式,建议你一定要亲手试一次:
js
function outer(x) {
const a = 'A';
function middle(y) {
const b = 'B';
function inner(z) {
const c = 'C';
debugger; // ← 在这里暂停,观察 Scope 面板
return x + y + z + a + b + c;
}
return inner;
}
return middle;
}
const mid = outer('X');
const inn = mid('Y');
const result = inn('Z');
console.log(result); // 'XZYABC'
操作步骤:
- 打开 Chrome DevTools(F12)→ Sources 面板
- 把上面的代码粘贴到控制台并回车
- 程序会在
debugger处暂停 - 查看右侧 Scope 面板,你会看到:
sql
┌─ Local (inner 的局部作用域)
│ c: 'C'
│ z: 'Z'
├─ Closure (middle) ← 中间层闭包
│ b: 'B'
│ y: 'Y'
├─ Closure (outer) ← 外层闭包
│ a: 'A'
│ x: 'X'
└─ Global
这就是闭包的 Context Chain ------inner 函数通过作用域链,一层一层向外查找变量,直到找到为止。每一层闭包都像一个"背包",函数走到哪里就背到哪里。
九、总结:闭包避坑清单
| 场景 | 陷阱 | 正确做法 |
|---|---|---|
for 循环中创建闭包 |
var 导致所有闭包共享同一变量 |
用 let |
| 闭包中读取外层变量 | 变量是引用,不是快照,后续修改可见 | 需要快照时用中间变量拷贝 |
闭包中访问 this |
普通函数的 this 取决于调用方式 |
用箭头函数或 bind |
| 闭包保留大对象 | 闭包可能保留整个词法环境 | 用块作用域隔离不需要的变量 |
使用 arguments |
arguments 保留所有参数 |
用剩余参数 ...args |
for...of + var |
和 for + var 一样的问题 |
用 let 或 forEach |
| 未调用的内部函数 | Lazy Compilation 延迟报错 | 注意函数调用时机,写好测试 |
写完这篇文章,小王默默把编辑器里的 ESLint 规则加上了 no-var,从此再也没有因为闭包加过班。