如何快速由 JS 转 TS:一条不推翻重写的渐进迁移路径
不重写、不停服。用 allowJs + JSDoc + 严格模式分级,把老 JS 项目一步步迁移到 TypeScript,附完整 tsconfig 与排错清单。
先选路线:渐进混跑,而不是推翻重写
JS 转 TS 有三条路:
| 路线 | 做法 | 适合 |
|---|---|---|
| 推倒重写 | 新起 TS 项目,重抄一遍 | 项目小于五千行且即将大改 |
| 双轨并行 | 老代码不动,新模块用 TS | 团队没人懂 TS,需要练兵期 |
| 渐进混跑 | 同一项目里 .js 与 .ts 共存,逐文件迁移 | 有存量代码、不能停业务(大多数人的处境) |
上一篇讲了为什么值得转,这篇讲怎么转。核心思路只有一句:让 TS 编译器从第一天起就为你工作,但一开始别逼它。
Step 1:让项目「认识」TS,但先不严格
装好 typescript,新建 tsconfig.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:从叶子模块开始改文件名
迁移顺序有讲究:自底向上,先叶子后主干。被依赖最少的工具函数、常量、类型定义先改,调用方暂不动;等依赖全部落地,再改业务组件。
# 一个典型的迁移批次
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 注释获得类型能力,是性价比极高的中间态:
/**
* @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 保住编译通过,再逐个收紧:
// 迁移第一版:先让编译器闭嘴
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:严格模式的分级打开顺序
按这个顺序,每级之间隔几个迭代:
noImplicitAny: true—— 消灭隐式 any,工作量最大,收益也最大strictNullChecks: true—— 强制处理空值,会暴露一批潜在崩溃strictFunctionTypes/strictBindCallApply—— 函数签名收紧strict: true(全开)—— 剩下的strictPropertyInitialization、noImplicitThis等一次收尾
我的实际节奏:一个三万多行的项目,noImplicitAny 阶段每天迁移两三个文件,两周清完;strictNullChecks 阶段集中处理了约 200 处空值风险,其中真炸出过 3 个历史 bug。
Step 6:第三方库没有类型声明怎么办
按优先级尝试:
- 库自带类型(
package.json有types字段)——直接用,什么都不用做 @types/xxx社区包——npm i -D @types/xxx- 自己写声明文件——建
src/types/xxx.d.ts:
// 某个没有类型的老库
declare module "legacy-widget" {
export function init(el: HTMLElement, options?: Record<string, unknown>): void;
export function destroy(): void;
}- 兜底:
// @ts-ignore(会连带忽略行内所有错误,慎用)或// @ts-expect-error(下一行没有错误反而报错,更适合临时压制)。
常见坑清单
| 坑 | 现象 | 解法 |
|---|---|---|
import 路径带 .js 扩展名 | 部分场景下 .ts 文件 import ./x.js 报 TS5097(扩展名不允许) | bundler 模式下去掉后缀;或开 allowImportingTsExtensions |
| JSON 导入无类型 | import data from './x.json' 是 any | resolveJsonModule: true |
| CSS/图片等非代码资源 | TS2307 找不到模块 | 写 declare module "*.css" 通配声明 |
| Node 全局变量报错 | process、__dirname 不存在 | npm i -D @types/node |
| 老库类型与实现不符 | 运行时才对 | 用 as unknown as T 双重断言兜底,加注释说明 |
时间账与心态
- 五千行以内:一个周末
- 三万行左右:两到三周,每天一小时
- 心态要点:迁移期的目标是「类型覆盖率上升」,不是「零错误」。每把一个文件的
any换成真实类型,就是一次净赚;中途放弃的人,多是第一天就开了 strict。
下一篇回到算法:《N 皇后问题的 DFS 解法》,顺便展示 TS 写算法题的正确姿势。