Ajax玉兔Ajax玉兔AJAX.BLOG
返回文章列表
2026 年 8 月20 日3 分钟阅读

如何快速由 JS 转 TS:一条不推翻重写的渐进迁移路径

不重写、不停服。用 allowJs + JSDoc + 严格模式分级,把老 JS 项目一步步迁移到 TypeScript,附完整 tsconfig 与排错清单。

TypeScriptJavaScript工程化

先选路线:渐进混跑,而不是推翻重写

JS 转 TS 有三条路:

路线做法适合
推倒重写新起 TS 项目,重抄一遍项目小于五千行且即将大改
双轨并行老代码不动,新模块用 TS团队没人懂 TS,需要练兵期
渐进混跑同一项目里 .js 与 .ts 共存,逐文件迁移有存量代码、不能停业务(大多数人的处境)

上一篇讲了为什么值得转,这篇讲怎么转。核心思路只有一句:让 TS 编译器从第一天起就为你工作,但一开始别逼它。

Step 1:让项目「认识」TS,但先不严格

装好 typescript,新建 tsconfig.json,第一版配置的关键是打开编译、关掉严格:

json
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "allowJs": true, // 允许 .js 文件参与编译 "checkJs": false, // 但先不检查它们——避免第一天被几千个错误淹没 "strict": false, // 同上,非严格模式起步 "noEmit": true, // 只做类型检查,产出仍交给打包器 "skipLibCheck": true // 跳过第三方库声明文件检查,加速 + 少踩坑 }, "include": ["src"] }

allowJs 是渐进迁移的地基:它让 .ts 文件可以直接 import 现有的 .js 模块,编译器和打包器都不报错。从此你可以今天迁一个文件,明天迁一个文件,中间状态完全可发布。

Step 2:从叶子模块开始改文件名

迁移顺序有讲究:自底向上,先叶子后主干。被依赖最少的工具函数、常量、类型定义先改,调用方暂不动;等依赖全部落地,再改业务组件。

bash
# 一个典型的迁移批次 src/utils/format.js → src/utils/format.ts src/api/client.js → src/api/client.ts # 依赖 format src/pages/home.js → src/pages/home.ts # 最后才动它

每改一个文件,tsc --noEmit 一次。错误只允许来自刚改的文件和它的直接调用方——如果错误炸到半个项目,说明这个文件的依赖还没迁完,回退它。

Step 3:不想改名的文件,用 JSDoc 先拿类型检查

有些文件短期不适合改名(比如构建脚本、配置文件)。TS 支持在 .js 文件里用 JSDoc 注释获得类型能力,是性价比极高的中间态:

js
/** * @param {string} noteId * @param {{ tags?: string[] }} options * @returns {Promise<import('./types').Note>} */ export async function getNote(noteId, options) { // ... }

配合 checkJs: false(只检查显式标注的文件)或对该文件开 // @ts-check,可以精准控制检查范围。

Step 4:处理「隐式 any」的正确姿势

老 JS 代码最大的噪音来源是隐式 any:函数参数没类型、解构没类型、JSON 解析没类型。迁移期正确姿势是先标 any 保住编译通过,再逐个收紧:

ts
// 迁移第一版:先让编译器闭嘴 function handleResponse(res: any) { return res.data.list; } // 迁移第二版(同一次发版或下次迭代):换成真实类型 interface ListResponse<T> { data: { list: T[]; total: number }; } function handleResponse<T>(res: ListResponse<T>) { return res.data.list; }

别一步到位开 strict

strict 不是开关而是五个一组开关。一次全开会得到几千个错误,然后团队用一周修完、再也不敢看错误列表。分级打开才有节奏感。

Step 5:严格模式的分级打开顺序

按这个顺序,每级之间隔几个迭代:

  1. noImplicitAny: true —— 消灭隐式 any,工作量最大,收益也最大
  2. strictNullChecks: true —— 强制处理空值,会暴露一批潜在崩溃
  3. strictFunctionTypes / strictBindCallApply —— 函数签名收紧
  4. strict: true(全开)—— 剩下的 strictPropertyInitialization、noImplicitThis 等一次收尾

我的实际节奏:一个三万多行的项目,noImplicitAny 阶段每天迁移两三个文件,两周清完;strictNullChecks 阶段集中处理了约 200 处空值风险,其中真炸出过 3 个历史 bug。

Step 6:第三方库没有类型声明怎么办

按优先级尝试:

  1. 库自带类型(package.json 有 types 字段)——直接用,什么都不用做
  2. @types/xxx 社区包——npm i -D @types/xxx
  3. 自己写声明文件——建 src/types/xxx.d.ts:
ts
// 某个没有类型的老库 declare module "legacy-widget" { export function init(el: HTMLElement, options?: Record<string, unknown>): void; export function destroy(): void; }
  1. 兜底:// @ts-ignore(会连带忽略行内所有错误,慎用)或 // @ts-expect-error(下一行没有错误反而报错,更适合临时压制)。

常见坑清单

坑现象解法
import 路径带 .js 扩展名部分场景下 .ts 文件 import ./x.js 报 TS5097(扩展名不允许)bundler 模式下去掉后缀;或开 allowImportingTsExtensions
JSON 导入无类型import data from './x.json' 是 anyresolveJsonModule: true
CSS/图片等非代码资源TS2307 找不到模块写 declare module "*.css" 通配声明
Node 全局变量报错process、__dirname 不存在npm i -D @types/node
老库类型与实现不符运行时才对用 as unknown as T 双重断言兜底,加注释说明

时间账与心态

  • 五千行以内:一个周末
  • 三万行左右:两到三周,每天一小时
  • 心态要点:迁移期的目标是「类型覆盖率上升」,不是「零错误」。每把一个文件的 any 换成真实类型,就是一次净赚;中途放弃的人,多是第一天就开了 strict。

下一篇回到算法:《N 皇后问题的 DFS 解法》,顺便展示 TS 写算法题的正确姿势。