本节目标:读完这一节,你能为一个已有的纯 JavaScript 项目设计出「不停机、可回滚」的渐进式迁移路线;能熟练使用
allowJs、checkJs与 JSDoc 注解把迁移拆成可独立验证的小步;能按正确顺序打开strict家族的开关并解释每一步报错的含义;还能用@ts-expect-error这类逃生舱控制迁移节奏,而不是被几千条报错淹没。
18.1 JavaScript 项目渐进式迁移
第 17 章我们把「新项目该怎么组织」讲完了。但真实世界里,绝大多数人第一次用 TypeScript,并不是从零起步,而是接手一个已经跑了几万行 JavaScript 的项目。这一节解决的就是这个问题:怎么在不推倒重来的前提下,把类型系统一点点铺进去。
为什么不该「重写」
先说结论:全量重写是迁移中最昂贵、失败率最高的方案。原因有三:
- 重写期间业务需求不会停,旧代码仍在改,两边的差异会持续扩大。
- 重写完成的判定标准模糊,「就差最后一个模块」可以拖半年。
- 团队在重写过程中学不到东西,因为真正难的业务逻辑还是从旧代码里抄过来的。
渐进式迁移的核心思路是:让新旧代码长期共存,每次只把一小块变严格。项目在任何一次提交之后都必须可构建、可发布、可回滚。
三种迁移策略对比
| 策略 | 做法 | 适用规模 | 风险 |
|---|---|---|---|
| 大爆炸重写 | 新建仓库,全部重写 | 极小工具库 | 极高 |
| 逐文件改名 | .js 改成 .ts,一个个来 | 中小项目 | 中 |
| 逐层收紧 | 先 allowJs,再 checkJs,最后开 strict | 中大型项目 | 低 |
现实中推荐从第三种起步,逐步过渡到第二种。因为「逐层收紧」的每一步都可以独立验证,而且不必先改动任何业务代码。
第零步:先让 tsc 能读懂 JS
第一步不是改代码,而是加配置。在项目根目录创建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowJs": true,
"checkJs": false,
"noEmit": true,
"strict": false,
"skipLibCheck": true
},
"include": ["src"]
}
这里有几个关键选择:
allowJs: true:让tsc把.js文件也纳入编译图。没有它,JS 文件里的import会把类型检查链路切断。checkJs: false:先只「看得懂」,不报错。等基础打通之后再打开。noEmit: true:迁移期通常还用原来的构建工具(Babel / esbuild / webpack)产出代码,tsc只当类型检查器用。strict: false:迁移期不要一步到位,理由见下文。
加完之后跑一次:
npx tsc --noEmit
如果这一步就报出大量 Cannot find module 或 Could not find a declaration file,说明缺类型声明,先处理依赖侧问题(见 12.1 .d.ts 与 @types 机制
)。tsconfig 各字段的完整含义可以回看 2.2 tsc 与 tsconfig.json 初探
。
第一步:用 JSDoc 在 JS 里写类型
这是渐进式迁移里最容易被忽略、但收益极高的一步。在还不打算改文件后缀的时候,JSDoc 就能带来类型检查:
/**
* 计算订单最终金额。
* @param {number} amount 原价(分)
* @param {number} [discountRate] 折扣率,0~1,默认不打折
* @returns {number} 折后金额(分)
*/
function finalPrice(amount, discountRate = 1) {
return Math.round(amount * discountRate);
}
打开 checkJs: true 之后,下面这些调用会立刻被标记:
finalPrice("100"); // 报错:类型 "string" 的参数不能赋给类型 "number" 的参数
finalPrice(100, "0.8"); // 同样报错
finalPrice(100); // 正确,discountRate 走默认值
JSDoc 的价值在于:类型信息和实现写在同一个文件里,不需要改后缀、不需要改构建配置。对于「不敢动」的核心模块,可以先这样把类型补上,等有空再整体转成 .ts。
一个常被忽视的技巧是用 import('...') 类型语法引用第三方类型,无需在文件顶部加 import 语句:
/**
* @param {import('express').Request} req
* @returns {Promise<void>}
*/
async function handle(req) {}
这样即使在 CommonJS 文件里,也能用上 DefinitelyTyped 提供的类型。
第二步:决定文件迁移顺序
不是所有文件都值得先迁。推荐的排序依据是「依赖图的叶子优先」:
| 优先级 | 文件类型 | 理由 |
|---|---|---|
| 1 | 纯工具函数、常量、类型定义 | 无副作用,改完立刻见效 |
| 2 | 数据模型 / DTO | 是其他模块的共同依赖,类型收益大 |
| 3 | 业务逻辑层 | 收益高,但改动面也大 |
| 4 | 路由 / 组件入口 | 最后迁,避免大面积破坏 |
| 5 | 构建脚本、配置文件 | 常常依赖第三方类型,收益低 |
实操上可以先用一条命令统计每个文件被引用的次数,从被引用最多的「底层」文件开始:
grep -rn "from ['\"]\./utils/format" src --include="*.js" | wc -l
数字越大,说明它越底层,越应该先迁。
第三步:逐文件改名
把 utils/format.js 改成 utils/format.ts,然后跑 tsc --noEmit,只修这个文件以及因它而新暴露的错误。改名的提交要小而独立,一个提交只动一到三个文件,这样出问题能精确回滚。
改名的过程中会遇到几类典型报错:
error TS7006: Parameter 'x' implicitly has an 'any' type.
error TS2339: Property 'foo' does not exist on type 'Bar'.
error TS2532: Object is possibly 'undefined'.
第一条说明该文件已经进入严格检查范围,需要补类型注解;后两条通常说明旧代码里存在真实隐患——这正是迁移的额外收益,而不只是「加注释」。
第四步:按顺序打开 strict 家族
strict: true 不是一个开关,而是一组开关的集合。一次性打开会产生海量报错,正确做法是按「修复成本从低到高」逐个开启:
| 顺序 | 开关 | 说明 |
|---|---|---|
| 1 | noImplicitAny | 隐式 any 报错,收益最大 |
| 2 | strictNullChecks | 区分 null / undefined,改动面最大但价值最高 |
| 3 | strictFunctionTypes | 函数参数逆变检查 |
| 4 | strictBindCallApply | bind / call / apply 的参数校验 |
| 5 | noImplicitThis | this 隐式 any 报错 |
| 6 | alwaysStrict | 输出文件加 "use strict" |
其中 strictNullChecks 是分水岭:打开它之后,Object is possibly 'undefined' 会成片出现。建议专门排一个迭代来做它,不要和其他改动混在一起,否则代码评审会变得无法进行。
每个开关的完整含义与配置细节见 16.1 编译目标与严格模式配置 。
逃生舱:控制迁移节奏的三个工具
迁移期难免有「暂时修不动」的地方,TypeScript 提供了三种粒度的逃生舱:
// 单个表达式:忽略这一行的错误
// @ts-expect-error 第三方库的类型定义有误,等上游修复
const raw = legacyLib.parse(payload);
// 单个文件:整个文件不做类型检查
// @ts-nocheck
// 这个文件是从旧系统直接拷贝过来的,暂未迁移
// 单个表达式:断言成 any(不推荐,仅作过渡)
const data = response.data as any;
三者的取舍很明确:
| 工具 | 粒度 | 适用场景 | 风险 |
|---|---|---|---|
@ts-expect-error | 一行 | 上游类型错误、已知且已记录 | 低,且错误消失时会反过来提醒 |
@ts-nocheck | 一文件 | 大文件、暂不迁移 | 中,容易长期遗留 |
as any | 表达式 | 临时打通 | 高,类型安全被绕过且无提示 |
@ts-expect-error 优于 @ts-ignore 的关键点在于:当那行代码的错误真的被修好时,@ts-expect-error 会自己报错提醒你删掉它,而 @ts-ignore 会永远沉默。团队规范里应当明确禁用 @ts-ignore。
一个真实工程示例:迁移一个 Express 中间件
假设有这样一个 JS 中间件:
// middleware/auth.js
module.exports = function auth(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
res.status(401).json({ error: "unauthorized" });
return;
}
req.user = verifyToken(token);
next();
};
第一步先加 JSDoc,不改后缀:
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
module.exports = function auth(req, res, next) {
// 实现不变
};
第二步改名为 .ts 并换成 ESM 导出,此时报错会精确地指向真正有问题的地方:
import type { Request, Response, NextFunction } from "express";
interface AuthedRequest extends Request {
user?: { id: string; role: "admin" | "user" };
}
export function auth(req: AuthedRequest, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
res.status(401).json({ error: "unauthorized" });
return;
}
req.user = verifyToken(token);
next();
}
注意 verifyToken 的返回类型一旦确定,req.user 的类型就跟着确定了——这就是「类型从底层往上长」的过程。继续往下迁,下游所有用到 req.user 的地方都会获得自动补全,并且 req.user.role 会被推导成 "admin" | "user" 而不是 string。
常见坑与错误信息
坑一:只加了 allowJs 忘了 checkJs,以为已经在检查。 allowJs 只让文件进入编译图,不产生类型错误。想看到报错必须开 checkJs(或把文件改成 .ts)。
坑二:迁移顺序搞反,从入口文件开始改。 这会导致入口处报出成百上千条「因为下游还没类型」的假错误,很快耗尽团队信心。始终自底向上。
坑三:skipLibCheck 被当成万能药。 它只跳过 .d.ts 文件的检查,不跳过你的业务代码。关掉它偶尔能发现依赖之间的类型冲突,但迁移期建议保持开启以缩短反馈时间。
坑四:把 as any 当成常规手段。 一旦 as any 出现在公共 API 的返回值上,类型检查链路就断了。真要临时放行,优先用 unknown 加运行时校验,参见 13.1 类型擦除带来的运行时盲区
。
坑五:忘了给 CI 加类型检查。 迁移成果很容易被一次「绕过检查的提交」毁掉。把 tsc --noEmit 放进 CI 的必过步骤,做法见 15.3 覆盖率、lint 与 CI 门禁
。
坑六:忘记同步 include 范围。 默认情况下 tsconfig.json 会包含目录下所有文件;如果你的源码分散在 src 与 scripts 两个目录,记得都写进 include,否则「检查通过」只是假象。
迁移进度怎么度量
不要用「还剩几个 .js 文件」这种粗糙指标,推荐三个可量化的数字:
git ls-files "src/**/*.ts" | wc -l # 已迁移文件占比
npx tsc --noEmit 2>&1 | grep -c "error TS" # 报错总数,应单调下降
grep -rn "@ts-nocheck\|@ts-ignore" src --include="*.ts" | wc -l # 逃生舱残留,目标为 0
把这三个数字记进团队文档,每周同步一次趋势。只要报错总数在下降,迁移就是在推进,哪怕文件占比暂时没变。反过来,如果报错总数连续两周不降,就说明当前批次卡住了,需要拆得更小。
迁移完成后的收尾
当 strict: true 全开、@ts-nocheck 清零、allowJs 可以关掉的时候,迁移基本完成。收尾清单:
- 关闭
allowJs,确认没有遗留的.js源文件 - 把
noEmit: true换成真实构建产物(见 16.2 esbuild/swc/tsup 与打包产物 ) - 若项目是 Monorepo,考虑引入 Project References 做增量构建(见 16.3 Monorepo 与 Project References )
- 把本节的自检数字写进团队文档,作为后续回归的基线
延伸阅读:既有专题文章 /typescript-js-migration/ 从工程角度补充了更多迁移案例,/typescript-strict-config/ 专门讨论严格配置的取舍,/typescript-project-architecture-tsconfig/ 则给出大型项目 tsconfig 的组织方式。
小结
- 渐进式迁移的目标是「每一步都可构建、可发布、可回滚」,而不是尽快改完后缀。
- 加
allowJs让 JS 进入编译图,开checkJs才产生类型错误;先用 JSDoc 在 JS 里补类型是成本最低的起点。 - 文件迁移顺序应当自底向上:工具函数 → 数据模型 → 业务逻辑 → 入口。
strict是一组开关的集合,按noImplicitAny→strictNullChecks→ 其余的次序逐个打开;strictNullChecks值得单独排一个迭代。- 逃生舱按粒度分三级,
@ts-expect-error优于@ts-ignore,as any应当尽量避免。 - 用「文件占比 / 报错总数 / 逃生舱残留」三个数字度量进度。
迁移完成只是起点:代码里有类型,不等于类型被用好。下一节我们讲类型驱动的重构与团队规范——如何让类型系统成为持续改进的引擎,而不是一次性交付的装饰。
阅读导航:上一节:17.3 部署、发布与版本演进 · 下一节:18.2 类型驱动的重构与团队规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。