从 JavaScript 迁移到 TypeScript 这件事,网上有很多"30 天迁移指南""从零到百万行 TypeScript"之类的教程。但真实项目里的迁移不是"做完一个 checklist 就结束"的线性过程——它是一次持续的、和遗留代码共存的工程治理。
迁移策略选择
大方向上,迁移有两种策略:
方案 A:渐进文件级迁移(推荐)
逐个文件从 .js 重命名为 .ts,边改边补充类型。其他文件仍然保持 .js,通过 allowJs 让两者共存。
方案 B:大爆炸迁移
所有文件一次性改成 TypeScript,然后统一修复类型错误。
绝大多数项目应该选方案 A。方案 B 只适用于文件数少于 20 个、逻辑简单的小项目。真实的项目中,文件数上百甚至上千,一次性修复的成本远高于收益。
第一步:最小化 tsconfig 起步
不要一上来就开 strict: true。先让代码能跑起来:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
这个配置只做文件名检查和基本的语法校验,不报类型错误。skipLibCheck: true 跳过 node_modules 的类型检查——在迁移初期值得开,减少第三方库的类型报错噪音。
第二步:逐个文件重命名
从叶子依赖(不依赖其他内部模块的文件)开始,逐步向内推进:
# 先改工具函数/工具类型文件
mv src/utils/helpers.js src/utils/helpers.ts
# 再改数据层/API 层
mv src/api/users.js src/api/users.ts
# 最后改页面/组件层
mv src/pages/profile.jsx src/pages/profile.tsx
每个文件重命名后需要做的事:
1. 用最宽松的方式补类型(any 可以接受,先用着)
2. 确保 build 通过
3. 提交代码
4. 进入下一个文件
不要在一个文件上花超过 30 分钟去补精确的类型。第一阶段的目标是让所有文件都变成 .ts,而不是把所有类型都补对。
第三步:第三方类型方案
JavaScript 项目依赖的第三方包,类型来源有三种情况:
// 情况 1:自带类型(最好)
import axios from 'axios' // axios 自带 .d.ts
// 情况 2:DefinitelyTyped 有类型(大部分)
npm install @types/lodash --save-dev
// 情况 3:没有类型(最麻烦)
// import 'dep-without-types'; // 报错:找不到类型声明
// 方案一:在 src/globals.d.ts 中声明
declare module 'dep-without-types' {
export function doSomething(input: string): void
// 只声明需要用到的 API
}
// 方案二:直接 any
declare module 'dep-without-types' {
const value: any
export default value
}
方案一比二更好——它只声明实际用到的方法,而且当你升级依赖时,声明中的函数签名不匹配会帮你发现破坏性变更。
第四步:分阶段启用 strict 模式
当所有文件都变成 .ts 后,再逐步开启 strict 系列的检查项:
阶段 1:noImplicitAny(最常见的错误)
阶段 2:strictNullChecks(改动量最大,最痛苦也最有价值)
阶段 3:noUncheckedIndexedAccess(细化 object 索引访问类型)
阶段 4:strictFunctionTypes(函数参数逆变检查)
阶段 5:strict(完整的 strict 模式)
每个阶段之间的间隔,建议在一个 sprint 以上。给团队足够的时间去消化每个检查项带来的修复。
noImplicitAny 修复
这是最容易的一步。编译器会报"参数 X 隐式具有 any 类型",你只需要给所有函数参数加上类型标注:
// 修复前
function formatDate(date) { return date.toISOString() }
// 修复后
function formatDate(date: Date) { return date.toISOString() }
这一步主要是工作量,不是技术难度。
strictNullChecks 修复
这一步是迁移中最大的坎。开启后,所有可能为 null 或 undefined 的值都需要做 null 检查:
// 修复前
const el = document.getElementById('app')
el.innerHTML = 'hello' // 运行时可能 crash
// 修复后
const el = document.getElementById('app')
if (el) {
el.innerHTML = 'hello'
}
// 或者
const el = document.getElementById('app')!
el.innerHTML = 'hello' // 非空断言:确认它一定存在
非空断言(!)可以作为临时方案,但不要滥用。如果一个值在很多地方都有可能为 null,用断言维护成本很高,改为早期检查更可靠。
第五步:逐步收紧 any
noImplicitAny 和 strictNullChecks 修复之后,项目中可能还有大量显式的 any:
// 🔴 宽泛的 any(需要逐步收紧)
function fetchData(): Promise<any>
// 🟡 退一步到 unknown(比 any 安全)
function fetchData(): Promise<unknown>
// 调用方需要先做类型断言再使用
// 🟢 最终目标:精确类型
function fetchData(): Promise<{ users: User[]; total: number }>
收紧 any 的策略:从调用次数最多的函数开始改,因为收益最高。用 ESLint 的 @typescript-eslint/no-explicit-any 规则可以监控 any 的数量变化。
迁移过程中的实用工具
// tsconfig 中的过渡配置
{
"compilerOptions": {
// 允许 JS 文件和 TS 文件互相引用
"allowJs": true,
// 允许 JS 文件中有类 JSDoc 注释替代类型
"checkJs": false,
// 最大错误数——超过这个数就停止报错
"noErrorTruncation": true,
// 不要在增量编译时跳过类型检查
"forceConsistentCasingInFileNames": true
}
}
JSDoc 注释可以作为完整迁移前的过渡方案:
/**
* @param {string} name
* @param {number} age
* @returns {User}
*/
function createUser(name, age) {
return { name, age, id: generateId() }
}
如果 checkJs: true,编译器会根据 JSDoc 做类型检查。这样可以在不重命名文件的情况下获得部分类型安全。
常见陷阱
陷阱 1:过多使用 as 断言
// 🔴 错误的用法
const data = response.data as User[]
// 🟢 更好的方式——在获取时就断言
interface ApiResponse<T> {
data: T
error: string | null
}
const { data } = await api.get<ApiResponse<User[]>>('/users')
所有 as 断言都是对编译器撒谎。每次写 as 时问自己:能不能用泛型或类型守卫来获取这个类型?
陷阱 2:全局类型污染
// 🔴 把项目中的类型都放在一个 global.d.ts 里
declare namespace MyApp {
interface User { ... }
interface Config { ... }
}
// 🟢 用模块导入导出
export interface User { ... }
export interface Config { ... }
全局 types namespace 看起来方便——不需要 import 就能用。但它让文件之间的依赖关系变得不透明,迁移后期很难理清哪些文件实际用了哪些类型。
陷阱 3:忽略编译性能
随着类型越来越精确,编译时间会上升。在迁移中期容易遇到的问题:
// 过度复杂的类型会拖慢编译器
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
// 如果 T 是一个深度嵌套的大型接口,类型实例化会非常慢
遇到编译性能问题时,检查是否有递归类型实例化超过了适度深度,或者联合类型规模过大。skipLibCheck: true 对性能有显著帮助——它跳过所有 .d.ts 文件的检查。
验收标准
迁移的每个阶段完成后,确认以下指标:
| 指标 | 初始 | 阶段 1 | 阶段 2 | 阶段 3 |
|---|---|---|---|---|
| .ts 文件占比 | 0% | 100% | 100% | 100% |
| any 数量 | 大量 | 较多 | 减少 | 极少 |
| strict 模式 | off | off | null check | full |
| 编译错误数 | 0(没检查) | 0(宽松) | 0(已修复) | 0(已修复) |
| 运行时类型错误 | 常有 | 减少 | 很少 | 极少 |
总结
从 JS 迁移到 TypeScript 最有效的路径不是"一次到位",而是"五分走":
- 最小化配置——先让 TS 编译器运行起来
- 逐个文件重命名——从叶子文件开始,改完就提交
- 补第三方类型——从自带类型到 DefinitelyTyped 到手动声明
- 分阶段开启 strict——noImplicitAny 先行,strictNullChecks 最关键
- 逐步收紧 any——从 hot path 开始,用 lint 监控进度
每一步的收益都可以独立评估。如果某一步停下来了(改了配置文件但修复代价太大),退回前一步也是一种合理的选择。迁移的价值不是"所有文件都变成了 strict TypeScript",而是"关键路径上的类型安全显著提升了"。


