JavaScript 项目迁移到 TypeScript 实战:渐进式策略与自动化工具

详解 JS 项目迁移到 TS 的渐进式策略:允许 JS 和 TS 共存、.d.ts 声明文件、ts-migrate 自动转换、any 逐步消除路径、第三方库类型声明处理。附完整的迁移检查清单。

把一个现有的 JavaScript 项目迁移到 TypeScript 不需要一次全部重写。TypeScript 编译器支持渐进式迁移,允许 .js 和 .ts 文件在同一源码目录中和平共处,开发团队可以按模块逐个完成类型化改造。这种策略对于大型遗留项目尤其重要,因为一次性重写不仅风险高,而且会阻塞正常的业务迭代。本文将提供一条经过大量生产环境验证的完整迁移路径,涵盖配置、工具、运行时边界、构建链调整以及团队推广策略,帮助你在不影响交付节奏的前提下,把代码库的类型安全水平提升到新的高度。


一、迁移策略

策略一:松耦合 → 严约束(推荐)

这是大多数团队首选的路径,核心思想是先让 TypeScript 编译器接纳现有 JavaScript 代码,再逐步收紧类型约束。具体可以分为四个阶段实施:

第一阶段:Rename。在不对任何代码逻辑做修改的前提下,将核心模块的文件扩展名从 .js 改为 .ts。此时由于 allowJs 选项开启,项目中 .js 文件仍然可以正常编译,团队可以感受到零回退成本。

第二阶段:JSDoc 标注。对于暂时不想重命名的 .js 文件,可以通过在函数上方添加 JSDoc 注释来提前获得类型提示。TypeScript 编辑器支持读取 JSDoc 中的 @param 和 @returns 标签,这相当于在运行时零开销的情况下引入类型约束。

第三阶段:allowJs 共存。在 tsconfig.json 中开启 allowJs: true,让 .ts 文件可以安全地 import .js 模块。此时新项目或重构模块用 TypeScript 编写,旧模块保持 JavaScript,两者并行不悖。

第四阶段:strict 全开。当代码库中 TypeScript 文件占比达到预期后,逐步开启 strict 子选项,最终收敛到完整的严格模式,彻底消除隐式 any。

每次提交的颗粒度建议控制在一个文件或一个目录,Pull Request 的 diff 不应超过五百行。这样代码审查者能够集中精力在类型变化上,而不会被业务逻辑的重写分散注意力。团队推广时,建议先在一两个非核心业务模块做试点,积累 tsconfig 配置经验后,再写一份内部迁移指南,由资深开发者做代码审查,确保类型注解的风格统一。

策略二:边界优先

如果你的项目是一个对外暴露公共接口的库或者服务端框架,可以从边界向中心推进:

  1. 先给公共 API 和接口编写 .d.ts 声明文件,外部消费者最先受益;
  2. 所有新模块强制使用 .ts 编写,从源头拦截类型问题;
  3. 旧模块在修复 Bug 或增加功能时顺便迁移,利用代码变更的窗口期完成类型化;
  4. 核心业务逻辑放在最后迁移,因为它往往涉及复杂的类型推导和大量的 any 清理。

二、第一步:安装与配置

安装 TypeScript 官方包以及 Node.js 内置 API 的类型声明:

npm install -D typescript @types/node
npx tsc --init

生成的 tsconfig.json 初始配置通常比较保守,建议按以下方式调整:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowJs": true,
    "checkJs": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

allowJs: true 是迁移的核心开关,它允许 TypeScript 编译器把 .js 文件当作源码处理并输出到 outDir。checkJs: false 则告诉编译器先不要对 .js 文件进行类型检查,避免一次性爆出几千个错误让开发者望而却步。rootDir 与 outDir 配合确保编译后的目录结构与源码保持一致,这样相对路径的 import 在编译前后都不会被破坏。


三、自动转换工具

ts-migrate(Airbnb 出品)

ts-migrate 是大规模迁移的利器,它将批量重命名、错误标注和占位类型整合在一个命令中:

# 安装
npm install -D ts-migrate

# 在指定目录执行完整迁移流程
npx ts-migrate-full folder-name

其内部执行顺序如下:首先将所有 .js 与 .jsx 文件重命名为 .ts 与 .tsx;然后运行 tsc 收集类型错误;接着在每个错误位置自动插入 @ts-expect-error 注释,并给缺少类型的变量显式标注 any。这样生成的代码是可以通过编译的,团队后续只需要按优先级逐步替换这些 any 和 @ts-expect-error。

TypeStat

如果你的团队希望在迁移的同时尽可能多地自动推断出真实类型,可以用 TypeStat:

{
  "include": ["src/**/*.{ts,tsx}"],
  "fixMissingProperties": true,
  "fixNoImplicitAny": true,
  "types": {
    "implicitClassGenerics": true,
    "implicitFunctionGenerics": true
  }
}
npx typestat --config typestat.json

TypeStat 会基于现有代码中的调用模式推断参数与返回值类型,比单纯的 any 占位要精确得多。对于函数式代码库,它可以自动补全高阶函数的泛型参数,显著减少人工标注工作量。

dts-gen(微软官方)

对于没有类型声明的第三方 JavaScript 库,可以先用 dts-gen 自动生成初始声明:

npm install -D dts-gen
npx dts-gen -m some-legacy-lib

生成的 .d.ts 文件虽然粗糙,但足以消除编译错误,后续可以人工精修类型细节。


四、渐进启用严格模式

一次性开启 strict: true 对大型 JavaScript 代码库来说往往意味着数千个编译错误。更务实的做法是将严格模式的各个子选项按阶段打开:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "strict": true
  }
}

第一步:noImplicitAny。这是最基础也最有效的选项。开启后,所有没有显式类型且无法被推断的变量都会报错,迫使开发者做出明确选择——要么补充类型,要么显式标注 any。

第二步:strictNullChecks。JavaScript 中最常见的一类运行时错误就是空指针异常。开启 strictNullChecks 后,null 和 undefined 将不再自动赋值给其他类型,访问对象属性前必须进行非空判断。

第三步:strictFunctionTypes。函数参数逆变检查能够捕获回调函数签名不匹配的隐患,对事件驱动型代码库尤为重要。

第四步:strictBindCallApply。确保 Function.prototype.call、apply 和 bind 的类型安全。

第五步:strictPropertyInitialization。类属性如果在声明时没有赋值且构造函数中也没有赋值,将产生编译错误。

最终阶段:strict: true。当所有子选项逐一攻克后,将顶层 strict 设为 true,从配置层面锁定团队不再回退。


五、第三方库类型处理

现代 JavaScript 生态中,大部分流行库都在 DefinitelyTyped 上有社区维护的类型声明:

# 安装社区类型声明
npm install -D @types/lodash @types/express @types/react

如果某個库没有官方类型包,又不值得自己从零编写完整声明,可以在项目中创建一个兜底模块声明:

// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
  const anything: any;
  export = anything;
}

对于仅有默认导出且结构简单的库,也可以直接用 declare module 为其添加类型:

// src/types/shims.d.ts
declare module 'my-utils' {
  export function formatDate(date: Date, pattern: string): string;
  export function parseId(input: string): number;
}

在 monorepo 中,如果多个子项目依赖同一个内部工具库,建议为该库编写一份正式的 .d.ts 声明文件,并通过 TypeScript 的 project references 或包管理器的 workspace 链接共享,避免重复声明导致的一致性问题。


六、运行时边界处理

TypeScript 的类型系统仅在编译时生效,代码运行后所有类型信息都会被擦除。这意味着来自外部的数据——例如用户输入、HTTP 响应、本地存储内容、URL 查询参数——仍然可能在运行时带有错误的结构。处理这些边界是迁移过程中最容易被忽视、但危害最大的环节。

初始化 unknown 再收窄

对于任何外部来源的数据,优先使用 unknown 而非 any:

// src/api/fetchUser.ts
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data &&
    typeof (data as Record<string, unknown>).id === 'string' &&
    typeof (data as Record<string, unknown>).name === 'string'
  ) {
    return data as User;
  }

  throw new Error('Invalid user response structure');
}

Zod 运行时验证

在关键路径上使用 Zod 进行 Schema 校验,可以在运行时同时完成类型断言和数据验证:

// src/schemas/user.ts
import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
});

export type User = z.infer<typeof UserSchema>;

// src/api/fetchUser.ts
import { UserSchema } from '../schemas/user';

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const json = await res.json();
  return UserSchema.parse(json); // 运行时校验 + 类型推断
}

本地存储的安全读取

localStorage 是常见的运行时边界,读取后务必做解析和验证:

// src/utils/storage.ts
export function getStorageItem<T>(key: string, validate: (v: unknown) => T | null): T | null {
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const parsed: unknown = JSON.parse(raw);
    return validate(parsed);
  } catch {
    return null;
  }
}

七、构建工具链迁移

Babel 与 tsc 双轨并行

很多项目原本使用 Babel 做转译以支持新语法特性。迁移时不必立刻放弃 Babel,可以让 tsc 只负责类型检查(--noEmit),而 Babel 继续负责代码转译与打包:

{
  "scripts": {
    "build": "babel src --out-dir dist",
    "type-check": "tsc --noEmit"
  }
}

这种双轨策略的优势在于,开发团队可以在不改变现有构建流程的情况下引入 TypeScript 类型检查,降低迁移阻力。

esbuild 与 swc 快速编译

对于中大型项目,tsc 的编译速度可能成为性能瓶颈。esbuild 和 swc 都是 Rust 编写的高性能编译器,能够在毫秒级完成大型代码库的转译:

// esbuild.config.ts
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  platform: 'node',
  format: 'cjs',
});

需要注意的是,esbuild 和 swc 只做转译不做类型检查,因此仍需在 CI 中保留 tsc --noEmit 作为独立步骤。

Monorepo 中的 project references

在 monorepo 中跨包引用时,应使用 TypeScript 的 project references 机制来确保增量编译和类型一致性:

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

// packages/app/tsconfig.json
{
  "references": [
    { "path": "../core" }
  ],
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

在根目录创建一个聚合配置:

// tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/app" }
  ]
}

这样执行 tsc --build 时,TypeScript 会自动按依赖关系拓扑排序并做增量编译,修改一个子包时无需重新编译整个仓库。


八、常见迁移陷阱

循环依赖的类型推导死锁

当两个 .ts 模块通过 import 互相依赖时,TypeScript 的类型推导可能出现死锁或生成隐式 any。建议在迁移初期通过代码拆分或引入第三个公共模块来打破循环,等类型稳定后再考虑重构回合理的引用结构。

this 绑定丢失

JavaScript 中常见的 .map(callback) 或 setTimeout(callback) 场景下,this 的指向在迁移后可能因严格模式而产生编译错误:

class Timer {
  private count = 0;

  start() {
    // 错误:this 在回调中丢失上下文
    // setTimeout(function () { this.count++; }, 1000);

    // 正确:使用箭头函数
    setTimeout(() => {
      this.count++;
    }, 1000);
  }
}

原型链扩展的类型声明

如果旧代码通过 String.prototype 或 Array.prototype 添加了自定义方法,迁移时需要启用声明合并:

// src/types/extensions.d.ts
declare global {
  interface String {
    toSnakeCase(): string;
  }
}

export {};
// src/utils/string.ts
String.prototype.toSnakeCase = function (): string {
  return this.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
};

CommonJS require 的类型处理

遗留代码中大量使用 require() 时,可以通过 esModuleInterop 和 allowSyntheticDefaultImports 来兼容,但推荐逐步替换为 ES Module 的 import:

// 旧代码
// const fs = require('fs');

// 新代码
import * as fs from 'fs';
// 或
import fs from 'fs';

装饰器迁移

如果项目使用了实验性装饰器,需要在 tsconfig.json 中显式开启并注意版本兼容性:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

九、类型推断与补充

手写 .d.ts 声明文件

当团队维护的公共模块被多个项目消费时,为其编写独立的 .d.ts 声明文件能够极大提升下游开发体验。一份良好的声明文件应当包含接口定义、泛型参数以及详细的 JSDoc 注释:

// types/http-client.d.ts
export interface RequestConfig<TParams = unknown, TBody = unknown> {
  url: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  params?: TParams;
  body?: TBody;
  headers?: Record<string, string>;
}

export interface ApiResponse<TData> {
  code: number;
  data: TData;
  message: string;
}

export declare class HttpClient {
  get<T>(config: RequestConfig<unknown, unknown>): Promise<ApiResponse<T>>;
  post<T, B>(config: RequestConfig<unknown, B>): Promise<ApiResponse<T>>;
}

any 与 unknown 的策略选择

迁移初期不可避免地会出现 any,但需要建立清晰的清理策略。建议约定:

  • 临时任何使用 any,并在同一行追加 // TODO: replace any with real type;
  • 对运行时数据一律使用 unknown,迫使调用方进行类型收窄;
  • 不允许在公共 API 的返回值或参数中出现隐式 any。
// src/utils/legacy.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseLegacyFormat(input: string): any {
  return JSON.parse(input);
}

利用类型断言进行局部迁移

当某个复杂对象的结构尚未完整类型化时,可以使用类型断言(as)来维持编译通过,同时逐步替换:

interface Config {
  host: string;
  port: number;
}

const raw = JSON.parse(readFileSync('./config.json', 'utf-8'));
const config = raw as Config; // 先断言,后续用 Zod 替换

十、大型项目案例

百万行代码库迁移经验

Airbnb 在 2019 年将超过两百万行 JavaScript 代码迁移至 TypeScript,其核心经验包括:

  1. 自动化优先:使用 ts-migrate 一次性完成重命名与任何占位,避免开发者陷入手工修改的泥潭;
  2. 模块粒度:按业务领域拆分迁移批次,每次最多迁移一个子目录,确保代码审查可控;
  3. 类型检查门禁:在 CI 流水线中加入 tsc --noEmit,任何新提交的代码都必须通过类型检查;
  4. 度量驱动:统计每周消除的 any 数量和新增的严格模式覆盖率,用数据驱动迁移进度。

CI 中的 type-check gate

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint
      - run: npm test

将 tsc --noEmit 放在测试任务之前非常重要,因为类型错误是成本最低的缺陷——在编码阶段就能发现,无需运行测试套件。

迁移前后缺陷率对比

根据多个公开技术分享的数据显示,在大型 JavaScript 项目迁移到 TypeScript 并开启 strict 模式后,生产环境中由类型错误引发的异常(如 undefined is not a function、Cannot read properties of null 等)平均下降百分之三十到百分之五十。对于外卖调度、金融交易等高风险领域,这种提升尤为显著,因为它把大量运行时缺陷前移到编译期拦截。


迁移检查清单

  • 安装 TypeScript 和配置 tsconfig.json,确认 allowJs: true
  • 配置 rootDir 与 outDir,保持源码与输出目录结构一致
  • 使用 ts-migrate 或手工方式把核心模块重命名为 .ts
  • 处理第三方库类型声明,安装 @types/* 包或编写 .d.ts 兜底
  • 开启 noImplicitAny,消除隐式任何
  • 开启 strictNullChecks,阻断空指针风险
  • 开启 strictFunctionTypes,校验回调函数签名
  • 最终开启 strict: true,锁定完整严格模式
  • 在关键边界引入 Zod 或 io-ts 进行运行时数据校验
  • 调整构建链,实现 tsc 类型检查与 Babel 或 esbuild 转译并行
  • CI 流水线中加入 tsc --noEmit 门禁,禁止类型错误的代码合入主干
  • 建立内部规范,约定 any 的使用规则和清理 timeline
  • 监控迁移后缺陷率变化,量化 TypeScript 引入的投资回报

总结

从 JavaScript 到 TypeScript 的迁移不是一次性的重写任务,而是一场持续数周甚至数月的渐进式工程。成功的关键在于合理分阶段、善用自动化工具、守住运行时边界,以及在 CI 中建立类型检查门禁。只要遵循松耦合到严约束的路径,配合团队的代码审查与度量机制,任何规模的 JavaScript 代码库都可以在不中断业务交付的前提下,稳步获得编译期类型安全的保护。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈