摘要本文通过三个真实翻车案例剖析了「同一个功能写两遍」这一隐蔽工程坑道具图标全变西瓜、界面显示 3 金实际扣 2 金、死了的宠物还站在场上。作者指出重复实现并非「懒」而是架构演化的必然产物——抽公共层时「看得见的结构」被搬走「藏在逻辑里的细节」被留下。文章最后总结了三条防重复的实战方法改一处先搜「还有谁在做」、把「两处必须一致」写成断言、抽公共层时专门检查内联逻辑。系列说明前两篇讲了技术选型和数据考证这一篇讲一个更隐蔽的坑重复实现。同一件事在代码里存在两份改了一份忘了另一份 —— 这个坑我在这个项目里踩了三次 而且每次都是修好了 A、B 还坏着。全程没有美化翻车的地方也照写。 边玩边读 Super Auto Pets · 单机版纯网页版点开就能玩——不用下载、不用注册、不用登录。一、先说结论重复实现不是懒是演化的必然我以前以为同一个功能写两遍是新手才会犯的错。做完这个项目我发现不是。它是架构演化的必然产物。过程几乎每次都是这样一开始只有一个页面所有代码堆在一起天经地义后来要加第二个页面比如8 人混战于是把能共用的抽出来抽走的永远是看得见的结构留下的是藏在逻辑里的细节于是从这一刻起同一个功能就有了两份之后每次改一处另一处就悄悄落后一点第 3 步是关键抽象的时候你会很自然地把一张表一个函数搬走但一段内联在三元表达式里的逻辑你根本不会意识到它也是个功能。下面三个案例全都是这个剧本。二、案例一一整套道具图标全是西瓜这是我最早、也最直观的一次翻车。起因抽公共渲染层最早只有经典模式一个页面所有渲染代码都在ui.js里——包括一张宠物 emoji 对照表// 最早ui.js 里什么都有 const PET_EMOJI { Ant: , Beaver: , ... }; const PERK_EMOJI { Melon: , Honey: , Garlic: };后来加 8 人混战时我把这些表抽到了一个公共文件render.js里两个模式共用。表搬走了。但有一段逻辑没搬。因为道具图标不是一张完整的表而是一段内联的三元表达式写在渲染循环里面// 留在经典模式 ui.js 里的那一份 div classfood-emoji (f.id Apple || f.id BetterApple || f.id BestApple ? : f.id Honey ? : ) /div翻译一下这段代码是苹果系 → 是蜂蜜 → 其他所有东西 → 统统显示成 。当时整个游戏一共5 种道具苹果、优质苹果、顶级苹果、蜂蜜、西瓜所以这段代码看起来完全正确——5 种道具它全都能正确覆盖。后来道具增加到 11 种……这段代码就变成了辣椒、花生、椰子、牛奶、优质牛奶、顶级牛奶 ——这 6 种全都显示成西瓜。西瓜本身显示成 是对的因为它是else分支兜住的。这也正是这段代码能活那么久的原因。现场证据这个 bug 我在当时正好留了两张截图而且是同一个时间点、同一种道具。先看经典模式经典模式的商店。右边两个道具分别是「椰子」和「辣椒」—— 但图标都是 再看同一时刻的 8 人混战同一时刻的 8 人混战。道具「椰子」的图标是正确的—— 因为 8 人混战用的是抽出去的公共渲染层同一个道具、同一份数据、同一个时间点两个模式显示得不一样。这就是两份实现最典型的样子。我怎么发现的老实说不是我审查代码发现的—— 是玩的时候觉得这个辣椒怎么长得像西瓜才去查的。查的过程也印证了前面那句话这份逻辑藏在一个大的innerHTML字符串拼接里搜emoji根本搜不到它它里面一个 emoji 单词都没有只有三个字面量的 emoji 字符。修法与反思修法很简单经典模式也改用公共的foodSlot()emoji 表只留一份。但我在那处代码上留了一段注释把这次事故写进去了// ⚠️ 这里以前是经典模式【自己手写】的一份食物渲染emoji 写成了 // 「不是苹果、不是蜂蜜的统统 」—— 结果西瓜/辣椒/花生/椰子/牛奶/ // 安眠药 全都显示成西瓜。现在统一复用 render.js 的 foodSlot() // 三种模式同一份实现emoji 表也只有一份。为什么要把事故写进注释因为下次有人或者 AI看到这里为什么要调一个函数能立刻明白不这么写会出什么事。然后我加了一条断言专门盯这件事「每种食物在经典模式商店里显示的 emoji都必须和FOOD_EMOJI表一致」这条测试的意义不在于防 而在于它检查的是两个地方必须一致而不是某个地方等于某个值。后者只能防住你想到的那一种错。三、案例二界面显示 3 金实际扣了 2 金第二次翻车是价格。经典模式当时也有自己手写的一份商店卡片价格直接写死在字符串里div classpet-cost3 金/div而实际扣钱走的是另一个函数const cost petCostOf(pet.defId, this); // 会算星级、会算遗物折扣平时这两个是对的上的——宠物 3 金显示 3 金扣 3 金。但只要有任何折扣它俩就分家了。比如抽到遗物「批发商」宠物便宜 1 金显示实际平时3 金扣 3 金 ✅有批发商3 金扣 2 金❌玩家的第一反应不是我赚了而是这游戏是不是扣错钱了。修法和案例一一样把那份手写的卡片删掉统一复用公共的shopPetSlot()价格一律走petCostOf(defId, g)。但这个案例还有一层修完之后我以为这类问题就绝迹了。直到我在 8 人混战那边又看到一行注释const cost petCostOf(p.defId, g); // ⚠️ 必须传 game否则「批发商」遗物的折扣不会显示意思是共享函数也不是万能药。petCostOf有两个参数第二个game是折扣的来源。少传一个参数函数不会报错——它只会安安静静地返回原价。于是两份实现变成了一个更隐蔽的版本代码只有一份但调用方式有两份。 这也是为什么重构掉重复代码之后不能就放心了共享函数的调用点也是需要被检查的地方。四、案例三死了的宠物还站在场上第三次是最烦的一次因为它跨越了三种模式。背景经典模式有自己的战斗渲染到后期战斗渲染的代码是这样分布的模式战斗渲染经典模式ui.js里自己一套renderBattleBoard/startBattle/stepBattle/finishBattle8 人混战playback.js的公共回放器联机同上共用公共回放器也就是说经典模式从头到尾都留着自己的一套。bug 一尸体不消失现象是宠物血量归零之后还站在场上一动不动甚至能被继续攻击。这个 bug 之前修过一次。当时我加了一个函数专门处理血量 ≤ 0 就标记阵亡function markDeadIfZero(pet) { if (pet.hp 0 !pet.dead) pet.dead true; }然后把它加进了playback.js的逐事件处理里 ——然后我就在 8 人混战和联机里验证通过了收工。但经典模式用的是ui.js里那套自己的循环根本没有这个函数。所以玩家的反馈是8 人模式好了经典模式还是一样。 ——修了一半。bug 二对手那一行永远不显示这个是后来改战斗布局时引入的而且极其阴险。新版战斗布局是对手在上、我方在下。我在商店阶段会把对手行隐藏起来display: none开打时再显示。改布局时我重写了渲染函数漏掉了恢复显示的那一句。于是现象是战斗开始了我方队伍正常显示对手那一行是空的看起来就像对手全灭了或者这一局没有对手最坑的地方在于如果我当时去看 DOM会发现foeRow里面明明有 3 张卡片。数据是对的DOM 是有的只是display: none。我最开始的诊断思路是是不是渲染函数没被调用是不是数据为空——全都排查不出来。最后是把computedStyle.display打印出来才看到那个none。这条教训我记到现在元素存在和元素可见是两件事。当你用--dump-dom或者 DOM 断言去验证界面时 你验证的是结构对不对而不是玩家能不能看见。 而截图……截图里对手行是空的看起来太像对手真的没了反而把我带偏了。修完之后我在那行代码上留了注释const foe $(#foeRow); foe.innerHTML ; // ⚠️ 必须把 display 清掉商店阶段会把它设成 noneui.js 上面那行 // 改布局时漏了这句结果对手行永远不显示。 foe.style.display ;修法为什么我没把两份渲染合并按理说最干净的修法是把经典模式那套循环删掉三个模式共用playback.js。但我没有这么做。理由是这两套循环的驱动方式本来就是不一样的经典模式8 人混战 / 联机驱动一个setTimeout步进循环一个玩家对象带调速 / 跳过动画 / 回放状态就在页面里服务端算完广播过来强行合并等于把这个已经很复杂的东西再重构一遍回归风险远大于收益。所以我走的是另一条路不合并循环只合并渲染细节。具体就是把两个循环里逐帧渲染一张卡这件事抽成公共函数放在render.jsmarkDeadIfZero(pet)—— 血量归零就标记阵亡battleDecorateCard(card, pet, fx, isMine)—— 血条 / 伤害飘字 / 前冲 / 受击抖动然后三个模式都去调这两个函数。效果真正出错的那部分卡片应该长什么样、什么时候该消失从此只有一份而确实不同的那部分怎么驱动时间保持分开。我的判断标准是先问这两份东西为什么会分开。如果分开是因为它们真的不一样那就别硬合 把它们共有的细节抽走通常就已经能消掉绝大部分 bug 了。五、怎么防止同一个功能写两遍三个案例下来我总结出三条现在每次改代码都会过一遍1. 改一处的时候先搜这件事还有谁在做这条最土但最有效。我在这个项目里养成一个习惯当我要改一个渲染/计算逻辑时先在全局搜一遍相关的关键词——不是搜函数名而是搜做同一件事的其他写法。案例一里那段代码如果搜food-emoji而不是搜FOOD_EMOJI是可以搜出来的。我当时搜的是后者所以错过了。2. 把两个地方必须一致写成断言前面反复提到的那条测试就是例子。弱断言强断言「西瓜的图标是 」「经典模式显示的图标必须等于公共 emoji 表里的值」只能防住你想到的那一个能防住整类不一致一致性断言比值断言便宜得多也可靠得多—— 因为它不需要你预先知道正确答案。3. 抽公共层的时候专门检查内联逻辑这是从案例一学到的表、函数、常量→ 抽取时很显眼不容易漏内联在字符串拼接里的三元表达式、写在循环里的坐标计算、魔法数字→抽取时几乎必然被漏掉所以抽完公共层之后应该专门去翻一遍有没有类似的功能是用另一种形式写着的。六、小结三条都很短重复实现是演化的必然不是懒。每次抽公共层看得见的结构被搬走藏在逻辑里的细节被留下 —— 从那一刻起就有了两份。共享函数不等于安全。参数传错共享函数会安静地退化少传game折扣就不显示了。调用点也要检查。不是所有重复都该合并。先问它们为什么会分开。如果驱动方式真的不同就只合并细节保留差异。一句话版本重复的代码不可怕可怕的是你不知道它有重复。我三次翻车没有一次是因为不会改全都是因为不知道还有另一份。下一篇写什么《没报错 ≠ 通过了》这一篇讲测试为什么我的测试数量从 445 涨到 700 多条中间有一次测试总数悄悄变少却没人发现以及假通过是怎么发生的、我是怎么给自检脚本加自检的。在线版还在持续迭代中——你点进去玩到的可能比我写这篇时又新了一些。如果发现哪里和文章对不上多半是我后来改了。