教程Claude Code系列第 3 / 6 篇

用 Claude Code 做重构和迁移:先在 plan 模式下出计划,再一步一个提交地改

重构怕的是改着改着范围失控、改坏了又退不回去。本站在 Express 的源码上实测了一次 var 到 const/let 的迁移:plan 模式下它查出 222 处声明和两处不能直接替换的地方,计划里有一处和源码不符被我们核对出来;执行时一步一个提交。还验证了一点:用命令批量改的文件,回退菜单退不回去,要靠 git。

发布 · 约 11 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-12。

本页目录(9 节)
  1. 什么时候值得先出计划
  2. 第一步:用 plan 模式启动,让它先调查
  3. 第二步:看计划,并且核对它
  4. 第三步:一步一个提交地执行
  5. 改坏了怎么退:这次回退菜单帮不上忙
  6. 文件更多的时候
  7. 这一趟花了多少
  8. 这次实测没有覆盖到的
  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,那样回退菜单就能用。不要假设它每次都用同一种方式。

下一步

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章