《TypeScript编程入门》18.1 JavaScript 项目渐进式迁移

本节讲解如何在不推倒重来的前提下,把类型系统逐步铺进一个存量 JavaScript 项目。内容包括三种迁移策略的取舍、用 allowJs 与 checkJs 让 tsc 先读懂 JS、用 JSDoc 在 JS 里补类型、文件迁移的自底向上顺序、strict 家族开关的正确开启次序,以及 @ts-expect-error 等逃生舱的粒度与风险。读完你能设计出可回滚的迁移路线,并用三个指标跟踪进度。

本节目标:读完这一节,你能为一个已有的纯 JavaScript 项目设计出「不停机、可回滚」的渐进式迁移路线;能熟练使用 allowJs、checkJs 与 JSDoc 注解把迁移拆成可独立验证的小步;能按正确顺序打开 strict 家族的开关并解释每一步报错的含义;还能用 @ts-expect-error 这类逃生舱控制迁移节奏,而不是被几千条报错淹没。

18.1 JavaScript 项目渐进式迁移

第 17 章我们把「新项目该怎么组织」讲完了。但真实世界里,绝大多数人第一次用 TypeScript,并不是从零起步,而是接手一个已经跑了几万行 JavaScript 的项目。这一节解决的就是这个问题:怎么在不推倒重来的前提下,把类型系统一点点铺进去。

为什么不该「重写」

先说结论:全量重写是迁移中最昂贵、失败率最高的方案。原因有三:

  1. 重写期间业务需求不会停,旧代码仍在改,两边的差异会持续扩大。
  2. 重写完成的判定标准模糊,「就差最后一个模块」可以拖半年。
  3. 团队在重写过程中学不到东西,因为真正难的业务逻辑还是从旧代码里抄过来的。

渐进式迁移的核心思路是:让新旧代码长期共存,每次只把一小块变严格。项目在任何一次提交之后都必须可构建、可发布、可回滚。

三种迁移策略对比

策略做法适用规模风险
大爆炸重写新建仓库,全部重写极小工具库极高
逐文件改名.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 不是一个开关,而是一组开关的集合。一次性打开会产生海量报错,正确做法是按「修复成本从低到高」逐个开启:

顺序开关说明
1noImplicitAny隐式 any 报错,收益最大
2strictNullChecks区分 null / undefined,改动面最大但价值最高
3strictFunctionTypes函数参数逆变检查
4strictBindCallApplybind / call / apply 的参数校验
5noImplicitThisthis 隐式 any 报错
6alwaysStrict输出文件加 "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 可以关掉的时候,迁移基本完成。收尾清单:

延伸阅读:既有专题文章 /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 类型驱动的重构与团队规范 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes