一个临床生长指标 app 出现数值存储后略高的问题,作者花三天排查了四层假设,最终发现是滚动条输入事件与失焦事件的竞争条件导致数值被应用两次。
有人用 AI 编程工具做了一款小型临床应用。你输入孩子的体重和身长,它会对照生长标准返回 Z 分数。它跑通了。然后用户开始反馈,保存的值回来时比输入的略高。
偏差很小。有时零点几,有时整整一个单位。这种数字看起来像是浮点数问题、单位换算,或者某个取整规则被应用了两次。

花了三天排查。以下是他们的排查路径,因为走过的弯路才是有价值的部分。
显而易见的嫌疑对象按显而易见的顺序被排查了。
取整。显示层四舍五入到一位小数,存储层保持完整精度。回读、对比,没有差异。
写入前的转换。值在存入数据库前会经过一个标准化步骤。用自己的输入输出做了校验,干净。
字段绑定。经典问题:体重绑到了身长字段,或者两个输入共享同一份状态。也干净。
三层都干净,恰恰这就是为什么搜索一直在它们周围打转。当某一层被证明无辜时,你不会把它划掉,而是会觉得是自己没检查好,然后再来一遍。
值在接收时就已经错了
问题出在思路上,而不是这些检查中。每一个检查问的都是"数字在存入数据库的过程中发生了什么"。没有人问过:save handler 收到这个数字时,它本身是多少。
它已经是错的。
一个获得焦点的 <input type="number"> 会捕获滚轮事件,浏览器按每格一步来增加或减少值。这是标准行为,是规范中写明的,浏览器很早就这样做了。
现在看截图中的布局。结果表正好在表单正下方。这是一个完全合理的设计——你输入测量值,然后在下面读取解读。这也意味着输入体重之后,自然的下一个动作是向下滚动读取 Z 分数,而光标还停在体重字段里。
每次滚动都在编辑刚输入的值。
偏差很小,因为几格就是几步,这就是为什么它看起来像是取整的痕迹而不是输入 bug。开发人员测试时无法复现,因为开发人员输入一个值后会 Tab 出去然后点按钮。只有真实用户在翻看恰好在折叠下方的结果时,才会用停在字段里的光标滚动。
滚动时失去焦点:
<input
type="number"
onWheel={(e) => e.currentTarget.blur()}
...
/>
或者不再对不是真正需要微调器的值使用 type="number":
<input
type="text"
inputMode="decimal"
...
/>
第二种方案在移动端保留数字键盘,而这通常就是选用 type="number" 的唯一理由。但它也放弃了浏览器内置的数字验证,所以原本依赖的验证逻辑必须变成显式的。
二选其一。有趣的部分不是修复方案。
没人审查没人要求过的代码
调试的人仔细审查了自己的代码。只是审查的是自己要求写出的代码。
滚动递增从未被要求过。它不是任何一行有人写的代码。它是生成表单时选中了一个默认属性所带来的——单独看站得住脚,从未被指定,从未被审查,直到真人的手摸到真的鼠标才暴露出来。
这是我一直在 AI 编程工具组装的应用程序中遇到的失败模式,而且不是"AI 写的代码有 bug"。生成的代码没问题。里面每一个选择单独看都合理。缺口在于:规格说明从未存在过,所以没有文件可以对照出哪里不对,也没有人有责任去注意一个没人订购过的行为。
bug 报告的语言让情况更糟,事实往往如此。"保存的值变了"命名了一层。它把你引向持久化层,而三天时间就花在了持久化层。一份说"我读结果时数字变了"的报告本来十分钟就能解决——但用户不报告机制,他们报告最后看到的东西。
可以推广的部分
当你收到报告说存储的值与用户认为自己输入的值不符时,搜索空间比写入路径更大。某个上游可能在编辑它:滚轮事件、自动填充、受控组件在重新渲染时重新格式化、事件处理器在父元素上触发。
缩小搜索范围的一个廉价方法:在 handler 收到值的那一刻记录下来。如果在那里就已经错了,下面每一层都是无辜的,你就停止重新审视它们。
这就是能工作的原型和能经受用户考验的产品之间的差别。代码没问题。产品不行。
我很想知道这个分歧有多普遍:当存储的值与用户认为自己输入的值不符时,有多少时候最后发现是写入路径的问题,又有多少时候是写入路径上游的什么东西?