TypeScript 项目从 JS 迁移的真实路径

HTMLPAGE 团队
17 分钟阅读

从 JavaScript 迁移到 TypeScript 最怕的不是改类型,而是改到一半发现收益抵不过成本。本文从项目实际经验出发,讲解增量迁移的策略选择、strict 模式的分阶段启用、第三方类型处理方案,以及迁移过程中的常见陷阱和验收标准。

#TypeScript #项目迁移 #strict 模式 #JS 到 TS #工程实践

从 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 修复

这一步是迁移中最大的坎。开启后,所有可能为 nullundefined 的值都需要做 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

noImplicitAnystrictNullChecks 修复之后,项目中可能还有大量显式的 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 模式offoffnull checkfull
编译错误数0(没检查)0(宽松)0(已修复)0(已修复)
运行时类型错误常有减少很少极少

总结

从 JS 迁移到 TypeScript 最有效的路径不是"一次到位",而是"五分走":

  1. 最小化配置——先让 TS 编译器运行起来
  2. 逐个文件重命名——从叶子文件开始,改完就提交
  3. 补第三方类型——从自带类型到 DefinitelyTyped 到手动声明
  4. 分阶段开启 strict——noImplicitAny 先行,strictNullChecks 最关键
  5. 逐步收紧 any——从 hot path 开始,用 lint 监控进度

每一步的收益都可以独立评估。如果某一步停下来了(改了配置文件但修复代价太大),退回前一步也是一种合理的选择。迁移的价值不是"所有文件都变成了 strict TypeScript",而是"关键路径上的类型安全显著提升了"。