第 19 章

渐进迁移:10万行 JS 项目的迁移路线

第19章:渐进迁移:10万行 JS 项目迁移 TypeScript 实战路线

理解渐进迁移是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用10万行 JS 项目迁移 TypeScript 实战路线?关键的设计决策和陷阱是什么?

读完本章你将理解


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.jslogger.jsusers.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 strictNullChecksnoImplicitAny 等逐一开启 type-coverage 百分比
Phase 4 noImplicitAny: true,清理 @ts-nocheck any 节点数,@ts-nocheck 文件数

扩展阅读

本章评分
4.6  / 5  (10 评分)

💬 留言讨论