渐进迁移:10万行 JS 项目的迁移路线
第19章:渐进迁移:10万行 JS 项目迁移 TypeScript 实战路线
理解渐进迁移是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用10万行 JS 项目迁移 TypeScript 实战路线?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 为什么大爆炸式重写必然失败
- 迁移路线总览
- Phase 1:零摩擦起步
Level 1 · 你需要知道的(1-3年经验)
为什么大爆炸式重写必然失败
很多团队看到遗留 JS 代码库就想彻底重写:新建一个 TypeScript 项目,把所有文件都转过去,解决所有类型错误,然后上线。这个计划几乎从不成功,原因是:
时间线爆炸。 一个 10 万行的代码库,如果要同时加上 strict 模式,产生的类型错误可能是几千个。修 bug 的同时,业务需求还在往老代码里加功能,两条线永远追不上。
合并地狱。 大规模重写意味着整个代码库都在一个长期分支上,与主分支的 diff 越来越大,合并成本指数级上升。
测试缺失。 重写过程中行为回归很难发现,尤其是没有足够测试覆盖的代码库。
团队疲劳。 "TypeScript 迁移"分支存在 3 个月之后,所有人都开始摆烂。
正确的做法是渐进迁移:在同一个代码库里,JS 和 TS 文件共存,逐步扩大 TS 的覆盖面,每一步都是可以单独上线的小增量。
迁移路线总览
Phase 1: 零摩擦起步(1-2 周)
→ allowJs + checkJs:不改文件名就得到类型检查
→ JSDoc:给 JS 文件加类型注解
Phase 2: 战略性重命名(持续进行)
→ 从叶子模块开始改 .ts
→ @ts-nocheck 用于延迟困难文件
Phase 3: 收紧类型(1-3 个月)
→ 逐一开启 strict 子选项
→ unknown 替换边界上的 any
Phase 4: 消除剩余 any(持续进行)
→ noImplicitAny: true 作为最终里程碑
→ type-coverage 跟踪进度
Phase 1:零摩擦起步
allowJs: true + checkJs: true
这两个选项让 TypeScript 在不改文件名的情况下检查 .js 文件,是迁移的第一步,也是阻力最小的一步。
// tsconfig.json(初始迁移配置)
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noImplicitAny": false, // 先关掉,JS 文件里到处都是隐式 any
"strict": false, // 先不开 strict
"outDir": "./dist",
"target": "ES2020",
"module": "CommonJS"
},
"include": ["src/**/*"]
}
开启后,你会立刻看到 JS 文件里的很多低级错误被标出,而且不需要改任何文件名:
// src/utils/format.js(还是 .js 文件)
// checkJs 下,TypeScript 会推断 value 是 number 类型
function formatPrice(value) {
return "$" + value.toFixed(2); // 如果 value 是 string,这里会有警告
}
// 调用时传错类型,会有类型提示(不一定报错,取决于推断结果)
formatPrice("hello"); // TS 可能推断出这是字符串
JSDoc 注解:在 JS 里写类型
不改文件名,通过 JSDoc 注释给 JS 代码加类型,TypeScript 能识别并利用这些注解。
// src/api/users.js
/**
* @typedef {Object} User
* @property {string} id
* @property {string} name
* @property {string} email
* @property {"admin" | "user" | "guest"} role
*/
/**
* 根据 ID 获取用户
* @param {string} userId
* @returns {Promise<User>}
*/
async function getUserById(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
/**
* 更新用户信息
* @param {string} userId
* @param {Partial<User>} updates
* @returns {Promise<User>}
*/
async function updateUser(userId, updates) {
const response = await fetch(`/api/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
return response.json();
}
JSDoc 的优点:给 JS 文件加了类型信息,又不需要 TypeScript 工具链,老的 JS 工具仍然能直接运行文件。这是一个很好的桥接阶段。
处理无法避免的初始错误
刚开启 checkJs 时,可能出现大量噪音错误。可以用 // @ts-nocheck 暂时跳过整个文件,只处理你能处理的文件:
// src/legacy/old-module.js
// @ts-nocheck
// TODO: 迁移到 TypeScript 时处理类型问题
// 这个文件里的所有类型错误都会被忽略
也可以用 // @ts-ignore 跳过单行:
// 下一行类型错误已知,迁移时处理
// @ts-ignore
const result = weirdLegacyFunction(data);
Phase 2:战略性重命名文件
从叶子模块开始
叶子模块是那些不从项目内部其他模块导入任何东西的文件——它们只依赖 node_modules,是依赖树的最底层。
项目依赖图(简化示例):
app.js
├── api/router.js
│ ├── api/users.js ← 叶子(只用 node_modules)
│ └── api/products.js ← 叶子
├── utils/format.js ← 叶子
└── utils/logger.js ← 叶子
正确的迁移顺序:先把叶子节点(format.js、logger.js、users.js)改成 .ts,然后向上迁移依赖它们的模块。
为什么从叶子开始? 因为叶子模块转换成 .ts 后,上层模块在导入它们时会立刻获得类型信息。如果从顶层模块开始改,改完之后还是得依赖未类型化的底层模块,收益最小。
查找叶子模块的方法:
# 找出没有从 src 内部导入任何东西的文件(粗略查找)
# 可以结合 dependency-cruiser 等工具做精确分析
npx depcruise --include-only "^src" --output-type dot src | dot -T svg > deps.svg
重命名文件时的操作步骤
以 src/utils/format.js 为例:
# 1. 重命名文件
mv src/utils/format.js src/utils/format.ts
# 2. 立刻用 tsc 检查这个文件的类型错误
npx tsc --noEmit 2>&1 | grep "format.ts"
处理重命名后的类型错误:
// src/utils/format.ts(重命名后的文件)
// 原来的 JS 代码
function formatPrice(value) { // 错误:隐式 any
return "$" + value.toFixed(2);
}
// 加上类型注解
function formatPrice(value: number): string {
return "$" + value.toFixed(2);
}
// 如果函数很复杂,先用 any 过渡,后续再细化
function complexLegacyFn(data: any): any { // 先不管,后续 Phase 3 处理
// ... 复杂逻辑
}
// @ts-nocheck 用于延迟困难文件
某些文件类型错误太多,现在改它会花太长时间影响交付节奏。先重命名(获得 IDE 支持),然后加 @ts-nocheck 推迟修复:
// src/legacy/complex-module.ts
// @ts-nocheck
// TODO: 类型问题待处理,预计工时:2天
// 添加时间:2024-01-15
// 文件内容照旧,现在 TypeScript 不检查这里
在团队的 issue 系统里记录每个 @ts-nocheck 文件,避免它们永久留在代码库里。
Level 2 · 它是怎么运行的(3-5年经验)
Phase 3:收紧类型
当代码库里大部分文件都变成 .ts 后,开始逐步开启 strict 选项。一次只开一个,修完错误再开下一个。
推荐的开启顺序
// 阶段 3.1:最安全的两个
{
"compilerOptions": {
"strictNullChecks": true, // 先开这个,收益最大
"noImplicitAny": true // 然后这个
}
}
// 阶段 3.2:再开这些
{
"compilerOptions": {
"strictFunctionTypes": true,
"strictBindCallApply": true
}
}
// 阶段 3.3:剩余的
{
"compilerOptions": {
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"useUnknownInCatchVariables": true
}
}
// 达到目标:
{
"compilerOptions": {
"strict": true
}
}
处理 strictNullChecks 产生的大量错误
开启 strictNullChecks 之后,最常见的错误模式和修复方法:
// 错误模式 1:函数参数可能为 null/undefined
function processUser(user: User) { // 原来的代码
return user.name.toUpperCase();
}
// 调用处传入了 null,现在 processUser(null) 报错
// 修复:在类型上允许 null,并在函数内处理
function processUser(user: User | null): string {
if (!user) return "";
return user.name.toUpperCase();
}
// 错误模式 2:DOM 操作返回 null
const button = document.getElementById("submit"); // 类型:HTMLElement | null
button.addEventListener("click", handler); // 错误:button 可能是 null
// 修复
const button = document.getElementById("submit");
if (button) {
button.addEventListener("click", handler);
}
// 或者用非空断言(确信元素存在时)
const button = document.getElementById("submit")!;
button.addEventListener("click", handler);
// 错误模式 3:对象属性可能未定义
interface Config {
timeout?: number;
}
function applyConfig(config: Config) {
const timeout = config.timeout * 1000; // 错误:timeout 可能 undefined
// 修复
const timeout = (config.timeout ?? 30) * 1000;
}
unknown 替换边界上的 any
找到所有作为"入口"的 any(API 响应、用户输入、外部配置),换成 unknown,强迫在使用前缩窄类型:
// 之前:any 到处传播
async function fetchData(url: string): Promise<any> {
const res = await fetch(url);
return res.json(); // 返回 any,污染调用链
}
const data = await fetchData("/api/users");
data.users.forEach((u: any) => console.log(u.name)); // any 链
// 之后:unknown 在边界
async function fetchData(url: string): Promise<unknown> {
const res = await fetch(url);
return res.json();
}
// 调用方必须先验证类型
const data = await fetchData("/api/users");
const parsed = UserListSchema.parse(data); // 用 zod 验证
parsed.users.forEach(u => console.log(u.name)); // 类型安全
@ts-expect-error vs @ts-ignore:永远优先选前者
// @ts-ignore:无论下一行有没有错误都忽略
// @ts-ignore
const result = brokenFunction(); // 如果 brokenFunction 后来被修好了,这行 @ts-ignore 成了噪音
// @ts-expect-error:下一行必须有类型错误,否则 TypeScript 自己报错
// @ts-expect-error:第三方库类型有 bug,版本升级后检查是否还需要
const result = brokenFunction();
// 当 brokenFunction 被修好(类型错误消失),TypeScript 会提示"这个 @ts-expect-error 不再需要"
// 这让你能跟踪哪些绕过已经过时了
在迁移过程中,用 @ts-expect-error 来标记已知的临时绕过,加上注释说明原因和计划修复时间。
Phase 4:消除剩余 any
用 type-coverage 跟踪进度
# 安装
npm install -D type-coverage
# 查看当前覆盖率
npx type-coverage
# 输出详细报告(列出所有 any 的位置)
npx type-coverage --detail
# 设置最低覆盖率(放入 CI)
npx type-coverage --atLeast 90
典型输出:
9312 / 9704 97.44%
这表示 9704 个类型节点中,有 9312 个有明确类型(不是 any)。
把 type-coverage 加入 CI
# .github/workflows/ci.yml
- name: Check type coverage
run: npx type-coverage --atLeast 95
设定一个递增的目标:第一个月 80%,第二个月 85%,直到达到 95%+。每次 PR 不能降低覆盖率。
系统性消除 any
找出 any 的来源,按类别处理:
// 来源 1:函数参数没有类型注解(noImplicitAny 处理大部分)
function process(data: any) { /* ... */ } // 改成具体类型
// 来源 2:JSON.parse 返回 any(用 unknown 接)
const data: unknown = JSON.parse(text);
// 来源 3:第三方库没有类型(装 @types/xxx 或写声明文件)
const result = legacyLib.doSomething(); // 返回 any
// 来源 4:显式 any 用于逃避复杂类型(用泛型或更精确的类型替换)
function identity(x: any): any { return x; } // 用泛型
function identity<T>(x: T): T { return x; }
// 来源 5:类型断言 as any(最危险的,优先处理)
const user = data as any; // 不知道为什么加了 as any?删掉,看真正的错误是什么
可以量化的迁移指标
在迁移过程中跟踪这些指标,让进度可见:
# 1. TS 文件覆盖率(多少文件已经是 .ts)
ls src/**/*.ts | wc -l
ls src/**/*.js | wc -l
# 2. any 覆盖率
npx type-coverage
# 3. @ts-nocheck 文件数量
grep -rl "@ts-nocheck" src/ | wc -l
# 4. @ts-ignore 和 @ts-expect-error 数量
grep -r "@ts-ignore\|@ts-expect-error" src/ | wc -l
# 5. 编译错误数量(目标:0)
npx tsc --noEmit 2>&1 | grep "error TS" | wc -l
建议每周把这些数字记录在团队文档或 Slack 频道里,保持迁移动力。
常见陷阱及应对
陷阱 1:把迁移和功能开发混在一起
错误做法:
- 在一个 PR 里:把 format.js 改成 format.ts + 加新功能 + 修 bug
正确做法:
- 类型迁移 PR:只改文件名和类型注解,行为不变
- 功能 PR:完全在 .ts 文件里,正常开发
两件事混在一起,code review 很难,出了问题也不知道是迁移导致还是功能导致。
陷阱 2:在迁移时顺手"优化"代码
// 错误:把迁移当成重构的机会,同时重写逻辑
// 这样测试覆盖就失效了,无法验证行为是否一致
// 正确:迁移 PR 里,只加类型,不改逻辑
// 这确保 any 修复不会引入新 bug
陷阱 3:对所有 any 用 as 断言代替真正修复
// 简单粗暴但错误的方式:把所有 any 用断言"修掉"
const user = getUser() as User; // 如果 getUser 真的返回 any,这还是有风险的
// 正确:找出 any 的来源,在根源处修复
// 把 getUser 的返回类型从 any 改成 Promise<User>,
// 同时在内部用类型守卫或 zod 验证
陷阱 4:忽略第三方库的 any
// express 的 req.body 是 any,但你不去管它
app.post("/users", (req, res) => {
const { name, email } = req.body; // any 污染进来了
createUser(name, email); // 没有类型保护
});
// 应该在入口处用 zod 验证
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) return res.status(400).json({ errors: result.error.format() });
const { name, email } = result.data; // 类型安全
createUser(name, email);
});
真实迁移时间线参考
基于实际项目经验的估算(10 万行 JS 代码库,5-10 人团队):
| 阶段 | 工期 | 关键里程碑 |
|---|---|---|
| Phase 1:allowJs + checkJs | 1-2 周 | 无新错误引入,有 JSDoc 覆盖率 |
| Phase 2:文件重命名(50%) | 4-8 周 | 50% 文件是 .ts,type-coverage > 60% |
| Phase 2:文件重命名(100%) | 4-8 周 | 所有文件是 .ts,type-coverage > 70% |
| Phase 3:strict 选项逐步开启 | 4-12 周 | strict: true 开启,type-coverage > 85% |
| Phase 4:any 清零 | 持续进行 | type-coverage > 95%,@ts-nocheck 为 0 |
注意:上面的时间假设迁移是团队工作的一部分,而不是暂停所有业务需求专门做迁移。实际节奏取决于代码库复杂度和团队投入比例。
Level 3 · 规范怎么定义的(资深)
渐进迁移的核心策略是利用 allowJs: true + checkJs: true 让 TypeScript 在不改文件名的情况下检查 JS 文件。JSDoc 注解被 TypeScript 编译器完整支持,包括 @typedef、@param、@returns 等——这意味着你可以在纯 JS 文件中获得几乎与 .ts 文件相同的类型检查。@ts-expect-error 优于 @ts-ignore 的原因是:当注释下方的代码不再有类型错误时,@ts-expect-error 会报告"多余的指令",让你及时清理;而 @ts-ignore 会永远静默。
Level 4 · 边界与陷阱(所有人)
反模式
反模式:立刻追求完美类型,不做增量
// 错误心态:
// "这个函数的类型必须完全正确才能提交"
// 结果:迁移 PR 越来越大,越来越难合并
// 正确心态:
// "先让文件是 .ts,基本类型通过,// @ts-expect-error 处理临时问题"
// 然后在后续 PR 里逐步细化类型
// 一个有 3 个 @ts-expect-error 的 .ts 文件
// 远好过一个没有类型的 .js 文件
反模式:跳过 Phase 1 直接改文件名
如果代码库有 500 个 JS 文件,直接把它们全改成 .ts 但不先建立基础设施,会面对一次性几千个错误,团队直接放弃。
反模式:不把迁移指标纳入 sprint
错误:
- 迁移是"有空就做"的事情
- 结果永远没空,停留在 30% 覆盖率
正确:
- 每个 sprint 分配固定比例(比如 20%)用于类型迁移
- 把 type-coverage 目标写进 sprint 目标
汇总表
| 阶段 | 关键选项 | 可量化指标 |
|---|---|---|
| Phase 1 | allowJs, checkJs, noImplicitAny: false |
初始错误数建立基线 |
| Phase 2 | 文件重命名,@ts-nocheck 临时绕过 |
.ts 文件比例 |
| Phase 3 | strictNullChecks,noImplicitAny 等逐一开启 |
type-coverage 百分比 |
| Phase 4 | noImplicitAny: true,清理 @ts-nocheck |
any 节点数,@ts-nocheck 文件数 |
扩展阅读
- TypeScript 官方迁移指南
- type-coverage CLI 工具
- DefinitelyTyped — 当第三方库没有类型时的去处