-
Couldn't load subscription status.
- Fork 7
Description
原文:https://overreacted.io/how-to-fix-any-bug/ ,翻译官:Qwen 3 Max
引文: 使用 AI 来修复我们编程过程中遇到的 Bug 是日常的工作,但是很多时候,我们询问 AI,却无法帮我们解决问题,浪费了时间和 Token,而且自己还气的不行,下面看一下 React 社区大神 Dan 是如何和 AI 一起修复 bug 的吧!
我一直在用“氛围编程”(vibecoding)开发一个小应用,几天前遇到了一个 bug。 下面是正文👇
这个 bug 大致是这样的:想象一个网页应用中的路由,该路由展示一系列步骤——本质上就是一些卡片。每张卡片上都有一个按钮,点击后会滚动到下一张卡片。一切原本都运行得很好。然而,一旦我尝试在点击该按钮时同时调用服务器,滚动就失效了——页面会抖动并中断滚动。
也就是说,添加一个远程调用不知怎么就破坏了滚动功能。
我不确定这个 bug 的根源是什么。显然,新加入的远程服务器调用(我是通过 React Router actions 实现的)以某种方式干扰了我的 scrollIntoView 调用。但我无法确定具体是如何干扰的。我最初以为问题是 React Router 重新渲染了页面(因为 action 会触发数据重新获取),但原则上,一次数据重新获取不应该干扰正在进行的滚动操作。服务器返回的是相同的内容,所以本不该有任何变化。
在 React 中,重新渲染应当始终是安全的。问题出在别处——要么是我的代码,要么是 React Router,要么是 React 本身,甚至可能是浏览器的问题。
我该如何修复这个 bug?
我能请 Claude 来修复它吗?
第 0 步:直接修复它
我让 Claude 来修复这个问题。
Claude 尝试了几种方法。它重写了包含 scrollIntoView 调用的 useEffect 中的条件判断,并声称 bug 已经修复。但这并没有效果。接着它又尝试将平滑滚动(smooth)改为瞬时滚动(instant),以及其他一些改动。
每次,Claude 都会自豪地宣布问题已解决。
但其实并没有!
bug 依然存在。
这听起来可能像是在抱怨 Claude,但其实我写这篇文章的真正动机是:我发现人类工程师(包括我自己)也会犯同样的错误。因此,我想记录下我通常用来修复 bug 的流程。
为什么 Claude 会一再出错?
因为 它没有一个可复现的案例(repro)。
第 1 步:找到一个可复现案例
一个 repro(即可复现案例)是一组操作步骤,当你执行这些步骤时,就能可靠地判断 bug 是否仍然存在。它就是“测试”。一个 repro 会说明:要做什么、预期会发生什么,以及实际发生了什么。
从我的角度来看,我已经有了一个很好的 repro:
- 点击按钮。
- 预期行为是向下滚动,但实际行为是滚动抖动。
更妙的是,这个 bug 每次都会发生。
如果我的 repro 不可靠(例如只在 30% 的尝试中出现),我就必须逐步排除各种不确定性来源(例如录制网络请求并在后续尝试中模拟它),或者接受效率损失——因为每个潜在修复都需要测试更多次。但幸运的是,我的 repro 是可靠的。
然而,对 Claude 来说,我的 repro 基本上并不存在。
问题在于,我 repro 中所说的“滚动抖动”对 Claude 毫无意义。Claude 没有眼睛,也无法直接感知这种抖动。因此,Claude 本质上是在没有 repro 的情况下工作——它试图修复 bug,却没有做任何具体验证。这种情况其实很常见,即使是我们中最优秀的人也会犯。
在这个案例中,Claude 无法精确遵循我的 repro,因为它不能“看”屏幕(拍几张截图也无法捕捉到抖动)。因此,如果我想让 Claude 修复它,我最初的 repro 并不合适。这看起来像是 Claude 的问题,但实际上在与他人协作时也很常见——有时 bug 只在某台机器上出现,或只对某个特定用户、特定设置生效。
幸运的是,有个技巧:只要你能说服自己新方法有助于推进原始问题的解决,你就可以用一个 repro 换取另一个 repro。
下面介绍如何更换你的 repro,以及需要注意的事项。
第 2 步:缩小可复现案例
更换你正在使用的 repro 总是有风险的。风险在于,新的 repro 可能与原始 bug 完全无关,解决它只是在浪费时间。
但有时更换 repro 是不可避免的(Claude 无法看我的屏幕,所以我必须另想办法)。而且有时这样做对迭代大有裨益(例如,一个耗时十秒的 repro 远比一个耗时十分钟的 repro 有价值得多)。因此,学会更换 repro 是很重要的。
理想情况下,你应该用一个更简单、更窄、更直接的 repro 来替代原来的。
我向 Claude 提出的思路如下:
- 测量文档的滚动位置。
- 点击按钮。
- 再次测量文档的滚动位置。
- 预期行为是滚动位置发生变化(存在差值),实际行为是没有变化。
我的想法是,这大致等价于我亲眼所见的问题。虽然这个 repro 无法捕捉“抖动”,但滚动失败很可能与之相关。即使这不是唯一的问题,单独修复它也是值得的。
Claude 添加了一些 console.log,通过 Playwright MCP 打开页面并点击按钮。果然,尽管点击了按钮,滚动位置并未改变。
好了,现在 Claude 能够验证 bug 确实存在了!
我们是否已经完成了寻找 repro 的工作?
其实还没有!
缩小 repro 时一个常见的陷阱是:你以为找到了一个好的 repro,但实际上新 repro 捕捉的是一个表现相似但完全无关的问题。这是一个代价极高的错误,因为你可能会花数小时去解决一个与原始问题无关的问题。
例如,Claude 可能只是过早地读取了滚动位置,即使 bug 已被修复,它仍会“看到”位置未变。这极具误导性——即使修复正确,测试仍会显示 bug 依旧存在,Claude 就会错过正确的修复!人类工程师也会犯这种错误。
因此,每当你缩小一个 repro 时,还应确认:使用这个新 repro 仍然能够获得一个正面结果(“一切正常”)。
用一个例子更容易说明这一点。
我让 Claude 注释掉网络调用(最初引发 bug 的代码)。如果新的 repro(“测量滚动位置 → 点击按钮 → 再次测量滚动位置”)确实捕捉到了我想修复的 bug(“点击时滚动抖动”),那么一个我已经验证过能修复该 bug 的改动(注释掉 action 调用)也应该能修复新 repro 中的行为(滚动位置现在应该会变化)。
事实正是如此!临时注释掉网络调用后,Claude 执行的测试也得到了修复——滚动位置确实发生了变化。
此时,值得多次来回修改代码(注释/取消注释),以验证每次修改是否都能预测新 repro 的结果。(也可以尝试其他修改,以排除“每隔一次修改就生效”的巧合。)
滚动测量结果似乎仍与那个网络调用相关。
但这仍不意味着新 repro 代表了同一个问题。它可能完全无关,或者只是其中一部分。更换 repro 始终存在风险。不过,一个网络调用与滚动位置相关,至少和原始问题(滚动抖动)一样可疑,本身就值得解决。这已足够成为继续使用新 repro 的理由。
第 3 步:移除其他一切内容
我新建了一个分支,并要求 Claude 遵循以下工作流程:
- 运行 repro,确认 bug 存在。
- 从相关代码中移除某些内容(移除组件、事件处理器、简化条件、删除样式、移除导入等)。
- 再次运行 repro,确认 bug 是否仍然存在。
- 如果 bug 仍然存在,提交更改。
- 如果 bug 不存在了,记下可能“修复”它的理论,然后重置到上一次提交,尝试删除更小的部分。
我正要离开,于是告诉 Claude 继续工作,不要停,直到将 repro 缩小到无法再简化的程度——一个不含额外库(甚至不包含 React Router)且逻辑极简的 React 组件。
等我回来时,Claude 为我创建了几个复现案例,但令人沮丧的是,它们都没有复现出这个 bug:
⏺ 我进行了大量调查,创建了三个逐步更精确的最小复现案例。
它们全都正常工作,但真实页面却失败了。我测试了:
- 纯 React 状态更新
- React Router 重新验证(revalidation)
- 组件重新挂载(remounting)
- 新的对象引用
- 同步加载器(loaders)
- 各种依赖数组
- 滚动过程中的多次渲染
我能想到的一切都已测试并排除。该 bug 在真实应用中始终可复现,
但在任何复现案例中都无法重现。这表明页面环境中存在某些
我尚未捕捉到的非常特定的因素。
临时解决方案(用 setTimeout 延迟网络调用)可靠有效,
这证实问题出在 mutation/revalidation 与滚动之间的时序上。
但我无法确定:为什么在真实应用中 React Router 的 revalidation
会破坏 scrollIntoView,而在复现案例中却不会。
我已提交所有复现案例和调查笔记,供将来参考。这是否意味着缩小 bug 并不总是有效?
不是。
这意味着 Claude 没有遵守我的指示。但有趣的是,它违反指示的方式恰恰是人类(包括我自己)常犯的错误。
Claude 在简化代码时,开始形成理论:也许这个 effect 有 bug,也许和重新挂载有关,也许 React 做了什么奇怪的事。于是它开始测试这些理论,创建聚焦于这些理论的隔离复现案例,并观察它们是否表现出 bug。
提出理论并加以测试是很好的!我们确实应该这样做。
但请再看一下我的指示:
- 运行 repro,确认 bug 存在。
- 从相关代码中移除某些内容(移除组件、事件处理器、简化条件、删除样式、移除导入等)。
- 再次运行 repro,确认 bug 是否仍然存在。
- 如果 bug 仍然存在,提交更改。
- 如果 bug 不存在了,记下可能“修复”它的理论,然后重置到上一次提交,尝试删除更小的部分。
我真正想让它做的是:确保在任何时候,我们都有一个 bug 仍然存在的检查点,并且每一步都在缩小该 bug 的作用范围。
Claude 过于沉迷于测试自己的理论,最终得到一堆实际上并未复现 bug 的测试案例。再次强调,测试新理论本身没错,但如果失败了,正确的做法是回到原始案例(那个仍然复现 bug 的案例!),继续移除内容,直到找到根源。
这让我想起了良基递归(well-founded recursion)的概念。考虑下面这个本应计算 斐波那契数 的 fib(n) 函数实现:
function fib(n) {
if (n <= 1) {
return n;
} else {
return fib(n) + fib(n - 1);
}
}实际上,这个函数是有 bug 的——它会永远挂起。我不小心写了 fib(n) 而不是 fib(n - 2),于是 fib(n) 会调用 fib(n),再调用 fib(n),如此往复。由于 n 从未“变小”,它永远无法退出递归。
支持良基递归的语言不会允许我犯这种错误。例如,在 Lean 中,这会是一个类型错误:
def fib (n : Nat) : Nat := /- error: fail to show termination for fib -/
if n ≤ 1 then
n
else
fib n + fib (n - 2)Lean 知道 n “没有变小”(详见此处),因此知道这个递归会永远挂起。它不会“随着时间推移而接近终止”。
这并非 Lean 教程,希望你能原谅这个轻率的比喻。
我认为,缩小 repro 案例的过程与此类似。你必须确保自己始终、始终在取得渐进式进展,且 repro 不断变小。这意味着你必须保持纪律性,一点一点地移除内容,只有在 bug 仍然存在时才提交更改。最终,你会耗尽所有可移除的内容,这时要么暴露出你代码中的错误,要么暴露出那些无法进一步简化的部分(如 React)中的问题。
不断重复,直到找到根源。
第 4 步:找到根本原因
Claude 最终没能解决这个问题,但它让我非常接近真相。
在我要求它真正遵守我的指示、只做删除操作后,它删减了足够多的代码,将问题缩小到了单个文件。我把这个文件移出路由器,突然同样的代码就能正常工作了。再把它移回路由器,问题又出现了。接着我将它设为顶层路由,它又能正常工作了。
问题出在它被嵌套在根布局(root layout)内部时。
我的根布局如下所示:
import { Outlet, ScrollRestoration } from "react-router-dom";
export function RootLayout() {
return (
<div>
<ScrollRestoration />
<Outlet />
</div>
);
}啊哈!原来,曾经存在一个 bug(已在六月修复),导致 React Router 的 ScrollRestoration 在每次重新验证(revalidation)时都会激活,而不是仅在路由变更时激活。由于我的网络调用(通过 action)触发了路由重新验证,它在 scrollIntoView 执行期间触发了 ScrollRestoration,从而导致了抖动。
这种工作流程——一边确保 bug 仍然存在,一边逐个移除内容——曾多次救我于水火。(我曾花一周时间删除 Facebook React 树的一半来追踪一个 bug,最终的 repro 只有约 50 行代码。)在穷尽所有理论之后,我不知道还有哪种方法比这更有效。
如果我自己搭建项目,我会使用最新版的 React Router,也就不会遇到这个 bug。但这个项目是由 Claude 搭建的,它不知为何决定让我使用一个核心依赖的旧版本。
唉,算了!
这就是“氛围编程”的乐趣所在。