一个 var 让整个团队加班到凌晨——JS 闭包的那些暗坑

故事的开始:实习生小王的"灵异事件"

周五下午 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 的值变成了 33 < 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',所有闭包共享

同一个闭包函数里,一个变量"各回各家",另一个变量"共享一张卡"------这就是 letvar 在闭包中的本质区别。


四、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

  1. 打开 Chrome DevTools → Memory 面板
  2. 运行上面的代码
  3. 点击"Take Heap Snapshot"
  4. 搜索 Array,观察大数组是否还在内存中
  5. 对比新旧版 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 做了优化(只保留被闭包引用的变量),但在某些场景下(如 evalwithdebugger 存在时),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 也是共享的

两个问题,两个原因

问题 原因 解决方案
thisundefined 普通函数的 this 取决于调用方式,promotions[0]() 是普通调用,thisundefined(严格模式) 用箭头函数
i3 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  ✓

记忆口诀 :箭头函数管 thislet 管变量绑定,两个问题要分开治。


七、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'

操作步骤

  1. 打开 Chrome DevTools(F12)→ Sources 面板
  2. 把上面的代码粘贴到控制台并回车
  3. 程序会在 debugger 处暂停
  4. 查看右侧 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 一样的问题 letforEach
未调用的内部函数 Lazy Compilation 延迟报错 注意函数调用时机,写好测试

写完这篇文章,小王默默把编辑器里的 ESLint 规则加上了 no-var,从此再也没有因为闭包加过班。

相关推荐
weedsfly3 小时前
用了 React/Vue 之后,这些 DOM 操作的坑你踩过几个?
前端·javascript
Asize3 小时前
Ajax 入门:从 JSON 序列化到 XMLHttpRequest
前端·javascript·前端框架
林希_Rachel_傻希希3 小时前
react hooks速通笔记
前端
Csvn3 小时前
🚨 组件卸载后还在 setState?一个被你忽视的内存泄漏和报错根源
前端
乘风gg3 小时前
AI GenUI 真正落地时,前端到底要做什么?
前端·ai编程·cursor
铁皮饭盒3 小时前
@kognitivedev/rag, 用js做AI Agent开发
javascript·后端
恋猫de小郭4 小时前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
IT_陈寒4 小时前
JavaScript的默认参数挖坑实录,我掉进去了
前端·人工智能·后端
kyriewen16 小时前
别再 console.log 了:5 个 Chrome DevTools 调试技巧,用过就回不去了
前端·javascript·面试