前言
TL;DR:AI 生成的代码在 for 循环中用var声明变量,导致 gsap 动画的onComplete回调闭包全部指向循环最后一次迭代的节点。结果是解锁卡片时操作了错误的对象、查看详情后图片消失。根因是var函数作用域 vslet块作用域。修复:var→let+ 项目规则禁止var+ 文档模板全量替换(139 处)。
技术背景
运行环境
业务场景
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, {opacity: 0, duration: 0.4,onComplete: function () { iconImage.visible = false; }});gsap.to('#' + cardImage.id, {opacity: 1, duration: 1.8,onComplete: function () {cardImage.style = Object.assign({}, cardImage.style, { opacity: 1 });}});}}}
Bug 现象
卡片从"可解锁"变为"已解锁",淡入动画正常播放 点击该卡片打开详情弹窗 关闭弹窗后,卡片图片变为空白(opacity 被重置为 0)
三个卡片都处于"可解锁"状态 点击解锁卡片1 卡片1正常执行解锁动画 卡片3的图标突然消失
排查过程
第一轮:猜测状态重置
第二轮:猜测 gsap onComplete 延迟执行
解锁动画结束后(~2秒)→ window._debug = undefined(onComplete 未执行!)打开详情弹窗后 → window._debug 出现了(onComplete 此时才执行)
事后回顾:此时 onComplete 执行的cardImage.style.opacity = 1设置的是卡片3的 MNode(不是卡片1的)。卡片1的 MNode.style.opacity 从未被修改过,仍是之前在"可解锁"状态下设置的 0。因此排查时陷入了对框架响应式同步(reconciliation)机制的猜测——试图理解"为什么 MNode=1 但 DOM 还是会重置"——但根因其实是 MNode 本身就没被正确更新。
第三轮:绕过 onComplete 修复
cardImage.style = Object.assign({}, cardImage.style, { opacity: 1 }); // MNode 立即为 1gsap.fromTo('#' + cardImage.id, { opacity: 0 }, { opacity: 1, duration: 1.8 }); // DOM 从 0 渐变
第四轮:Bug 2 揭示真相
根因分析
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, { // 选择器字符串 → 立即求值 → 正确onComplete: function () {cardImage.style.opacity = 1; // 闭包引用 → 延迟求值 → 错误!}});gsap.to('#' + iconImage.id, {onComplete: function () {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
项目规则中有一条约束:"语法兼容 ES2017,不得使用更新的语法特性(如可选链?.、空值合并??等)"——AI 将其过度解读为"应使用更保守的语法" 文档模板中的代码示例全部使用 var——AI 忠实模仿模板风格
项目规则:"不要用新语法特性"(本意是禁止 ES2020+)↓ AI 过度解读文档模板:代码示例全用 var(历史习惯,但无 for+异步回调场景,模板本身不出 bug)↓ AI 模仿模板风格AI 生成代码:所有变量统一用 var 做机械替换↓ "for 循环 + gsap onComplete" 场景触发闭包陷阱 → Bug
修复方案
直接修复: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, { opacity: 1 });// 用 fromTo 强制 DOM 从 0 开始动画(保留视觉效果)gsap.fromTo('#' + cardImage.id, { opacity: 0 }, { opacity: 1, duration: 1.8 });
制度修复:规则 + 模板更新
在项目编码规范中新增:禁止使用var,统一使用const/let(含原因说明) 将所有文档模板中的var替换为正确的const/let(3 个文件,共 139 处) 确保 AI 后续生成代码时参考的模板不再包含var
经验总结
排查中走的弯路
弯路 | 原因 | 教训 |
猜测框架 reconciliation 机制 | 不熟悉框架内部实现,试图从黑盒行为推导原因 | 先排除代码层面的基础问题,再考虑框架机制 |
只看逻辑差异不看语法差异 | 对比模板代码时关注了结构/算法,忽略了 var/let | 对比代码要到语法级别 |
在单一 bug 上打转 | Bug 1 让我陷入 opacity/reconciliation 分析 | 多个 bug 交叉分析可能更快定位共同根因 |
识别信号(排查速查表)
防御性编程原则
禁止var:从制度上消除问题产生的可能性 异步回调中避免依赖循环变量:即使用了let,也尽量在回调外通过字符串/ID 固化引用 MNode 状态应立即更新:不要把关键状态变更放在异步回调中,框架可能在回调执行前就读取了旧值 模板代码要用最安全的写法:模板风格会被团队复制传播,一个var模板可能产生 N 个闭包 bug
结语
伪装成框架行为问题("为什么 reconciliation 重置了我的 DOM?") 通过多个看似无关的症状分散注意力(Bug 1 和 Bug 2 表现完全不同) 通过 AI + 文档模板悄悄扩散(文档用 var → AI 学习模板 → 新代码全用 var → bug)
项目规则中明确禁止var(给 AI 和人类同一个约束) 文档模板全量替换为const/let(消除 AI 的错误学习源) 复盘记录作为项目知识沉淀(后续 AI 和人类都能参考)
夜雨聆风