老项目迁 TypeScript 最稳的路线不是一次性全量改造,而是「先建基线、再分批收紧」:先用 allowJs 把项目跑起来,按依赖方向从底层模块开始分批开启 strict,每一批只交给 AI 有限范围的文件,并用 tsc --noEmit 的报错条数作为收敛指标。这样做的好处是迁移可以随时停在任意一批,仓库始终可发布。
为什么全量迁移几乎必失败
全量迁移的典型做法是:跑一遍转换脚本,把 .js 全改成 .ts,然后面对几千条报错。问题不在报错多,而在于三个后果同时出现:
- 报错无法归因:类型错误互相掩盖,改一处可能”修好”另一处,实际只是把错误挪了位置。
- 评审失效:一次几千行的 diff 没人能真正 review,AI 的改动也就没人兜底。
- 无法回退:迁移分支和主分支长期分叉,合回去时业务代码已经变了。
分批方案的核心是把这三点拆掉:每批 diff 小到可以评审,报错数只降不升,随时能合并回主干。
第一步:建立可度量的类型基线
在让 AI 动手之前,先把”现状”固化下来,否则你无法判断迁移是在前进还是在原地打转。
1. 让 TypeScript 接管但不阻断
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true,
"strict": false,
"target": "ES2022",
"moduleResolution": "bundler",
"skipLibCheck": true
},
"include": ["src"]
}
allowJs 是关键:它让 .js 与 .ts 共存,迁移期间不需要一次性重命名。checkJs 先关掉,避免一开始就淹没在旧代码的类型错误里。
2. 补依赖类型
先跑一次 npm i -D typescript @types/node,再逐个补第三方库的 @types/*。缺类型声明的库先加一行 declare module "xxx"; 占位,记为技术债,不要在第一批就处理。
3. 记录基线数字
把 tsc --noEmit | wc -l 的报错条数、any 出现次数、.js 文件数写进一个 MIGRATION.md。这三个数字就是后面每批 PR 的验收标准。
第二步:分批顺序按依赖方向排,不按目录排
很多团队按目录顺序迁,结果底层工具函数还是 any,上层的类型推导全是 any,白做。正确顺序是被依赖最多的先迁:
| 批次 | 范围 | 开启选项 | 验收标准 |
|---|---|---|---|
| 第 0 批 | 常量、枚举、类型定义文件 | strict: false | 纯声明,零报错 |
| 第 1 批 | utils / helpers / 数据模型 | strict: false | 对外导出全部有显式类型 |
| 第 2 批 | service / repository 层 | strictNullChecks: true | 报错数比基线下降 |
| 第 3 批 | controller / 路由 / 组件 | strict: true | 该目录 any 归零 |
| 收尾 | 全仓库 | strict: true + checkJs: false | 剩余 .js 有明确迁出计划 |
判断”被依赖最多”不用猜,一条命令即可:
# 按被 import 次数倒序列出文件,排在前面的就是第一批
rg -o "from ['\"]([^'\"]+)['\"]" -r '$1' src | sort | uniq -c | sort -rn | head -30
第三步:把哪些活交给 AI,哪些活留给人
AI 在迁移里最值钱的是”机械但费眼”的部分,而不是架构判断。实测下来分工大致是:
适合交给 AI 的四类改动(点击展开)
- 从运行时值反推类型:给它几段真实入参样本,让它写出接口定义,比人肉读代码快得多。
- 补 JSDoc 类型标注:对还没转成
.ts的.js文件,先加 JSDoc 风格的@param/@returns,配合checkJs就能提前享受类型检查。 - 批量收敛
any:让它把any换成unknown并补上必要的类型守卫,这一步人做极易漏。 - 生成迁移报告:每批结束后让它输出”改了哪些公共签名、是否有行为变更”,直接贴进 PR 描述。
反过来,这三件事不要交给 AI 自行决定:是否改变函数签名、是否引入运行时校验、是否为兼容旧调用方保留重载。
四个几乎必踩的坑
坑一:第三方库没有类型声明
不要急着写完整 .d.ts。先用最小声明顶住,把真正的类型工作推到专项批次:
// types/shims.d.ts
declare module "legacy-chart-lib" {
const render: (el: HTMLElement, opts: Record<string, unknown>) => void;
export default render;
}
坑二:this 的隐式 any
老代码里大量 function 声明 + call/apply,转 TS 后 this 全是隐式 any。对策是在开启 noImplicitThis 之前,先把这些函数逐个确认调用方式,能改箭头函数就改,改不了的加显式 this 参数。
坑三:动态属性访问
obj[dynamicKey] 在 strict 下直接报错。正确做法不是加 any,而是把键收窄成联合类型或用 Map:
type StatusKey = "draft" | "published" | "archived";
const labelMap: Record<StatusKey, string> = {
draft: "草稿", published: "已发布", archived: "已归档",
};
坑四:构建配置没跟上
编译过了不代表打包能过。 Babel 项目要加 @babel/preset-typescript,Vite 项目确认 esbuild 能处理 .ts,Webpack 要检查 resolve.extensions 是否包含 .ts。这一步建议单独一个 PR,不混在类型批次里。
迁移期的日常怎么排
最实用的节奏是”顺手迁”:开发某个功能时,把该功能触及的文件顺带纳入当前批次。这样类型覆盖率随业务迭代自然上升,不需要专门的迁移排期。判断某个文件值不值得迁,和用 AI 重构遗留代码的思路一致——看修改频率、复杂度和被依赖程度,三者都低的文件可以先不动。
- 确定本批文件清单(≤10 个文件),写入 MIGRATION.md,记录当前 tsc 报错数。
- 补依赖的 @types 或最小 shims 声明,保证报错只来自本批文件。
- 把文件清单 + 不改公共签名的边界条件一起交给 AI,要求逐文件输出改动说明。
- 跑 npx tsc –noEmit 对比报错数,跑一次完整测试套件确认行为未变。
- 提交 PR,diff 控制在可评审规模;合并后更新 MIGRATION.md 的三个基线数字。
和其他 AI 编程流程怎么衔接
迁移过程中会顺带遇到两类问题:依赖版本不兼容,以及 AI 生成代码引入的新风险。前者可以参考用 AI 做依赖升级的完整流程,后者建议在每批 PR 里固定跑一遍AI 生成代码安全审查清单。如果你习惯让 AI 先出方案再动手,那么迁移这种大改动尤其需要Plan/Act 分离的工作流——先让它列出分批计划,你确认后再逐批执行。
老项目迁 TypeScript 一定要全部转成 .ts 文件吗?
allowJs 开启后 .js 可以长期保留,只要在 .d.ts 或 JSDoc 里给出类型即可。真正需要重命名的是”正在被高频修改”的文件,其余保持原样不影响类型收益。strict 应该一次全开还是逐项开?
strictNullChecks → noImplicitAny → strictFunctionTypes → 其余。strictNullChecks 收益最大、也最容易暴露真实缺陷,优先开它性价比最高。AI 生成的大量类型定义可信吗?
迁移期间报错数反而变多了怎么办?
any 把错误压下去。一句话收尾:TypeScript 迁移不是一次改造,而是一条持续收敛的曲线。把基线数字、分批顺序、 AI 的改动边界这三件事固定下来,曲线就会稳定向下。










评论 (0)