乐于分享
好东西不私藏

一个 AI 引发的 bug:JavaScript 闭包陷阱

一个 AI 引发的 bug:JavaScript 闭包陷阱

前言

在一个可视化搭建编辑器项目中,我们通过代码块控制页面节点的显示状态和动画效果。某次从模板工程复制代码到新工程时,引入了两个看似毫无关联的 bug。经过多轮排查,最终发现它们共享同一个根因——JavaScript 中最经典的陷阱之一:var在 for 循环中的闭包问题。
本文记录了完整的排查过程,包括走过的弯路和最终定位的思路,希望对类似场景有参考价值。

TL;DR:AI 生成的代码在 for 循环中用var声明变量,导致 gsap 动画的onComplete回调闭包全部指向循环最后一次迭代的节点。结果是解锁卡片时操作了错误的对象、查看详情后图片消失。根因是var函数作用域 vslet块作用域。修复:var→let+ 项目规则禁止var+ 文档模板全量替换(139 处)。


技术背景

运行环境

可视化搭建编辑器:页面由节点树组成,每个节点有属性(visible/src/style 等)
MNode:节点的响应式数据模型,修改属性会触发 DOM 更新
gsap:用于执行节点动画(操作 DOM 行内 style)
代码通过数据源方法执行,数据变更触发 UI 更新

业务场景

页面有 3 个"卡片",每个卡片有 3 种状态:锁定、可解锁、已解锁。解锁时播放淡入动画(opacity 0→1),解锁后点击可打开详情弹窗。
核心代码结构:
async function updateCards(cardList) {  // card.children 包含不同状态的子节点:背景底图、锁定容器、可解锁图、已解锁图、标题、图标、点击区  for (var index = 0; index < cardList.length; index++) {    var card = cardList[index];    var children = card.children;    var cardImage = getNode(children[3].id);       // 已解锁图片    var iconImage = getNode(children[5].id);       // 图标    if (card.isUnlocked && isPlayingAnimation) {      // 解锁动画      gsap.to('#' + iconImage.id, {        opacity0duration0.4,        onCompletefunction () { iconImage.visible = false; }      });      gsap.to('#' + cardImage.id, {        opacity1duration1.8,        onCompletefunction () {          cardImage.style = Object.assign({}, cardImage.style, { opacity1 });        }      });    }  }}

Bug 现象

Bug 1:解锁卡片后查看详情,卡片图片消失
  1. 卡片从"可解锁"变为"已解锁",淡入动画正常播放
  2. 点击该卡片打开详情弹窗
  3. 关闭弹窗后,卡片图片变为空白(opacity 被重置为 0)
Bug 2:解锁卡片1时,卡片3的图标消失
  1. 三个卡片都处于"可解锁"状态
  2. 点击解锁卡片1
  3. 卡片1正常执行解锁动画
  4. 卡片3的图标突然消失
两个 bug 看似无关:一个是"查看详情后图片消失",一个是"解锁时错误卡片受影响"。

排查过程

第一轮:猜测状态重置

假设:打开/关闭详情弹窗时触发了状态重新渲染,重渲染时读取到了旧的 opacity 值。
验证:打开详情弹窗不会修改卡片数据 → 不会触发 updateCards → 这个假设不成立。
结论:方向错误。问题不在"状态管理"层面。

第二轮:猜测 gsap onComplete 延迟执行

假设:gsap 的 onComplete 回调没有在动画完成时立即执行,导致 MNode.style.opacity 一直停留在 0。后续弹窗操作触发了框架的 DOM 同步(reconciliation),从 MNode 读到 0 并重置了 DOM。
验证:在 onComplete 中写入调试变量window._debug,动画完成后立即检查。
发现
解锁动画结束后(~2秒)→ window._debug = undefined(onComplete 未执行!)打开详情弹窗后 → window._debug 出现了(onComplete 此时才执行)
中间结论:onComplete 确实延迟执行。但它执行时输出 MNode.opacity=1, DOM.opacity=1——为什么最终还是变空白?

事后回顾:此时 onComplete 执行的cardImage.style.opacity = 1设置的是卡片3的 MNode(不是卡片1的)。卡片1的 MNode.style.opacity 从未被修改过,仍是之前在"可解锁"状态下设置的 0。因此排查时陷入了对框架响应式同步(reconciliation)机制的猜测——试图理解"为什么 MNode=1 但 DOM 还是会重置"——但根因其实是 MNode 本身就没被正确更新。

此时排查陷入了对框架 reconciliation 机制的猜测,试图理解"为什么 onComplete 执行后 DOM 还会被重置"。

第三轮:绕过 onComplete 修复

修复尝试:不依赖 onComplete,在 gsap.to 启动前立即设置cardImage.style.opacity = 1。
结果:卡片不再消失了!但解锁动画消失了(因为 MNode 立即设为 1 → 框架同步 DOM 为 1 → gsap 没有 0→1 的变化空间)。
改进:用gsap.fromTo强制从 0 开始动画:
cardImage.style = Object.assign({}, cardImage.style, { opacity: 1 }); // MNode 立即为 1gsap.fromTo('#' + cardImage.id, { opacity: 0 }, { opacity: 1, duration: 1.8 }); // DOM 从 0 渐变
结果:Bug 1 修复了。但根因仍然没有被理解。

第四轮:Bug 2 揭示真相

当用户报告 Bug 2("解锁卡片1时卡片3的图标消失")时,这触发了关键联想——循环闭包问题
"总是最后一个元素被错误操作"——这是var闭包 bug 的经典症状。

根因分析

var 的函数作用域 vs let 的块作用域

// var:整个函数共享同一个变量for (var i = 0; i < 3; i++) {  setTimeout(function() { console.log(i); }, 100);}// 输出:3, 3, 3(都是循环结束后的值)// let:每次迭代创建独立变量for (let i = 0; i < 3; i++) {  setTimeout(function() { console.log(i); }, 100);}// 输出:0, 1, 2(各自迭代时的值)
在本案例中的具体表现
for (var index = 0; index < 3; index++) {  var cardImage = getNode(items[3].id);   // var → 函数作用域  var iconImage = getNode(items[5].id);   // var → 函数作用域  if (shouldAnimate(index)) {    gsap.to('#' + cardImage.id, {         // 选择器字符串 → 立即求值 → 正确      onCompletefunction () {        cardImage.style.opacity = 1;      // 闭包引用 → 延迟求值 → 错误!      }    });    gsap.to('#' + iconImage.id, {      onCompletefunction () {        iconImage.visible = false;        // 闭包引用 → 延迟求值 → 错误!      }    });  }}
关键区分

'#' + cardImage.id:字符串拼接是同步的,在 gsap.to 调用时立即求值 → 得到正确的 ID

onComplete回调中的cardImage:是闭包引用,在回调异步执行时才求值 → 此时cardImage已被循环覆盖为最后一个卡片的节点

Bug 1 因果链

解锁卡片1 (index=0):  → cardImage 指向卡片1 → gsap.to 启动(选择器正确)  → 循环继续 → cardImage 被覆盖为卡片3  → onComplete 执行 → cardImage.style.opacity=1 设置的是卡片3  → 卡片1 的 MNode.style.opacity 从未变为 1(仍是 0  → 打开详情弹窗 → 框架 DOM 同步 → 从卡片1 MNode 读到 opacity=0 → DOM 重置

Bug 2 因果链

解锁卡片1 (index=0):  → iconImage 指向卡片1 → gsap.to 启动(选择器正确)  → 循环继续 → iconImage 被覆盖为卡片3  → onComplete 执行 → iconImage.visible=false 隐藏的是卡片3的图标

两个 bug 共享同一根因

Bug

表现

受影响对象

原因

1

解锁后查看详情图片消失

被解锁的卡片自身

onComplete 设 opacity 到了错误节点

2

解锁卡片1时卡片3图标消失

最后一张卡片

onComplete 设 visible 到了错误节点


为什么模板工程没有这个 bug

对比发现:模板工程的原代码由人类编写,使用const/let(块作用域),每次循环迭代变量独立,闭包不会出错。
但这次复制到新工程的代码是AI 生成的。AI 在生成时参考了两样东西:
  1. 项目规则中有一条约束:"语法兼容 ES2017,不得使用更新的语法特性(如可选链?.、空值合并??等)"——AI 将其过度解读为"应使用更保守的语法"
  2. 文档模板中的代码示例全部使用 var——AI 忠实模仿模板风格
传播链:
项目规则:"不要用新语法特性"(本意是禁止 ES2020+)          ↓ AI 过度解读文档模板:代码示例全用 var(历史习惯,但无 for+异步回调场景,模板本身不出 bug)          ↓ AI 模仿模板风格AI 生成代码:所有变量统一用 var 做机械替换          ↓ "for 循环 + gsap onComplete" 场景触发闭包陷阱 → Bug
关键洞察:let/const是 ES2015 的特性,完全在 ES2017 范围内。模板工程的原代码也使用const/let。但 AI 不理解"for 循环中的 var 有闭包风险",只是机械地遵循"模板用什么我就用什么"。而这正是人类开发者的优势——人类的编码直觉能识别"这个地方用 var 不行"。
这暴露了 AI 辅助开发的一个隐性风险:文档模板的"坏习惯"不会影响人类(人类凭直觉会纠正),但会被 AI 忠实复制和放大。

修复方案

直接修复:var → let

for (let index = 0; index < cardList.length; index++) {  let cardImage = getNode(items[3].id);  let iconImage = getNode(items[5].id);  // 每次迭代的 onComplete 闭包捕获各自独立的变量}

防御性修复:不依赖 onComplete 设 MNode

// 立即设 MNode.style.opacity = 1(防止 reconciliation 重置)cardImage.style = Object.assign({}, cardImage.style, { opacity1 });// 用 fromTo 强制 DOM 从 0 开始动画(保留视觉效果)gsap.fromTo('#' + cardImage.id, { opacity0 }, { opacity1, duration1.8 });
两种修复结合使用(双重保险)。

制度修复:规则 + 模板更新

  1. 在项目编码规范中新增:禁止使用var,统一使用const/let(含原因说明)
  2. 将所有文档模板中的var替换为正确的const/let(3 个文件,共 139 处)
  3. 确保 AI 后续生成代码时参考的模板不再包含var
这是从根源上断绝问题再次产生的措施——当所有文档、模板、规则都统一使用const/let时,无论是人类开发者还是 AI,都不会再写出var。

经验总结

排查中走的弯路

弯路

原因

教训

猜测框架 reconciliation 机制

不熟悉框架内部实现,试图从黑盒行为推导原因

先排除代码层面的基础问题,再考虑框架机制

只看逻辑差异不看语法差异

对比模板代码时关注了结构/算法,忽略了 var/let

对比代码要到语法级别

在单一 bug 上打转

Bug 1 让我陷入 opacity/reconciliation 分析

多个 bug 交叉分析可能更快定位共同根因

识别信号(排查速查表)

如果看到以下任何一条,立即怀疑var闭包问题:
"总是最后一个/第 N 个元素被错误操作"
"回调执行时操作的对象不是预期的"
"循环中的动画/定时器影响了错误的元素"
异步回调(setTimeout/Promise/gsap onComplete/事件监听器)在 for 循环内注册

防御性编程原则

  1. 禁止var:从制度上消除问题产生的可能性
  2. 异步回调中避免依赖循环变量:即使用了let,也尽量在回调外通过字符串/ID 固化引用
  3. MNode 状态应立即更新:不要把关键状态变更放在异步回调中,框架可能在回调执行前就读取了旧值
  4. 模板代码要用最安全的写法:模板风格会被团队复制传播,一个var模板可能产生 N 个闭包 bug

结语

这个 bug 的根因(var 闭包)是 JavaScript 最基础的知识点,任何入门教程都会提到。但在实际工程中,它依然能够:
  1. 伪装成框架行为问题("为什么 reconciliation 重置了我的 DOM?")
  2. 通过多个看似无关的症状分散注意力(Bug 1 和 Bug 2 表现完全不同)
  3. 通过 AI + 文档模板悄悄扩散(文档用 var → AI 学习模板 → 新代码全用 var → bug)
这个案例也揭示了 AI 辅助开发中的一个重要原则:给 AI 的参考文档和模板代码,必须使用最安全的写法。人类开发者能凭直觉规避的"不推荐写法",AI 会忠实模仿。文档中的每一个var、每一个不规范的示例,都可能在 AI 生成的代码中被放大为 bug。
最终我们的应对措施:
  1. 项目规则中明确禁止var(给 AI 和人类同一个约束)
  2. 文档模板全量替换为const/let(消除 AI 的错误学习源)
  3. 复盘记录作为项目知识沉淀(后续 AI 和人类都能参考)
教训:遇到异步回调 + 循环的组合时,第一时间检查变量声明方式,不要跳过基础直接分析框架行为。而对于 AI 辅助开发——确保你的文档模板本身是无懈可击的。