ts-morph:基于 TypeScript Project 的类型感知重构
类型身份出现时,文件级 AST 已经不够
重命名导出符号、追踪别名、判断类型或跨文件更新引用,需要 TypeScript 项目图而不是孤立语法树。ts-morph 在 Compiler API 之上提供 Project、SourceFile、Node、Type 与 Symbol 对象,并把多文件修改缓存在内存中统一保存。
代价也很明确:tsconfig、模块解析、依赖和内存容量都会改变结果。加载不完整时的“零引用”不能作为删除依据,逐文件保存又可能把仓库留在半迁移状态。
因此 ts-morph 的运行单元应是一个经过验证的 Project,而不是文件循环。先完成诊断与候选统计,再缓冲修改、统一保存,最后用项目自身的类型检查和测试决定是否保留工作树。
先按所需证据选择执行引擎
三种常见入口都能遍历语法树,却解决不同层次的问题。jscodeshift 提供多进程 runner、基于 Recast 的集合 API、parser 选择、dry run、统计和 fixture 工具,适合“单文件结构足以判定”的 JavaScript/TypeScript 改写。它努力保留未触碰代码的原始风格,但不会因为选了 ts 或 tsx parser 就自动拥有完整项目类型信息。
TypeScript Compiler API 直接暴露 Program、SourceFile、TypeChecker、factory、transformer 和 printer。需要精确控制编译选项、模块解析、symbol 身份或生成过程,而且团队愿意承担较底层节点操作与版本兼容成本时,直接使用它最透明。Compiler API 的 Symbol 是类型系统中的声明身份,不是 JavaScript 运行时的 Symbol。
ts-morph 在 Compiler API 上提供项目加载、导航和修改 API,适合跨文件、需要类型信息并希望减少工厂函数样板的迁移。代价是整个 Project 和 compiler objects 会占用更多内存;修改节点后,原 wrapper 可能被遗忘,保存又是显式阶段。它不是另一套类型系统,最终可信度仍由 tsconfig.json、文件集合、模块解析和当前 TypeScript 版本决定。
| 判断问题 | 优先入口 | 典型信号 |
|---|---|---|
| 单文件语法形状足够吗 | jscodeshift | 改调用形状、导入声明、JSX 属性,fixture 能穷举变体 |
| 必须确认符号来自哪个包吗 | ts-morph 或 Compiler API | 同名函数、别名导入、重导出、重载和泛型影响匹配 |
| 要完全控制 AST 生成和打印吗 | Compiler API | 自定义 host、诊断、transformer、emit 或极致性能调优 |
| 仓库巨大且规则简单吗 | jscodeshift 分片 | 文件可独立处理,runner 并发收益大于项目图价值 |
用项目内 Node 工具链固定运行入口
先在实验分支确认仓库规定的 Node 版本与包管理器。Codemod 应作为开发依赖进入锁文件,避免某位开发者的全局安装决定 AST 形状或打印结果。版本也不能只写 latest:npm 注册表当前发行线是 jscodeshift 17.4.0、TypeScript 7.0.2 和 ts-morph 28.0.0,但 ts-morph 28.0.0 的发布说明明确以 TypeScript 6.0 为编译器基线,不能因为三个包都能安装就认定它们共享同一 AST/API 版本。
需要 ts-morph 的实验可固定在 jscodeshift 17.4.0、TypeScript 6.0.3、ts-morph 28.0.0;已经升级到 TypeScript 7 的应用,应优先直接使用该项目锁定版本的 Compiler API,或等待并验证声明支持 TypeScript 7 的 ts-morph 发行线。jscodeshift 与 ts-morph 使用 MIT 许可证,TypeScript 使用 Apache-2.0;它们允许在企业迁移工具中使用,但仍要保留许可证通知、通过依赖准入并审查传递依赖。能力与许可证分别以 jscodeshift 仓库、TypeScript 仓库 和 ts-morph 28.0.0 发布说明 为依据。
下面准备一组编译器基线一致的实验依赖:
npm install --save-dev jscodeshift@17.4.0 ts-morph@28.0.0 typescript@6.0.3 tsx @types/node
npx jscodeshift --version
npx tsc --versiontsx 只负责运行 TypeScript 脚本;生产构建仍使用项目自己的编译命令。依赖安装后检查 lockfile diff,并确认 npm 脚本不会从不受信任的网络位置下载 transform。企业仓库还应通过制品代理、lockfile 完整性和依赖准入约束这组开发工具。
建立一个只服务迁移脚本的 codemods/tsconfig.json,避免脚本的 Node 环境与浏览器应用配置互相污染:
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"noEmit": true
},
"include": ["./**/*.ts"]
}这里通过 extends 继承项目当前 ECMAScript target,只覆盖 codemod 在 Node 环境运行所需的模块解析、类型和输出选项;若脚本目录层级不同,应把相对路径改到真实项目配置。应用源码仍由仓库根部或各 package 的 tsconfig.json 建模。moduleResolution 必须匹配真正执行代码的 Node 或 bundler;paths 只改变 TypeScript 的查找,不会重写 emit 中的 import,因此类型检查通过并不能证明运行时能解析同一别名,详见 TypeScript Modules Reference。
jscodeshift --parser=tsx 只解决 TSX 语法能否被解析,不会读取 path alias 后判断符号来源;ts-morph 的 Project({ tsConfigFilePath }) 会读取该配置的 compiler options 和文件集合,并解析已加入文件的依赖。solution-style tsconfig 只有 references 而没有自己的输入文件时,不要假定一个 Project 会自动装入所有被引用工程;TypeScript 的 tsc -b 会按引用图构建,不代表任意 AST 包装器也会复制这套加载行为。应逐个 reference/workspace 的 tsconfig 建立 Project,或显式调用 addSourceFilesFromTsConfig,并把每个项目的诊断分别设为门禁。解析失败时先记录文件路径与语法类别,不要退化成正则后继续批量写入。
类型身份出现时升级到 ts-morph 或 Compiler API
当调用可能经过别名、barrel export 或重导出,仅检查局部 import 文本会漏掉真实引用,也可能改到同名包。此时从应用的 tsconfig.json 建立 Project,再用 declaration 或 symbol 确认 callee 最终来自目标模块。下面示例展示缓冲修改和集中保存的骨架:
// codemods/typed-legacy-track.ts
import { Node, Project, SyntaxKind } from "ts-morph";
const project = new Project({
tsConfigFilePath: "tsconfig.json",
skipAddingFilesFromTsConfig: false,
});
const diagnostics = project.getPreEmitDiagnostics();
if (diagnostics.length > 0) {
throw new Error(`Refusing to modify a project with ${diagnostics.length} diagnostics`);
}
let changed = 0;
for (const sourceFile of project.getSourceFiles()) {
if (sourceFile.isDeclarationFile()) continue;
const hasTelemetryBinding = sourceFile.getImportDeclarations().some((declaration) =>
declaration.getModuleSpecifierValue() === "@acme/telemetry" &&
declaration.getNamedImports().some((specifier) =>
specifier.getName() === "telemetry" && specifier.getAliasNode() == null
)
);
if (!hasTelemetryBinding) continue;
while (true) {
const call = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).find((candidate) => {
const expression = candidate.getExpression();
if (!Node.isIdentifier(expression) || candidate.getArguments().length !== 2) return false;
const symbol = expression.getSymbol();
const target = symbol?.isAlias() ? symbol.getAliasedSymbol() : symbol;
return target?.getDeclarations().some((node) =>
node.getSourceFile().getFilePath().includes("@acme/legacy-telemetry")
) ?? false;
});
if (!call) break;
const [name, payload] = call.getArguments().map((arg) => arg.getText());
call.replaceWithText(`telemetry.track({ name: ${name}, payload: ${payload} })`);
changed += 1;
}
}
console.log(JSON.stringify({ changed }));
if (process.argv.includes("--write")) await project.save();Project 内的修改先存在内存中,sourceFile.save() 只保存该文件,project.save() 会把 Project 中待处理的保存、移动、复制和删除集中落到文件系统。它降低脚本在变换中途主动保存造成的半写风险,却不是跨文件事务:磁盘空间、权限或进程中断仍可能留下部分已写文件,所以真正的原子恢复边界仍是隔离工作树和 Git 批次。dry run 不调用保存,并不代表零资源消耗:脚本仍读取整个项目、解析依赖并建立 compiler objects。示例在任何修改前拒绝已有 diagnostics,并在每次替换后重新查询下一候选,避免继续遍历可能已失效的 wrapper;真实脚本还要检查候选文件数和预期上限,任何异常都应阻断 save()。示例用声明文件路径识别演示包,实际仓库应把 pnpm/PnP、path mapping、exports 条件和源码工作区纳入 fixture,不能把一个 includes 判断冒充通用包身份算法。保存与节点失效语义可对照 ts-morph 的 Manipulating Source Files。
直接使用 Compiler API 时,可以通过 ts.findConfigFile、ts.readConfigFile 和 ts.parseJsonConfigFileContent 得到真实 root names 与 options,再用 ts.createProgram 和 program.getTypeChecker() 查询 symbol。factory 与 printer 适合构造节点,但打印整个 SourceFile 可能带来大面积格式 diff;需要最大程度保留原文时,应限定替换区域,或继续让 Recast/ts-morph 承担文本操纵。选择的关键是证据与成本,不是 API 层级越低越专业。
缓冲保存和节点遗忘决定脚本能否稳定运行
ts-morph wrapper 绑定当前 compiler tree。调用 replaceWithText、remove、set 或某些结构化修改后,被替换节点以及它的后代可能被 forgotten;继续读取旧引用会抛出“node was forgotten”一类错误。上例在替换前先把两个参数转成字符串,并在替换后重新查询下一候选,就是为了不在树被替换后继续访问旧参数或预先缓存的调用节点。
常见坏写法是先缓存整个项目的 descendant 数组,再边遍历边替换祖先节点。数组后半段可能已经属于旧树。更稳的做法是按文件、按最深层节点执行;一次替换后重新查询受影响区域;跨阶段只保存稳定身份,如文件路径、起止位置、符号名和自定义业务键,不长期持有 node wrapper。
大项目还要主动释放不再使用的树。可以在完成并保存一批文件后调用适当的 forget API,或通过 project.forgetNodesCreatedInBlock 控制临时导航产生的 wrapper;调用前必须确认后续阶段不会再访问这些节点。释放 compiler objects 会节省内存,却可能让后续类型查询重新建模。用代表性仓库测量峰值 RSS、每千文件耗时和 GC 停顿,再决定批大小。
保存也要设清晰闸门:先收集候选与诊断,再在内存完成一批修改,输出将写文件清单和摘要,最后一次性保存。不要每改一个调用就 saveSync();同步频繁落盘会放大 I/O,并让失败停在难以辨认的半写状态。若必须分批保存,则每批绑定独立 Git commit 或工作树快照,使批次可以单独撤回。
用 Project 固定项目入口
从仓库自己的 tsconfig 创建 Project,先打印未解析模块和目标符号数量,再修改节点。
import { Project } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const source = project.getSourceFileOrThrow("src/api.ts");
const target = source.getFunctionOrThrow("legacyCall");
console.log(target.findReferences().length);
await project.save();如果项目无法解析依赖,应先失败退出,不要降级成文本替换。
正反实验必须覆盖类型分叉
正向 fixture 应包含直接引用、别名 import 和跨文件调用;反向 fixture 放置同名局部变量、字符串和不同模块中的同名导出。
npx tsx codemod.ts
npx tsc --noEmit
git diff --check第二次执行应产生零差异。否则变换不是幂等迁移,不能进入批量分支。
幂等不是口号,要由第二次执行证明
幂等意味着同一基线经一次变换达到目标状态,第二次运行不再产生语义或格式变化。规则应识别目标形状,例如先排除已经是 telemetry.track({ name, payload }) 的调用,插入 import 前检查同源 specifier,排序和格式化只运行一次且配置固定。
把完整验证做成项目命令,避免操作者漏掉第二轮:
{
"scripts": {
"codemod:track:preview": "jscodeshift src --transform codemods/legacy-track.cjs --extensions=js,jsx,ts,tsx --gitignore --dry --print --fail-on-error",
"codemod:track:write": "jscodeshift src --transform codemods/legacy-track.cjs --extensions=js,jsx,ts,tsx --gitignore --fail-on-error",
"verify:codemod": "npm run format:check && npm run typecheck && npm test"
}
}实际写入后记录 git diff --stat、变更文件清单和 diff 哈希,按仓库固定版本运行 formatter,再执行格式检查、应用各自的 tsc -p 或 tsc -b、单元测试、契约测试与构建测试。以格式化后的树作为第二轮输入,再次运行 write 命令;第二轮后的 diff 哈希必须与第一轮格式化完成时一致。仅看到 runner 报 0 errors 不够:它可能把不该改的文件成功改写,也可能把目标全部归为 skipped。fixture 测试负责 AST 输入输出,类型测试负责模块图与重载,运行测试负责副作用与参数求值顺序,三者不能互相替代。
漏命中用独立查询发现。保留旧 API 的文本搜索、import 来源搜索、编译期弃用诊断和运行时埋点可形成互补证据。迁移完成后旧包依赖数、旧 API 静态命中数和运行时旧事件数都应收敛;动态 require、字符串注册表和生成代码需要专门处理,AST 工具不会凭空恢复运行时关系。
接入真实项目时把变换当作一次发布
先冻结目标提交和候选文件清单,在小型 package 上试跑,再扩大到代表性 package,最后处理全仓。monorepo 不应默认从根 tsconfig.json 加载一切:逐个 project reference 或 workspace 建模,能降低峰值内存,也能让失败落到明确 owner。共享类型包和 barrel export 则要进入较早批次,否则后续 package 可能在旧新 API 间反复震荡。
把 transform、fixture、parser 选择、TypeScript 版本、运行命令和拒绝原因一起纳入代码评审。CI 先跑 fixture 与 dry run;真正写文件通常由受控工作分支上的一次性作业或开发者执行,产出普通 PR。不要让每次 CI 都对主工作区原地重写,否则不同 runner、缓存和行尾设置会制造不可预测 diff。
回滚分三层处理。尚未保存时丢弃内存中的 Project 即可;已经写入但未提交时,先保存 diff 证据,再只恢复本批文件或丢弃隔离工作树;已经合并时用正常 revert,并重新执行构建与发布验证。若迁移伴随依赖升级、代码生成、schema 或线上双写,源码 revert 只是其中一步,还要按系统状态执行兼容开关和数据补偿。
权限、敏感代码与容量需要独立预算
codemod 只需要读取候选源码并写当前工作树,不需要生产凭证、发布权限或组织管理员 token。运行身份应受仓库 ACL 约束;跨仓自动化把“读取私有源码”“创建分支”“发起 PR”拆成不同权限。transform 能执行任意 Node 代码,来自外部包或远程 URL 的脚本必须先固定版本、审查源码,并在隔离环境禁用生产网络与云元数据访问。
日志和失败快照可能含私有路径、源码片段、客户标识或硬编码秘密。默认只输出文件相对路径、规则编号、计数和错误类型;需要打印源码时限制在受控调试会话,并按仓库敏感等级设置保留期。fixture 使用最小合成样例,不要把真实客户代码复制进公共工具仓库。
容量由文件数、平均 AST 大小、类型图、并发 worker、打印与格式化成本共同决定。jscodeshift 的 worker 数适合无共享状态的单文件变换;盲目拉满 CPU 会同时冲击磁盘、杀毒扫描和 CI 邻居。ts-morph/Compiler API 更受项目图与 compiler object 内存影响,应按 workspace 分片并限制并发。观察每批读取文件数、解析失败数、修改数、拒绝数、峰值 RSS、耗时、第二轮新增 diff 和回滚耗时,阈值来自仓库基线与 CI 容量,而不是复制一个固定数字。
长期维护时,每条 codemod 都应有 owner、目标依赖版本、parser 基线、fixture 集、适用 package、最大批次和删除时机。迁移完成后撤销临时写权限、删除一次性凭证和执行缓存;仍有复用价值的 transform 保留版本与测试,其余脚本随迁移记录归档,避免几年后被人误当成当前项目模型再次运行。
