《TypeScript高级编程》6.3 重构工具与 codemod

本节讲批量改写 TypeScript 代码的完整方法论。先对比三类手段:语言服务的安全重命名、Compiler API 与 ts-morph 的结构化改写、文本替换的取舍;再用 ts-morph 写出一个可批量运行、幂等的 codemod,并处理打印格式、dry-run、回滚与验证。读完本节,你能把一次跨上百个文件的重构做成可重复执行、可审阅、可回滚的工程流程。

本节目标:建立「批量改写代码」的工程方法论——知道什么时候该用语言服务、什么时候该用 AST 工具、什么时候文本替换就够;能用 ts-morph 写出一段可批量运行的 codemod;并掌握 dry-run、格式统一、幂等性与验证这几道护栏,让一次跨仓库重构不至于变成事故。

6.3 重构工具与 codemod

前两节都在「读」代码:TS Server 回答查询,插件增强提示。这一节开始「写」代码——由程序自动改写源码。典型需求包括:把 enum 全面换成 as const 对象、把 moment 调用批量迁移到 dayjs、把某个 API 的参数顺序调整、给几百个文件统一加上 export type。

这类工具通常叫 codemod。它的价值不在「能改」,而在「改得可重复、可审阅、可回滚」——手改 300 个文件没人能审完,codemod 的 diff 却能按规则推断。如果你正从 JavaScript 迁移,JavaScript 到 TypeScript 迁移 里有更贴近迁移场景的流程建议。

6.3.1 三类改写手段与它们的边界

手段是否懂语义批量能力典型工具适用场景
语言服务重命名完全懂(走符号表)单次、跨文件LanguageService.findRenameLocations改名、改引用,要求零误伤
结构化 AST 改写懂语法,可接入类型强,可遍历全项目ts-morph、Compiler API模式化重构、需要类型判断
文本/正则替换不懂最强最快sed、ast-grep极简单、范围可控、有测试兜底

选择顺序是:能用重命名就不写 AST,能写 AST 就别用正则。因为越往右,误伤成本越高——正则改错一个字符串字面量,可能三个月后才在线上暴露。

这里要区分两个概念:重命名(rename)是语言服务的既有能力,安全性由符号表保证;codemod 是我们自己写的程序,安全性由我们自己保证。本节大部分篇幅都在讲后者的护栏。

6.3.2 用 LanguageService 做安全重命名

重命名不必依赖编辑器。LanguageService 直接暴露了 findRenameLocations,返回的就是一份「待改区间清单」:

import * as ts from "typescript";

const config: ts.CompilerOptions = { target: ts.ScriptTarget.ES2022 };

const host: ts.LanguageServiceHost = {
  getScriptFileNames: () => ["/work/src/index.ts", "/work/src/util.ts"],
  getScriptVersion: () => "1",
  getScriptSnapshot: (fileName) => {
    const text = ts.sys.readFile(fileName);
    return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
  },
  getCurrentDirectory: () => "/work",
  getCompilationSettings: () => config,
  getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
  fileExists: ts.sys.fileExists,
  readFile: ts.sys.readFile,
  readDirectory: ts.sys.readDirectory,
  directoryExists: ts.sys.directoryExists,
  getDirectories: ts.sys.getDirectories,
};

const service = ts.createLanguageService(host);
const file = "/work/src/index.ts";
const program = service.getProgram()!;
const source = program.getSourceFile(file)!;

const position = source.getPositionOfLineAndCharacter(2, 13);
const spans = service.findRenameLocations(file, position, false, false, false) ?? [];

for (const span of spans) {
  const sf = program.getSourceFile(span.fileName)!;
  const old = sf.text.slice(span.textSpan.start, span.textSpan.start + span.textSpan.length);
  console.log(`${span.fileName}:${old} -> newName`);
}

关键在第三个参数 findInStrings 与第四个 findInComments。默认关闭意味着字符串与注释里的同名文本不会被改:

const key = "getUserInfo"; // 字符串,不会被改
// getUserInfo 是老名字     // 注释,不会被改

这是保守且正确的默认值,但也会漏掉「字符串里其实是引用」的情况(比如 DI 容器的 token 名)。这时把 findInStrings 打开,代价是可能误伤无关字符串,必须人工复核 diff。没有免费的午餐。

6.3.3 ts-morph:把批量改写写成声明式

直接操作 Compiler API 的 TextChangeRange 很繁琐。ts-morph 把 Compiler API 包了一层更顺手的对象模型,写 codemod 时基本是默认选择:

import { Project } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });

const fn = project.getSourceFileOrThrow("src/index.ts").getFunctionOrThrow("greet");

fn.rename("sayHello");                 // 语义重命名,跨文件生效
fn.setIsExported(true);                // 补上 export
fn.getParameterOrThrow("name").setType("string");
fn.addParameter({ name: "loud", type: "boolean", initializer: "false" });

project.saveSync();                    // 只写「脏」文件,没改的一个字节都不动

saveSync() 只落盘被改动过的文件,这一条就解决了「diff 里混进 200 个无关文件」的常见灾难。Project 构造时传入 tsConfigFilePath 后,路径解析、paths 别名、lib 版本全部与项目一致——这正是 ts-morph 优于裸写 AST 的地方。

对比一下:同样的「加一个参数」,在 AST 与代码生成 里我们要手写 printer 与节点工厂;ts-morph 把这些都隐藏了。代价是它对 Compiler API 的封装有版本耦合,升级 ts-morph 要跟着看它支持的 TS 版本区间。

改之前通常需要先「侦察」:把改动面列出来,才知道这次重构该不该自动化。ts-morph 的 findReferences 直接暴露了符号引用清单:

const refs = fn.findReferences();

for (const ref of refs) {
  for (const entry of ref.getReferences()) {
    const node = entry.getNode();
    console.log(`${node.getSourceFile().getFilePath()}:${node.getStartLineNumber()}`);
  }
}

真实输出(一个被 4 个文件引用的函数):

/work/src/index.ts:12
/work/src/api/handler.ts:34
/work/src/api/handler.ts:51
/work/src/cli.ts:8

注意 findReferences 返回的是引用而非定义位置,且它只认符号表能追踪到的引用——通过字符串动态调用的地方不在其中。侦察阶段就要把这类「隐形引用」列进人工检查清单。

6.3.4 一次真实 codemod:把 enum 换成 as const

enum 因为运行时对象、反向映射、与 isolatedModules 的冲突,近年被很多团队淘汰。下面这段把它整体迁移为 as const 对象加联合类型:

import { Project } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });

for (const source of project.getSourceFiles()) {
  for (const en of source.getEnums()) {
    const name = en.getName();

    const members = en.getMembers().map((member) => {
      const key = member.getName();
      const value = member.getInitializer()?.getText() ?? JSON.stringify(key);
      return `  ${key}: ${value},`;
    });

    const literal = [
      `const ${name} = {`,
      ...members,
      `} as const;`,
      `type ${name} = (typeof ${name})[keyof typeof ${name}];`,
    ].join("\n");

    en.replaceWithText(literal);
  }
}

project.saveSync();

输入:

enum Color {
  Red = "red",
  Green = "green",
}

function paint(c: Color): string {
  return c;
}

输出:

const Color = {
  Red: "red",
  Green: "green",
} as const;
type Color = (typeof Color)[keyof typeof Color];

function paint(c: Color): string {
  return c;
}

为什么要在同一个名字上同时产出值和类型? 因为 TypeScript 允许值空间与类型空间重名:Color 作为值是那个对象,作为类型是联合 "red" | "green"。于是原来写 c: Color 的地方一行都不用改,这就是这次迁移能「零改动调用方」的原因。

但它不是无条件安全的。三类情况必须人工复核:

  1. 反向映射。数字 enum 编译后会生成 Color[0] === "Red" 这类反向映射,as const 对象没有。代码里凡有 Color[0] 或 Object.keys(Color) 的地方都会行为变化。
  2. const enum。它被内联成字面量、运行时根本不存在;迁移后多出一个运行时对象,体积与行为都变。
  3. 成员值是复杂表达式。as const 要求值是可静态推断的字面量,遇到函数调用会报 TS1355 之类的错误。

所以正确的做法不是「一把梭跑完全仓库」,而是先在小范围跑、看 diff、跑测试,再放量。

6.3.5 打印与格式:别让 diff 淹没改动

ts-morph 默认用 TypeScript 自带的 printer 输出,缩进、引号、换行位置可能与项目里的 Prettier 配置不同。结果就是:你只改了一行,diff 却是整个文件——审阅价值归零。

三种处理方式,按推荐度排列:

# 1) 跑完后统一格式,让「格式差异」与「语义差异」分开成两次提交
npx prettier --write "src/**/*.ts"

# 2) 先 dry-run 看会改哪些文件,确认改动面符合预期
npx tsx codemod.ts --dry-run

# 3) 只看统计,快速判断有没有失控
git diff --stat | tail -3

更讲究的做法是把格式统一拆成单独一次提交:先跑 Prettier 全仓库格式化并提交(这次 diff 很大但语义为零),再跑 codemod,此时 diff 就只剩下真正的语义改动。审阅者只需要认真看第二次提交。

在 ts-morph 里还有两个实用开关。其一是把文件系统换成内存实现,得到真正的 dry-run——所有 saveSync() 只写内存,一个字节都不落盘:

import { InMemoryFileSystemHost, Project } from "ts-morph";

const project = new Project({
  tsConfigFilePath: "tsconfig.json",
  fileSystem: new InMemoryFileSystemHost(),
});

// ……执行改写逻辑

for (const file of project.getSourceFiles()) {
  if (file.isSaved()) continue;      // 没被改过的跳过
  console.log(`--- ${file.getFilePath()}`);
  console.log(file.getFullText());
}

其二是 forgetNodesCreatedInBlock,能在遍历大仓库时及时释放节点,避免内存爆掉。

6.3.6 幂等、可回滚、可验证

一段合格的 codemod 必须满足三条:

性质含义如何保证
幂等跑第二遍产生零 diff用「替换」而不是「追加」;跑完再跑一次做断言
可回滚出错能一键还原在干净的 git 工作区里跑,失败即 git checkout -- .
可验证改完类型与测试都过tsc --noEmit + 测试套件 + 人工抽样 diff

把这三条写成脚本护栏,codemod 就从「一次性脚本」升级成「可重复的工程流程」:

# 前置:工作区必须干净,否则回滚会把你的未提交改动一起冲掉
test -z "$(git status --porcelain)" || { echo "工作区不干净,先提交"; exit 1; }

npx tsx codemod.ts
npx tsc --noEmit                       # 类型必须仍然通过
npx tsx codemod.ts                     # 幂等断言:这次应当零 diff
test -z "$(git status --porcelain)" || { echo "不幂等,请检查"; exit 1; }

第二条 tsc --noEmit 是整条流程里最重要的一道闸。codemod 改的是语法结构,编译器是唯一能廉价告诉你「语义是否被破坏」的工具。如果你正在同步做严格化,请把它和 渐进式迁移与严格化路径 里「按目录、按规则分批」的节奏结合起来——一次只放开一类改动,出问题才定位得到。

6.3.7 工具选型:ts-morph、jscodeshift 与 ast-grep

工具解析器类型信息保留原格式适合
ts-morphTypeScript Compiler有否(需配 Prettier)TS 项目、需要类型判断
jscodeshiftBabel + recast无(除非另配)是JS 生态、格式敏感、Meta 系 codemod
ast-greptree-sitter无是结构化搜索替换、CLI 友好、多语言
sed / 正则无无是范围极小、有测试兜底

两条经验法则:

  • 需要类型信息就用 ts-morph。比如「只改类型为 Moment 的变量」这种规则,没有 TypeChecker 根本写不出来。
  • 只需要结构匹配就用 ast-grep。它的模式串形如 $A && $A,能表达「同一个表达式重复两次」这类正则写不出来的模式,而且不依赖 Node 项目环境。

6.3.8 常见坑

现象根因处理
diff 里整个文件都变了printer 与 Prettier 格式不一致先单独提交一次全仓格式化
重命名漏掉字符串里的引用findInStrings=false按需打开并人工复核
codemod 跑完 tsc 报一堆错没做语义等价验证必须加 tsc --noEmit 闸门
大仓库跑一半 OOMProject 持有全部 SourceFile按目录分批,或用 forgetNodesCreatedInBlock
第二次跑又产生 diff逻辑不幂等用替换而非追加,跑两遍做断言
生成文件被一起改了没有排除 dist、*.d.ts、node_modules在 tsconfig 的 exclude 里先排掉

还有一条容易被忽略:codemod 也应当进代码评审。脚本本身是代码,它的规则错了会批量错,比手改一次错得更彻底。把 codemod 连同它的测试一起提交,下次别人要改规则时才有据可依。

到这里,第 6 章「语言服务与编辑器」就收束了:6.1 讲了 TS Server 的协议与 Project,6.2 讲了怎么把逻辑挂进语言服务,6.3 讲了怎么反过来批量改写代码。三者共同的底座都是 Compiler API——读、增强、写,构成了工具链开发者的完整闭环。

小结

  • 改写手段按「懂不懂语义」排序:语言服务重命名 > AST 结构化改写 > 文本替换;能用左边的就别用右边的。
  • findRenameLocations 默认不动字符串与注释,这是保守而正确的默认值;打开 findInStrings 必须人工复核。
  • ts-morph 把 Compiler API 包成对象模型,saveSync() 只写脏文件,是写 codemod 的默认选择。
  • enum → as const 的迁移能零改动调用方,是因为值空间与类型空间可以同名;但反向映射、const enum、复杂成员值三类场景必须复核。
  • 格式统一要拆成独立提交,否则 printer 差异会把语义 diff 淹没。
  • 幂等、可回滚、可验证是 codemod 的三条底线;其中 tsc --noEmit 是最廉价也最重要的那道闸。

下一章我们离开工具链,回到产物本身:如何生成 .d.ts、如何用 exports 映射把它正确暴露给使用者。

阅读导航:上一节:6.2 自定义 tsPlugin · 下一节:7.1 .d.ts 生成与 exports 映射 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes