用 Claude Code 做重构和迁移:先在 plan 模式下出计划,再一步一个提交地改
重构怕的是改着改着范围失控、改坏了又退不回去。本站在 Express 的源码上实测了一次 var 到 const/let 的迁移:plan 模式下它查出 222 处声明和两处不能直接替换的地方,计划里有一处和源码不符被我们核对出来;执行时一步一个提交。还验证了一点:用命令批量改的文件,回退菜单退不回去,要靠 git。
本站已实测,测试日期:2026-10-12。
本页目录(9 节)
这篇教程解决一个问题:一处改动要动很多文件,对外的行为又不能变,怎么让 Claude Code 做得可控。
修 bug 可以直接让它改,改完跑测试就知道对不对。重构和迁移不一样:改动多、分散,容易出现三种情况:改着改着顺手动了不该动的地方;有几处特殊情况照着通用的办法改会出错;发现不对的时候已经改了十几个文件,退不干净。
对付这三种情况的办法是同一个:先让它只调查、出计划,你看过计划再动手,动手时一步只改一处、每步一个提交。
本站拿一个典型的迁移做了实测:把 Express 的 lib/ 目录从 var 迁到 const 和 let。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-12 |
| 版本 | Claude Code 2.1.296 |
| 系统 | Ubuntu 24.04 |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例仓库 | Express 的源码,v5.3.0,带一份 CLAUDE.md。迁移前 npm test 是 1262 个用例通过,npm run lint 没有报错 |
什么时候值得先出计划
官方的最佳实践里有一条很实用的判断:如果你能用一句话说清楚这次的 diff,就不用计划,直接让它改。 改个错字、加一行日志、给一个变量改名,都属于这一类。
计划值得做的情况是:不确定该怎么做、改动跨多个文件、或者你对要改的代码不熟。这次的迁移占了第二条:6 个文件,两百多处。
1用 plan 模式启动,让它先调查
plan 模式下它可以读文件、跑命令去了解情况、写计划,但不改你的源文件。启动时指定:
claude --permission-mode plan -n var-to-const
已经在会话里的话,按 Shift+Tab 切到状态栏显示 ⏸ plan mode on。只想让某一句话走计划,在前面加 /plan。
提的要求里写清楚三件事:要它查什么、不能直接改的情况有哪几类、计划要包含什么。
我想把 lib/ 下的代码从 var 迁到 const 和 let,对外的行为不能变。先不要改,调查清楚三件事:
1)一共多少处 var,分布在哪些文件;
2)哪些地方不能直接替换,比如同一个函数里重复声明、依赖变量提升、在块外面用到块里面声明的变量,
每一类给出文件和行号;
3)怎么验证没改坏。
然后给我一个分步的计划:每一步只改一个文件,写明怎么验证、出了问题怎么退回去。
第二条是这类任务的关键。迁移里真正会出问题的是那几处不能照通用办法改的地方,要让它先把这些找出来。
它在 plan 模式下做了什么
它没有靠读代码去数,而是用项目里已有的 ESLint,临时加上 no-var 之类的规则扫了一遍,并把“自动修复后的样子”输出到 /tmp 下的一份副本里,再对比差异:
● lib/下222处var声明中220处可安全自动修复为let/const,仅utils.js中colonIndex的两处
因重复声明加块外使用需手动处理。接下来检查switch/case、try/catch等边界情况,
再跑测试验证修复结果。
这里有一点值得知道:plan 模式不改源文件,但不是什么都不写。 它跑的命令在 /tmp 下建了目录、写了文件。按官方文档,plan 模式下的命令在能用 auto 模式的会话里由分类器审查后放行,这次多数命令后面都标着 Allowed by auto mode classifier。仓库本身在整个调查过程中没有被改动,git status 是干净的。
它会反过来问你
调查完,它没有直接给计划,而是先问了两个问题:
← ☐ 测试范围 ☐ 提交前缀 ✔ Submit →
│ 项目约定改完 lib/ 只跑「对应的那一个测试文件」,但 request/response/application 各自
对应二三十个测试文件(如 test/res.*.js)。验证范围怎么定?
❯ 1. 按通配跑一组 (Recommended)
每步跑该文件对应的一组测试,如 test/res.*.js;不跑全量 npm test
2. 每步一组 + 最后跑一次全量
6 步都完成后额外跑一次 npm test,覆盖 acceptance 测试
3. 严格只跑一个文件
每步只挑一个最相关的测试文件,覆盖面较小
4. Type something.
第二个问题是提交说明用什么前缀:项目约定只有 fix:、docs:、test: 三种,重构哪个都不贴切。
这两条约定都写在仓库的 CLAUDE.md 里。它读到了,发现这次的任务和约定对不上,就停下来问,而不是自己挑一个。我们选了“每步一组,最后跑一次全量”和“破例用 refactor:”。
2看计划,并且核对它
计划写在 ~/.claude/plans/ 下的一个 Markdown 文件里,同时显示在屏幕上。78 行,主要内容是这些。
数量和分布:
| 文件 | var 声明 | → const | → let | 自动修复拒绝 |
|---|---|---|---|---|
| lib/express.js | 8 | 8 | 0 | 0 |
| lib/utils.js | 20 | 15 | 3 | 2 |
| lib/view.js | 26 | 19 | 7 | 0 |
| lib/request.js | 36 | 33 | 3 | 0 |
| lib/application.js | 42 | 33 | 9 | 0 |
| lib/response.js | 90 | 65 | 25 | 0 |
不能直接替换的地方,它逐类查了一遍。真正有问题的是 lib/utils.js 里的一处:
var colonIndex = str.indexOf(';'); // 91 行,函数顶层
var index = colonIndex === -1 ? length : colonIndex;
...
var colonIndex = str.indexOf(';', index); // 99 行,while 循环里又声明了一次
同一个变量在函数里用 var 声明了两次。var 允许这样写,两次指的是同一个变量。直接都换成 let,循环里那个就成了一个新的变量。它给的改法是 91 行改成 let,99 行去掉声明、只留赋值,这样仍然是同一个变量。
其他几类(声明前使用、循环里的闭包、try/catch 跨块使用)它都写了“未发现”,并注明是用哪条规则查的。
每一步的验证有四项:npm run lint、用 node -e "require('./')" 确认能加载、跑这个文件对应的那组测试、看 git diff -w 里除了替换没有别的改动。退回的办法也写了:没提交的用 git checkout,提交了的用 git revert。
我们核对出了一处错
计划写得再像样也要抽查。我们挑了它列的几处特例,对着源码看。colonIndex 那处属实。另一处不对:
计划里说 lib/response.js 第 138 行是 var type,改成 const 之后要给它所在的 case 加一对花括号。但源码里这一行本来就是:
case 'string':
encoding = 'utf8';
const type = this.get('Content-Type');
它已经是 const 了,不属于这次迁移。照计划做,就会改一处和 var 无关的代码,正是重构里最该避免的“顺手多改”。
把意见交回去
计划下面是批准的对话框:
Claude has written up a plan and is ready to execute. Would you like to proceed?
❯ 1. Yes, and use auto mode
2. Yes, manually approve edits
3. Tell Claude what to change
shift+tab to approve with this feedback
ctrl+g to edit in Vim · ~/.claude/plans/lib-var-giggly-feigenbaum.md
选第 3 项,直接在这一行里打字:
两处要改。一,第 6 步说 lib/response.js:138 是 var type,我看到的源码里它已经是 const 了,
这一处不属于这次迁移,不要给那个 case 加花括号。二,这次只做第 1 步和第 2 步,
每步做完停下来等我看,第 7 步不做。
回车之后它留在 plan 模式里,把计划改了:那一条特例删掉,范围缩到前两步,每步后面加了“停,等用户审阅”。之后它也说明了错在哪:
你指出的 lib/response.js:138 确实是我搞错了:源码里那里本来就是 const type,
我当时看的是自动修复试运行后的结果。计划里已经删掉了这一条。
另外两个改计划的办法:按 Ctrl+G 在编辑器里直接改计划文件;或者输入 /plan 预览。这两个我们没有试。
官方文档里第三个选项写的是“No, keep planning”,这个版本的界面上是“Tell Claude what to change”,作用相同。
批准的时候选哪个
- Yes, and use auto mode:批准,之后的改动和命令不再逐个问你。
- Yes, manually approve edits:批准,但每一处修改都要你点头。
这次我们选了第一个。理由是:这种迁移的把关不在“逐处看它改了什么”,而在每一步的测试、lint 和 diff,以及每步一个提交。两百多处替换逐个点确认,人很快就不看了。
如果是改业务逻辑的重构,每一处都值得看,选第二个。
3一步一个提交地执行
批准后它开始做第 1 步,lib/express.js,8 处全是 const。做完停下来汇报:
● 第 1 步(lib/express.js)已经改完并提交,现在停下来等你看。
改动:一共 8 处 var 改成了 const,其中 7 处是模块顶部的 require,1 处是
createApplication 里的 app。这些变量都没有被重新赋值。用 git diff -w 看,没有其他改动。
验证结果:
- npm run lint 没有报错
- 更严格的临时检查(no-var、prefer-const、block-scoped-var、no-redeclare)也没有报错
- node -e "require('./')" 能正常加载
- test/exports.js test/express.*.js 共 313 个用例全部通过
提交:a0d7112 refactor: use const/let instead of var in lib/express.js
退回方法:执行 git revert a0d7112 即可。这个提交只改了这一个文件。
我们自己看了一眼这个提交,确认它说的属实:
a0d7112 refactor: use const/let instead of var in lib/express.js
lib/express.js | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
回一句“第 1 步没问题,做第 2 步”,它接着做 lib/utils.js。18 处是 ESLint 自动改的,colonIndex 那两处是按计划手工处理的。提交里对应的 diff:
- var colonIndex = str.indexOf(';');
- var index = colonIndex === -1 ? length : colonIndex;
+ let colonIndex = str.indexOf(';');
+ let index = colonIndex === -1 ? length : colonIndex;
...
- var colonIndex = str.indexOf(';', index);
- var endIndex = colonIndex === -1 ? length : colonIndex;
+ colonIndex = str.indexOf(';', index);
+ const endIndex = colonIndex === -1 ? length : colonIndex;
两步做完,我们在会话外面自己跑了一遍全量:npm run lint 没有报错,npm test 仍然是 1262 个用例通过。
改坏了怎么退:这次回退菜单帮不上忙
权限模式那一篇讲过,输入框为空时连按两次 Esc 能打开回退菜单,把代码和对话退回到之前的某个时刻。我们想在这里用它演示“放弃一步”,于是让它做第 3 步但先不提交,然后打开菜单:
Rewind
我想把 lib/ 下的代码从 var 迁到 const 和 let,对外的行为不能变。先不要改…
lib-var-giggly-feigenbaum.md +89 -11
第 1 步没问题,做第 2 步。
No code changes
接着做第 3 步 lib/view.js,照计划改完并验证,但这一步先不要提交,停下来等我。
No code changes
第 2 步和第 3 步明明改了文件,这里写的却是 No code changes。选中第 3 步那一条回车:
The conversation will be forked.
The code will be unchanged.
❯ 1. Restore conversation
2. Summarize from here
3. Summarize up to here
4. Never mind
没有“恢复代码”的选项。
原因在它改文件的方式上。官方文档写明,检查点只记录通过 Claude 的文件编辑工具做的修改,Bash 命令改的文件不记录。这次的替换是它用 eslint --fix 这条命令做的,所以回退菜单看不到这些改动。菜单里唯一有记录的是那份计划文件,因为那是它用编辑工具写的。
这不是这次迁移特有的情况。批量的重构经常是用命令完成的:eslint --fix、sed、codemod 脚本、格式化工具。只要它是用命令改的,回退菜单就退不了代码。
所以真正的退路是 git,这也是计划里要求每步一个提交的原因。两种情况我们都实际走了一遍。
没提交的那一步,直接丢掉:
git checkout -- lib/view.js
已经提交的某一步,单独退掉。我们退了第 2 步,然后确认第 1 步不受影响:
git revert --no-edit d325452
b025dd1 Revert "refactor: use const/let instead of var in lib/utils.js"
d325452 refactor: use const/let instead of var in lib/utils.js
a0d7112 refactor: use const/let instead of var in lib/express.js
退完之后 lib/utils.js 回到 20 处 var,lib/express.js 仍然是 0 处,相关的测试通过。每步只动一个文件、只有一个提交,退哪一步都不牵连别的。
开始之前还有一件该做的事:先确认工作区是干净的,最好单独开一个分支。这次是在 refactor/var-to-const 分支上做的。
文件更多的时候
这次只有 6 个文件,一个会话里按顺序做就行。到了几百上千个文件,官方文档给了两条路,我们都没有实测:
/batch <要求>:让 Claude 把改动拆给 5 到 30 个子代理,各自在自己的 worktree 里做。- 自己写循环调
claude -p:先让它把要迁移的文件列成清单存进文件,再用脚本逐个调用,用--allowedTools事先批准迁移要用的工具,用--permission-mode dontAsk拒绝其他一切。文档特别提醒:先拿两三个文件试,按出的问题改提示词,再跑全部。
第二条的写法见把 Claude Code 接进脚本和 CI。
这一趟花了多少
整个会话 /cost 显示 0.92 美元,模型工作 3 分 49 秒。这是 Claude Code 按官方价格估算的数字,不是中转站实际扣的金额。
/cost 里“代码改动”一项只算了计划文件的 89 行,三个源文件的改动没有算进去,原因和回退菜单一样:它们是命令改的。
这次实测没有覆盖到的
- 只做了计划里的前两步,第 3 步做了又丢掉了,用来演示回退。剩下的四个文件没有做,所以“全部做完后全量测试仍然通过”没有验证。
- 没有制造一次真的改坏。 两步都一次通过,测试失败后它怎么定位和修,这次没有看到。
Ctrl+G编辑计划、/plan预览、shift+tab带意见直接批准没有试。/batch和claude -p的循环没有试,照官方文档写的。- 用编辑工具改的情况下回退菜单能恢复代码,这一点在权限模式那篇里实测过,这次没有重复。
- 只跑了一次。 换一次运行,它可能选择用编辑工具逐处改而不是跑
eslint --fix,那样回退菜单就能用。不要假设它每次都用同一种方式。
下一步
- 系列的下一篇:提交之前让 Claude Code 先审一遍。重构完的分支,正好拿去审。
- 重构之前先把测试补上:给老代码补测试。
- 几种权限模式的区别和回退菜单的完整用法:权限模式怎么选。