jscodeshift:从 AST Transform 到可回滚 Codemod
jscodeshift 适合文件级语法变换
jscodeshift 把 recast 的解析与打印能力、AST 遍历和文件批处理包装成 Codemod 运行入口。它擅长 import、调用形状和语法节点迁移,但不会自动加载完整 TypeScript 项目类型;需要跨文件类型身份时,应改用 ts-morph 主文中的项目模型。
可信的 transform 必须固定 Node 与包版本,定义文件集合,保存正反 fixture,先 dry run,再用第二次执行证明幂等。运行统计、无法解析的文件和人工例外也要进入完整迁移评审与发布材料,不能只提交最终 diff 或一张成功截图。
异常文件必须阻断批次放行。
先按所需证据选择执行引擎
三种常见入口都能遍历语法树,却解决不同层次的问题。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,并把每个项目的诊断分别设为门禁。解析失败时先记录文件路径与语法类别,不要退化成正则后继续批量写入。
用 jscodeshift 完成第一条单文件变换
先处理容易证明的形状:只改从 @acme/legacy-telemetry 命名导入且本地名为 legacyTrack 的直接调用,并要求文件已经从 @acme/telemetry 命名导入本地绑定 telemetry。transform 同时检查两个导入、调用绑定和参数个数;只看到文件里存在目标 import 并不足以证明某个同名调用引用它,因为函数参数或局部变量仍可能遮蔽该名字。
// codemods/legacy-track.cjs
module.exports = function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
const imported = root.find(j.ImportDeclaration, {
source: { value: "@acme/legacy-telemetry" },
}).find(j.ImportSpecifier, {
imported: { name: "legacyTrack" },
local: { name: "legacyTrack" },
});
const targetImported = root.find(j.ImportDeclaration, {
source: { value: "@acme/telemetry" },
}).find(j.ImportSpecifier, {
imported: { name: "telemetry" },
local: { name: "telemetry" },
});
if (imported.size() !== 1 || targetImported.size() !== 1) return undefined;
const importScope = imported.paths()[0].scope.lookup("legacyTrack");
const telemetryImportScope = targetImported.paths()[0].scope.lookup("telemetry");
let changed = false;
root.find(j.CallExpression, {
callee: { type: "Identifier", name: "legacyTrack" },
}).filter((path) =>
path.node.arguments.length === 2 &&
path.scope.lookup("legacyTrack") === importScope &&
path.scope.lookup("telemetry") === telemetryImportScope
)
.forEach((path) => {
const [name, payload] = path.node.arguments;
path.node.callee = j.memberExpression(
j.identifier("telemetry"),
j.identifier("track"),
);
path.node.arguments = [j.objectExpression([
j.property("init", j.identifier("name"), name),
j.property("init", j.identifier("payload"), payload),
])];
changed = true;
});
return changed ? root.toSource({ quote: "single" }) : undefined;
};
module.exports.parser = "tsx";这个版本故意只接受已经存在的命名导入,不自动猜测 telemetry 应来自默认导入、命名导入还是依赖注入对象;目标绑定不存在、不唯一或在调用点被局部变量遮蔽时,候选保持不变并进入拒绝统计。第一轮先把源函数与目标对象两侧的遮蔽规则和新调用形状证明正确,再为其他导入策略分别增加 fixture。一个 transform 同时承担匹配、跨模块判定、导入整理、格式化和兼容兜底,会让失败难以分型。
先只对 fixture 运行 dry run:
npx jscodeshift codemods/fixtures/positive.tsx \
--transform codemods/legacy-track.cjs \
--parser tsx --dry --print --run-in-band --fail-on-error--dry 禁止写文件,--print 展示结果,--run-in-band 让调试输出保持顺序,--fail-on-error 让解析或 transform 错误返回非零退出码。正式候选集再加 --extensions=js,jsx,ts,tsx --gitignore;执行目录必须是预期仓库根,因为 --gitignore 从当前目录读取规则。
正反 fixture 证明命中和拒绝都正确
正向样例至少包含普通 TypeScript、TSX 内调用、换行参数和已有括号;反向样例包含本地同名函数、另一模块导入、参数数量不符、字符串与注释。不能只断言“改了一个文件”,应比较完整输出,并检查拒绝样例字节不变。
// codemods/fixtures/positive.tsx
import { legacyTrack } from "@acme/legacy-telemetry";
import { telemetry } from "@acme/telemetry";
export function BuyButton() {
const onClick = () => legacyTrack("buy", { source: "detail" });
return <button onClick={onClick}>Buy</button>;
}
// codemods/fixtures/negative.ts
import { legacyTrack as vendorTrack } from "another-package";
const legacyTrack = (name: string) => name;
legacyTrack("local");
vendorTrack("vendor", {});用 jscodeshift/dist/testUtils 或仓库现有测试框架保存 .input / .output fixture。除了输出快照,再加入第二次执行断言:把第一次输出重新交给 transform,结果必须为 undefined 或与输入完全一致。反向 fixture 还要加入函数参数或块级变量遮蔽 legacyTrack、调用点局部变量遮蔽 telemetry、缺少目标绑定、重复导入等情况。对 alias import,当前 transform 应明确拒绝并计入待处理统计;如果业务要求支持 alias,再写独立正向 fixture,并从 ImportSpecifier.local.name 推导本地标识符。
反向实验可以把 module.exports.parser 临时改成 babel 后处理含 TypeScript 类型的 fixture。预期 runner 报解析错误并以非零状态退出,而不是静默写出部分文件。再把候选目录混入 dist/,分别比较有无 --gitignore 时的文件计数;两次差值证明生成物是否被纳入执行面。
幂等不是口号,要由第二次执行证明
幂等意味着同一基线经一次变换达到目标状态,第二次运行不再产生语义或格式变化。规则应识别目标形状,例如先排除已经是 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 保留版本与测试,其余脚本随迁移记录归档,避免几年后被人误当成当前项目模型再次运行。
