把 5 万行 JS 项目迁到 TypeScript 的 90 天血泪史:15 个真实坑 + 完整迁移 SOP

一个跑了七年的 Node.js + Express 老项目,5 万行业务代码 + 1.2 万行测试,我们用 102 天完成 JS → TS 全量迁移。本文写完整时间线、tsconfig 演进、allowJs 渐进策略、Branded Types / Project References / strict 八大开关、放弃过的两条歧路、和最后立下的 10 条 TS 工程纪律,供所有正在或将要做这件事的团队避坑。

2024 年春节后我们团队接了一个吃力不讨好的任务:把公司主站后台一个跑了七年的 JavaScript 项目(约 5 万行业务代码 + 1.2 万行测试)全量迁到 TypeScript。这个项目是 Node.js 14 + Express + MongoDB,前后端不分离的老古董,常年由不同的 5 任开发交接,代码风格混乱,类型一塌糊涂,光是"一个函数有 6 种调用方式"这种场景就遍地都是。立项时管理层给的预期是"30 天搞定",我们做了详细评估之后报上去 90 天,实际跑下来用了 102 天才正式收口。这篇把这次迁移的完整路线图、踩过的 15 个真实坑、放弃过的两条歧路、以及最后沉淀出来的"渐进迁移 SOP"全部写一遍,希望正在或者即将做 JS→TS 迁移的团队能少走些弯路。

项目背景:为什么决定迁

这个项目的痛苦在于"看起来能跑,实际处处雷"。每周平均会出现两到三次生产事故,根因大约一半是"传错参数类型"——比如把 string 类型的订单号传成了 number,或者把可选字段当成必选用了。Bug 追踪系统里这种"低级类型错误"占了全年事故的 38%,我们尝试过加 ESLint 规则、加运行时 schema 校验(用 joi),效果都不理想:ESLint 规则太死板,joi 校验只能在边界生效。讨论了几个月,最终拍板上 TypeScript,目标不是花哨的类型炫技,而是把"传参类型错"这一类事故彻底消灭。

阶段 时间 关键产出
立项 + 调研 D1-D7 评估报告、迁移路线图、SOP 草案
基础设施搭建 D8-D14 tsconfig、构建链、CI 流水线
allowJs 兼容期 D15-D35 JS / TS 混存, 双轨编译跑通
核心模块迁移 D36-D70 2.8 万行核心业务代码切 .ts
边缘模块迁移 D71-D90 剩余 2.2 万行业务 + 1.2 万行测试
开 strict 收口 D91-D102 strict: true 全开, noImplicitAny 强制

第一周做的事:决定路线

迁移启动后的第一周完全没碰代码,只在做规划。我们花了五天时间评估了三条路线:全量重写(估计需要 8 个月,直接砍掉)、用 JSDoc 加类型注解(估计 60 天,但缺乏强约束)、用 allowJs 渐进迁移到原生 TS(估计 90 天,可控性最强)。最终选 allowJs 渐进迁移,核心理由是可以随时回滚、风险可控、新老代码混跑、不影响业务发布节奏。这条路线后来被证明是对的,但实际跑下来仍有不少 allowJs 模式的坑(后面会讲)。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",

    "allowJs": true,           // 允许 .js 文件参与编译
    "checkJs": false,          // 但暂时不检查 .js 的类型 (后期再开)
    "esModuleInterop": true,
    "resolveJsonModule": true,

    "strict": false,           // 先不开 strict, 渐进改
    "noImplicitAny": false,    // 先关 noImplicitAny, 允许隐式 any
    "skipLibCheck": true,      // 跳过 node_modules 的类型检查, 否则编译慢

    "sourceMap": true,
    "declaration": false
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.js"]
}

这份 tsconfig 是迁移启动版本,有几个关键决策。第一,allowJs + checkJs=false:允许 JS 文件参与编译但不做类型检查,先把构建链跑通再说。第二,strict: false:先让代码能编过,不要一上来就被几千个类型错误淹没。第三,skipLibCheck: true:不检查 node_modules 里的类型,否则我们这种几百个 npm 依赖的老项目能给你编出几万个错误,根本没法干活。这三个开关后期都要逐步收紧,但启动期必须先松后紧。

第一个真实大坑:CommonJS 和 ESM 混乱

构建链跑通后做的第一件事是 export/import 的兼容。原项目是清一色 const x = require('x'),我们引入 TypeScript 默认用 import x from 'x',看起来兼容,但实际有大量库的默认导出形式不一致,埋了一堆雷。最经典的例子是 moment.js:在 CommonJS 下是 const moment = require('moment'),在 TS 里如果直接写 import moment from 'moment' 配置不对就会拿到 undefined。我们花了整整两天才把这块兼容问题理清楚。

// 反模式: esModuleInterop 没开时, 这种写法会拿到 undefined
import moment from 'moment';
moment().format();  // TypeError: moment is not a function

// 正确做法 1: 开 esModuleInterop (推荐)
// tsconfig.json: "esModuleInterop": true
import moment from 'moment';  // 现在能正常工作

// 正确做法 2 (不开 esModuleInterop 时的兼容写法)
import * as moment from 'moment';
(moment as any)().format();  // 丑但可用

// 正确做法 3 (默认导出真的没有时, 比如某些工具库)
import { format, parseISO } from 'date-fns';
const d = parseISO('2024-01-01');
format(d, 'yyyy-MM-dd');

// 老代码兼容: 仍然可以用 require 救急
const legacyLib = require('some-untyped-lib');
// 但要打 @ts-ignore 或写 .d.ts

esModuleInterop 这个选项的作用是模拟 Babel 的默认导出兼容行为,几乎所有现代 TS 项目都应该开。我们打开它之后,大约 90% 的 import 问题自动消失了,剩下的 10% 是一些没有类型定义文件(.d.ts)的老 npm 包,需要手动写声明或者 install @types/xxx。这一块工作量比预期大得多,有些冷门包根本没人写过 @types,我们最后给 8 个内部库写了 .d.ts,提交到了 DefinitelyTyped 仓库。

渐进迁移的关键决策:文件粒度

迁移启动后,第一个具体动作是按文件粒度逐个改 .js → .ts。我们试过两种粒度:函数粒度(在同一个 .js 文件里逐个函数加 JSDoc 类型注解)和文件粒度(整个文件一次性改成 .ts)。前者看起来"温柔",但实际操作下来痛苦得多,因为 JSDoc 的表达力远不如 TS,而且要管理混合状态。文件粒度虽然单次改动大,但每个文件迁完就是一个干净的小胜利,士气也好维持。

第二个大坑:any 满天飞

开始迁移后第三周,我们发现一个让人尴尬的现象:为了让代码"快速能编过",team 里大量使用了 any 兜底,有些文件里 any 出现的频率比 string 还高。这种迁移其实是"假迁",代码看起来是 .ts 了,但类型保障基本没有,bug 该出还是出。我们紧急叫停,定了一个规矩:any 必须有理由,要写注释 @ts-expect-error 或者 // FIXME(any): xxx。然后用 eslint 规则 @typescript-eslint/no-explicit-any 当 warning 级别,在 CI 里输出 any 数量趋势,每周复盘。

// 反模式: 偷懒用 any 兜底
function processOrder(order: any) {
  return order.items.map((item: any) => item.price * item.qty);
}

// 修法 1: 定义 interface
interface OrderItem {
  price: number;
  qty: number;
  sku: string;
}

interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  createdAt: Date;
}

function processOrder(order: Order): number[] {
  return order.items.map(item => item.price * item.qty);
}

// 修法 2: 临时不知道类型, 用 unknown + 类型守卫 (比 any 安全)
function parsePayload(raw: unknown): Order {
  if (typeof raw !== 'object' || raw === null) {
    throw new Error('Invalid payload');
  }
  const obj = raw as Record<string, unknown>;
  if (typeof obj.id !== 'string') {
    throw new Error('Invalid order id');
  }
  // ... 一系列守卫
  return obj as unknown as Order;
}

// 修法 3: 用 zod 等 runtime schema 库, 一行得到类型 + 校验
import { z } from 'zod';
const OrderItemSchema = z.object({
  price: z.number(),
  qty: z.number().int().positive(),
  sku: z.string(),
});
const OrderSchema = z.object({
  id: z.string(),
  userId: z.string(),
  items: z.array(OrderItemSchema),
  createdAt: z.date(),
});
type Order = z.infer<typeof OrderSchema>;  // 类型自动推出来

const validated = OrderSchema.parse(rawData);  // 同时做了运行时校验

引入 zod 之后我们迁移效率明显提升,因为 zod 同时解决了"运行时校验"和"类型定义"两件事,以前用 joi 写校验、用 interface 写类型,两套人维护,经常对不上。zod 之后 schema 就是单一真理来源,改一处全改了。整个项目大约 80% 的 API 边界都改用了 zod,代码量净减少了 1500 行(因为去掉了一堆重复的类型 + 校验代码)。

第三个大坑:Express 中间件类型签名

Express 是 2010 年代的库,类型支持一直不够好,尤其是中间件的 req/res 类型扩展。原项目里有大量中间件给 req 加自定义字段(req.userreq.sessionreq.tenantId 之类),JS 时代随便加,TS 一下子全报错。正确做法是用 declaration merging 扩展 Express.Request 接口,但这个特性不熟悉的同学经常写错位置导致全局污染。

// types/express.d.ts (放在 tsconfig include 范围内)
import 'express';

declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        email: string;
        roles: string[];
      };
      tenantId?: string;
      requestId: string;
    }
  }
}

// 中间件里加字段
import { Request, Response, NextFunction } from 'express';

export function authMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // 解析 token, 拿到 user
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) {
    res.status(401).json({ error: 'no token' });
    return;
  }
  const payload = verifyToken(token);
  req.user = payload;     // ← 现在这里有类型, IDE 自动补全
  req.tenantId = payload.tenantId;
  next();
}

// 控制器里用
app.get('/me', (req, res) => {
  if (!req.user) {
    return res.status(401).json({ error: 'no user' });
  }
  res.json({ id: req.user.id, email: req.user.email });  // 自动补全
});

这个声明文件位置至关重要。我们一开始放在 src/types/ 下,工作正常;后来重构时不小心移到了 src/typings/,但 tsconfig 的 include 没改,全局扩展失效,所有控制器里 req.user 都报错。这种"位置敏感"的全局扩展很容易踩,建议在项目 README 里专门写一段说明,告诉新人这些 .d.ts 文件放哪、为什么。

第四个大坑:循环依赖

老 JS 项目几乎一定有循环依赖,只是 JS 运行时对循环依赖很宽容(可能拿到 undefined,但不会报错),所以一直没人意识到问题。改成 TS 之后循环依赖会引发奇怪的类型错误,有时候是"找不到类型",有时候是"类型变成 never",非常难排查。我们用 madge 这个工具扫了一遍,扫出了 47 处循环依赖,全部修掉花了一周。

# 安装 madge
npm install -g madge

# 扫描循环依赖 (--circular 只输出环, --extensions 指定要扫的后缀)
madge --circular --extensions ts,js src/

# 输出示例:
# 1) src/models/user.ts > src/models/order.ts > src/models/user.ts
# 2) src/services/payment.ts > src/services/order.ts > src/services/payment.ts
# 47 circular dependencies found.

# 生成依赖图 (输出为 svg, 直观看依赖关系)
madge --image deps.svg --extensions ts src/

# CI 里加入循环依赖检查, 防止新增
madge --circular --extensions ts,js src/ && echo "no circular deps"

修循环依赖的标准手法是抽出共同依赖到一个独立模块。比如 user.ts 和 order.ts 互相依赖,通常是因为它们都引用了一些共享类型,把这些类型抽到 types/shared.ts,两边都引这个新模块,环就破了。少数情况下确实需要双向依赖,比如循环引用的数据结构,这时候要用接口隔离(在一个文件里只引另一个文件的接口,而不是具体实现),让 TS 编译时只看到类型签名。

第五个大坑:第三方库的 @types 滞后或冲突

npm 生态里有些库有官方类型(包内自带 .d.ts),有些靠 DefinitelyTyped 社区维护(@types/xxx),还有少数没有任何类型。这三种情况要处理的方式不一样,而且经常出现 @types 版本滞后(比如库已经升到 5.0,@types 还停留在 4.x),导致类型签名对不上。我们项目里有过两个真实案例:@types/express 比 express 主包慢一个大版本,导致某些 API 类型不对;@types/lodash 包巨大(几兆),拖慢编译速度,我们最后改用 lodash-es 自带类型。

# 查一个库有没有官方类型
npm view express types       # 自带类型
npm view express typings     # 旧字段, 兼容

# 查 DefinitelyTyped 是否有 @types
npm view @types/some-lib version

# 强制锁定 @types 版本和主包匹配 (package.json)
{
  "dependencies": {
    "express": "4.19.2"
  },
  "devDependencies": {
    "@types/express": "4.17.21"
  }
}

# 极少数情况, 自己写 .d.ts
# src/types/legacy-lib.d.ts:
declare module 'legacy-lib' {
  export function doSomething(input: string): number;
  export const VERSION: string;
}

# 写完之后 import 就有类型了
import { doSomething } from 'legacy-lib';

这一块工作非常琐碎,我们专门安排一个同学每周清一次依赖,看看有没有 @types 滞后、有没有重复的类型定义、有没有可以替换的方案。三个月清下来,package.json 里的 devDependencies 从 142 个降到 88 个,体感清爽了不少,CI 拉依赖也快了 30%。

开 strict 模式:最后一公里最痛苦

所有 .js 改完 .ts 之后,真正的硬仗才开始:把 tsconfig 里的 strict 选项一个一个打开。strict 实际上是 8 个独立选项的总和(strictNullChecks、noImplicitAny、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis、useUnknownInCatchVariables、alwaysStrict),我们的策略是一个一个开,每开一个就修完所有报错再开下一个,避免一次性面对几千个错误。

{
  "compilerOptions": {
    // 阶段 1 (第 6-7 周): 先开这两个, 影响最广
    "noImplicitAny": true,
    "strictNullChecks": true,

    // 阶段 2 (第 8-9 周): 函数和绑定相关
    "strictFunctionTypes": true,
    "strictBindCallApply": true,

    // 阶段 3 (第 10-11 周): 类相关
    "strictPropertyInitialization": true,
    "noImplicitThis": true,

    // 阶段 4 (第 12 周): catch 和最后的边角
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true,

    // 等价于:
    // "strict": true,

    // 同步开启这些, 进一步收紧:
    "noImplicitReturns": true,           // 函数所有路径必须返回
    "noFallthroughCasesInSwitch": true,  // switch case 必须 break
    "noUncheckedIndexedAccess": true,    // arr[i] 返回 T | undefined
    "exactOptionalPropertyTypes": true,  // 可选字段不能显式赋 undefined
    "noPropertyAccessFromIndexSignature": true
  }
}

这八个 strict 选项里,strictNullChecks 是最痛苦的,因为老代码里大量使用 nullundefined 不加区分。打开之后我们修了大约 1800 处报错,主要模式是"参数可能为空但调用方没处理"。修法基本是三选一:加 if 守卫、用可选链 ?.、或者从源头就保证非空(比如函数签名改成必传)。这一块如果你的项目还没开 strictNullChecks,我强烈建议尽早开,任何 TS 项目不开这个等于自废武功。

四种 TS 迁移路线的真实对比

路线 预估周期 风险 实际产出 推荐场景
全量重写 6-12 个月 极高 代码很干净但业务可能错 3000 行以内小项目
JSDoc 渐进 1-2 个月 类型表达力差, 易回退 过渡过渡, 不推荐长期
allowJs 渐进 2-4 个月 可控可逐步收紧 大多数中型项目首选
分模块独立迁 3-6 个月 中低 新模块用 TS, 老模块保留 monorepo 或微服务架构

CI 流水线的迁移配套

代码改造的同时,CI 也要跟上。我们的 CI 在迁移期间分了三道闸:第一道是 tsc --noEmit 跑类型检查(初期允许失败,逐步收紧);第二道是 ESLint 跑代码规范;第三道是 jest 跑单元测试 + 集成测试。这三道都通过才能合并,任何一道失败 block 合并。

# .github/workflows/ci.yml (节选)
name: CI

on:
  pull_request:
    branches: [main, develop]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npx tsc --noEmit -p tsconfig.test.json

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx eslint --max-warnings 0 'src/**/*.{ts,js}'
      - run: npx madge --circular --extensions ts,js src/

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx jest --coverage --maxWorkers=2
      - uses: codecov/codecov-action@v4

这里有个细节:--max-warnings 0 是 ESLint 把 warning 当成 error 处理,适合"零警告"政策。我们一开始没开这个,结果 warning 越积越多,后来不得不专门花一周清掉所有 warning,再开 --max-warnings 0 防止反复。任何团队都应该坚持"零警告",一旦开放警告积累,以后清理成本是线性增长的。

迁移过程中放弃过的两条歧路

第一条歧路是用 ts-migrate 工具自动迁移。Airbnb 出过一个 ts-migrate 自动化迁移工具,理论上能自动把 .js 改成 .ts、自动加 any、自动跑 codemod。我们在第二周试了一下,在前 10 个文件上效果还行,但越往后越乱:工具加的 any 太多,某些复杂类型推断完全错,而且生成的代码风格和团队约定不符,后期清理成本比手动迁还大。我们最后放弃了这条路,改回纯手工。

第二条歧路是同时升级 Node.js 大版本。原项目跑在 Node 14,我们一度想"既然要大改,干脆 Node 也升到 20"。但很快发现 Node 升级会引入一堆兼容性问题(某些老依赖在 Node 18+ 跑不通,某些 native module 要重编译),和 TS 迁移叠加在一起复杂度爆炸。我们在第三周果断退回,只做 TS 迁移,Node 升级单独立项。这种"一次只做一件事"的纪律救了我们好几次。

第三条差点走的歧路是顺手切到 Deno 或 Bun。Deno 原生支持 TypeScript 不需要 tsc,Bun 启动飞快又自带打包,在内部分享会上有同事提出"既然都要大改,干脆运行时也换了"。我们认真评估了两天,结论是生态兼容性远没有想象中好:Deno 对 npm 兼容是后期才加的,我们用的 Express 中间件生态有一半在 Deno 上跑不顺;Bun 对 native module 的支持还在打磨期,我们依赖的 mongodb driver 有偶发崩溃。最后决定继续 Node + tsc,等 Deno / Bun 在生态成熟两到三年再考虑。这种"新技术诱惑"在大改造里特别危险,坚持只做必要的事,把"想做的事"延后,是大型项目能成功收口的关键。

真实收益:90 天后的数据

指标 迁移前 (JS) 迁移后 (TS) 变化
每月生产事故 8-12 次 2-3 次 降 70%
类型相关事故占比 38% 4% 降 90%
新人上手时间 3-4 周 1-2 周 降 50%
code review 时间 1.5 小时/PR 40 分钟/PR 降 55%
构建时间 22s (webpack) 35s (tsc + webpack) 慢 60%
IDE 自动补全可用率 30% 95% 大幅提升
团队满意度 5/10 9/10 显著改善

这里要特别说一下"构建时间慢了 60%"这一项。tsc 编译本身确实比 babel + JS 慢,而且 strict 模式下类型检查耗时也明显。我们后来用 swc / esbuild 替换了一部分构建链,把构建时间压回了 18s(比迁移前还快),但类型检查仍然需要 tsc,这一段时间无法砍掉。这个权衡是"类型安全 vs 构建速度"的经典选择,99% 的团队应该选类型安全,构建速度的损失完全可以用增量编译、watch 模式来缓解。

两个让我们后悔没早做的优化

迁完之后回头看,有两个 TS 高级特性我们后悔没在第一天就引入,后期补做时已经积累了不少历史包袱要清。第一个是 Branded Types(品牌类型 / nominal typing),用来给"看起来都是 string 但语义不同"的 ID 加区分,防止把 UserId 当成 OrderId 用。我们历史上至少有 4 次生产事故是这种 ID 错传引起的,任何静态类型检查都救不了,因为它们都是 string。Branded Types 用一个 trick 让 TS 在类型层面区分两个 string,运行时零成本。

// 反模式: 用 type 别名, 等于没区分
type UserId = string;
type OrderId = string;

function getOrder(id: OrderId) { /* ... */ }

const userId: UserId = 'u_123';
getOrder(userId);  // ← 编译通过, 但实际是 bug

// 修法: Branded Types
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

// 构造函数 (运行时只是 cast, 零成本)
const UserId = (s: string) => s as UserId;
const OrderId = (s: string) => s as OrderId;

function getOrder(id: OrderId) { /* ... */ }

const userId = UserId('u_123');
getOrder(userId);  // ← 编译错误! Type 'UserId' is not assignable to 'OrderId'
getOrder(OrderId('o_456'));  // ← OK

// 升级版: 配合 zod 同时做运行时校验
import { z } from 'zod';
const UserIdSchema = z.string().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
const uid = UserIdSchema.parse(req.params.userId);  // 类型 + 校验 一行搞定

引入 Branded Types 之后,我们把所有"业务 ID"的定义都改成了 branded,大约动了 230 个地方,但效果立竿见影:之后半年的事故里再没有一例 ID 错传,这种"用类型系统消灭整类 bug"的体感非常爽。Branded 也可以用来区分单位(Meters / Feet)、时区(UTCTime / LocalTime)、清洁状态(SanitizedHtml / RawHtml),思路完全一样。

第二个后悔的是 noUncheckedIndexedAccess 没在第一天就开。这个选项强制 arr[i]obj[key] 的返回类型变成 T | undefined,因为下标访问本来就可能拿不到值。这一改让代码里冒出几百个"忘记处理 undefined"的隐藏 bug,虽然修起来烦,但每一个被修掉的都是真实存在的潜在崩溃。我们后期补开时修了 380 处报错,其中有 17 处是已经在生产引发过偶发故障的真 bug。强烈建议任何新 TS 项目第一天就开 noUncheckedIndexedAccess,代价远小于收益。

团队立的 10 条 TS 工程纪律

  1. any 必须有理由:每一处 any 必须有注释 // FIXME(any): xxx 或 @ts-expect-error,CI 跟踪 any 数量趋势,只能减不能增。
  2. strict 全开不打折:strict: true + noUncheckedIndexedAccess + exactOptionalPropertyTypes 是标配,新项目从一开始就开。
  3. 禁止 // @ts-ignore:用 @ts-expect-error 替代,后者在错误消失时会报错,提醒你删掉这一行。
  4. 边界用 zod 校验:HTTP 入口、消息队列消费者、数据库读取等所有外部数据入口必须 zod 校验,内部代码相信类型。
  5. 禁止隐式 export:所有 export 必须显式,不允许 export * from,容易造成意外的 API 泄露。
  6. 禁止 enum,改用 const + as const:enum 在 TS 里有运行时开销且和 tree-shaking 不友好,用 const X = { ... } as const 替代。
  7. 类型抽到独立文件:每个模块的类型定义放 types.ts,业务逻辑放 index.ts,清晰分层。
  8. 泛型不超过 3 层:超过 3 层泛型嵌套必须 PR 评审解释,过度泛型化是技术债的开端。
  9. 禁止 namespace:用 ES module 替代 TS namespace,后者是历史遗留特性。
  10. 测试也要 TS:测试代码也用 .ts,享受同样的类型安全,而不是 "测试无所谓" 的态度。

给同行的几条建议

第一条建议是不要低估迁移工作量。我们 5 万行项目原计划 30 天,实际 102 天,几乎是预估的 3.4 倍。任何 JS→TS 迁移项目都应该按"预估 × 3"的余量来规划,千万别给管理层报激进时间表,做不完只会自己背锅。

第二条建议是渐进式迁移是唯一选择。除非项目特别小(几千行以内),否则不要尝试"全量重写"或"暂停业务集中迁移"。业务永远不会停,迁移必须能和日常开发并行,allowJs 模式就是为此而生。

第三条建议是strict 必须早开,但要分步开。strict 是 TS 的灵魂,不开 strict 等于白用 TS。但一次性开八个选项会被几千个错误淹没,正确做法是按本文的阶段表一步步开,每步只面对一类错误。

第四条建议是类型不是终点,是工具。TS 不能解决业务 bug,只能解决"类型不匹配"这一类 bug。其他类型的 bug(逻辑错、边界条件错、并发错)仍然要靠测试、code review、运行时校验来防护。把 TS 当成"银弹"是迁移失败的常见原因。

第五条建议是让 IDE 和 CI 一起当裁判。本地 VS Code 给你即时反馈,CI 兜底防止漏网,两者必须配置一致(同一份 tsconfig、同一个 TS 版本、同一套 ESLint 规则),否则会出现"本地能编 CI 报错"或者反过来的尴尬。我们在仓库根目录放了 .vscode/settings.json 把 TS 版本和路径都写死,确保所有人 IDE 行为一致,这条小事节省了无数次扯皮时间。

Project References:大型仓库的编译加速利器

项目过了 4 万行之后我们发现 tsc --noEmit 一次全量类型检查要跑 28 秒,本地开发体验直线下降。后来引入了 TypeScript 的 Project References 特性,把单一大项目拆成 4 个子项目(core / api / workers / shared),互相通过 references 引用,tsc 可以增量编译只重做改动的子项目,全量检查时间压到了 9 秒,改单文件的增量检查只要 1.2 秒。这是大项目几乎必须用的特性,但因为配置复杂,很多团队即使知道也没用上。

// 根 tsconfig.json: 不直接编译, 只引用子项目
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/core" },
    { "path": "./packages/api" },
    { "path": "./packages/workers" }
  ]
}

// packages/shared/tsconfig.json: 基础类型库
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,         // ← 启用 project reference 必须开
    "declaration": true,        // ← 输出 .d.ts 给上游用
    "declarationMap": true,     // ← 输出 .d.ts.map, 跳转源码用
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

// packages/api/tsconfig.json: 依赖 shared
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../shared" }   // ← 明确依赖, tsc 会先编 shared
  ],
  "include": ["src/**/*"]
}

启用 Project References 之后命令也要变:tsc --build(或者 tsc -b),不是裸 tsctsc -b --watch 会智能 watch 所有子项目,任何文件变动只重编必要部分。CI 也建议改成 tsc -b --force(强制全量,避免缓存陈旧)。这套机制和 Bazel / Nx 的增量编译思路一致,本质是把"依赖图 + 缓存"做进了 TS 自己,小项目用不上,但行数过万就值得引入。

顺手享受 TS 5.x 的几个新特性

迁移收口之后我们升到 TypeScript 5.3,有几个新特性在实际业务里收益挺明显,值得专门提一下。第一是 const 类型参数(TS 5.0 引入),让泛型可以保留字面量类型而不是被宽化成 string;第二是 satisfies 操作符(TS 4.9 引入,5.x 完善),既能做类型校验又能保留具体推导;第三是装饰器从 experimental 转正(TS 5.0),终于可以放心用了;第四是 import attributes(TS 5.3),让 JSON / CSS 等非 JS 资源的导入更规范。

// 1. const 类型参数: 泛型保留字面量
function pick<const T extends readonly string[]>(arr: T): T[number] {
  return arr[0];
}
const r = pick(['a', 'b', 'c']);
// 没有 const: r 的类型是 string
// 加了 const: r 的类型是 'a' | 'b' | 'c'

// 2. satisfies: 既要类型约束, 又要保留字面量推导
type Config = Record<string, string | string[]>;

// 反模式: 用 as Config, 失去具体推导
const config1 = {
  host: 'localhost',
  ports: ['8080', '8081']
} as Config;
config1.ports.map(p => p);  // p 类型变成了 string | string[], 错误

// 正确: satisfies
const config2 = {
  host: 'localhost',
  ports: ['8080', '8081']
} satisfies Config;
config2.ports.map(p => p);  // p 是 string, 类型正确

// 3. 装饰器 (转正后, 不再需要 experimentalDecorators)
function logged(target: any, ctx: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) {
    console.log(`[${String(ctx.name)}] called with`, args);
    return target.apply(this, args);
  };
}

class OrderService {
  @logged
  createOrder(userId: string, amount: number) {
    return { id: 'o_' + Date.now(), userId, amount };
  }
}

// 4. import attributes (TS 5.3+, 替代 import assertions)
import data from './data.json' with { type: 'json' };

这几个特性里 satisfies 是我们用得最多的,几乎所有"配置常量"的定义都改成了 satisfies SomeType 模式,既有类型保证又保留了具体的字面量信息,在做精细类型推导(比如根据配置 key 推导 value 类型)时非常香。const 类型参数也很有用,主要在写工具函数(比如自定义的 select / pick / pluck)时让返回类型更精确。

团队 onboarding 文档:新人的第一周

迁移完成后我们重写了 onboarding 文档,把"读完才能动代码"的清单减到了 12 项,新人按这个清单走,一周内基本能独立交付小需求。这套文档的核心思路是把规矩变成可学习的清单,而不是散落在各种 PR 评论里。这种"工程文化的显式化"是任何成熟团队都应该做的事,迁移本身是个好契机。

三个月之后的回访

迁移收口三个月后我们做了一次回访,几个真实体感值得分享。第一,开发幸福感显著提升:IDE 自动补全、refactor 工具、go to definition 这些以前只能在 IDEA 里享受的能力,现在在 VS Code + TS 也都有了,团队同学普遍反映"再也不想写 JS 了"。第二,新人上手时间从 3-4 周缩短到 1-2 周:类型签名本身就是文档,新人不需要再去翻散落在各处的注释和实际调用方式去猜函数参数。

第三,跨团队协作变顺畅:我们和前端 React 团队共享了一批类型定义(放在 shared-types 包里),后端定义的 API 入参类型,前端可以直接引用,杜绝了"后端改了 schema 没告诉前端"的事故。这种类型作为团队间契约的用法,是 TypeScript 最大的工程价值之一。

总结

这次 90 天的 JS→TS 迁移给我们最大的收获不是"代码更安全了",而是团队对类型系统的认知发生了根本变化。之前大家觉得 TypeScript 是"加点类型注解的 JS",迁完才意识到 TS 是一个独立的、强大的、有完整工程化体系的语言,正确使用它需要重新学习架构组织、模块划分、契约设计。这种认知升级比代码本身的改变更有价值。

给所有正在犹豫"要不要迁 TS"的团队一个建议:迁,越早越好。每多积累一个月的 JS 代码,迁移成本就增加一份。如果项目还在快速迭代,现在是最好的时机;如果项目已经稳定,那就把它列入下半年的 OKR,集中两到三个月把这事做完。这次投入带来的回报会在未来三五年里持续兑现,是少数"绝对值得做"的技术升级之一。如果你正在准备启动这件事,这篇文章里所有踩过的坑都可以直接拿去复用,愿你少走一些我们走过的弯路,把节省下来的时间留给真正的业务创新。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

C# async-over-sync 反模式 + HttpClient 端口耗尽两连击雪崩复盘:从 P99 11s 到 115ms 的全过程

2026-5-25 16:42:17

技术教程

LangGraph 多 Agent 协作:从 3 Agent 互相调死循环到稳态 ReAct 链的 8 周复盘

2026-5-25 17:06:57

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索