从 JavaScript 迁移
本教程共 80 篇 · 第 78 篇 · 更新于 2026-08-10 · 约 14 分钟阅读
本节目标:掌握把一个已有 JavaScript 项目逐步迁移到 TypeScript 的完整策略。学完你会知道从哪开始、怎么分配工作量、哪些坑需要提前绕过。
你不需要在周一早上把整个项目重写一遍。TypeScript 的迁移设计就是”渐进式”的——你可以先让 JS 和 TS 文件共存,然后一块一块地把 .js 改成 .ts。微软的 VS Code 团队当年就是这么干的。
迁移的核心思想:不是一次重写
很多人觉得”迁移到 TypeScript”就是开一个新分支、把所有文件改成 .ts、修完报错再合回去。
不要这么干。
一个 500 文件的项目,你改到第 20 个文件时类型错误可能已经几百个了。你不知道哪个是真实 bug、哪个是类型系统太严格、哪个是你的代码确实要改。这种”大爆炸式迁移”有 90% 的概率中途放弃。
正确的做法是:先让 TypeScript 认识你的 JS 代码,再逐步加类型标注,最后收紧检查选项。
第一步:不动任何代码,引入 tsconfig
先在你的 JS 项目根目录安装 TypeScript 并生成配置:
npm install -D typescript
npx tsc --init
TypeScript 7.0 默认生成的 tsconfig 里 strict 已经是 true。但你的 JS 项目大概率经不起严格模式的检查——别急,先把它关掉。打开 tsconfig.json,改几个关键选项:
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": false,
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
关键选项解读:
allowJs: true:让 TypeScript 编译器”认识”.js文件,允许它们和.ts文件共存。checkJs: false:不检查.js文件中的类型错误。这一步先不报错,只保证能编译。strict: false:先放水。类型检查后面再收紧。
整个项目的 JS 文件不需要改一行代码,tsc 就能跑起来——只是它几乎不会报任何错。这不是 bug,这是迁移策略。
Note如果你的项目用的是 Webpack 或 Vite 打包,tsc 在这里只做”编译到 JS”这一步。实际上你的打包工具已经在做这件事了——所以初始阶段 tsc 更多是一个”占位”,为后面逐步加类型检查做准备。
第二步:渐进开启 checkJs
当你的 tsconfig 能正常跑通之后,把 checkJs 打开:
{
"compilerOptions": {
"checkJs": true
}
}
tsc 会开始检查 .js 文件的类型。别被报错数量吓到——很多是 TypeScript 根据你的代码推断出来的警告,不代表你的代码有 bug。
这时候你可以做两件事:
- 用 JSDoc 注解给关键函数加类型,不用改文件扩展名。
- 挑报错最少的文件优先改成
.ts。
JSDoc 加类型长这样:
// users.js —— 不用改名,加注释就行
/**
* @param {string} name
* @param {number} age
* @returns {{ name: string, age: number }}
*/
function createUser(name, age) {
return { name, age };
}
checkJs 模式下,TypeScript 会读取这些 JSDoc 注解并做类型检查。如果函数调用和注解不一致,编辑器里就会有红色波浪线。
这个阶段的优势:**降级风险为零。**所有文件还是 .js,构建工具完全不受影响,type checking 是附加的。
Tip
checkJs是迁移的”温度计”而不是”手术刀”。先跑通、看报错分布、再决定从哪里下手。
第三步:按优先级把 .js 改成 .ts
改文件扩展名不是随便挑一个就改。有一个很实际的优先级顺序:
优先级 1:纯工具函数 / 工具模块
src/utils/formatDate.js → src/utils/formatDate.ts
src/utils/debounce.js → src/utils/debounce.ts
这些文件的特点是:**没有外部依赖(或者依赖很少)、输入输出明确、逻辑纯计算。**改起来最快、收益最高。一个 debounce 函数加上类型标注只需要 30 秒:
// 改名前
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 改名后
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return function (this: unknown, ...args: Parameters<T>) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
优先级 2:数据模型 / 类型定义
src/types/user.js → src/types/user.ts
src/models/order.js → src/models/order.ts
这些文件定义了项目的核心数据结构。把它们改成 .ts 后,可以顺势抽出 interface 和 type——后面的文件迁移会越来越轻松,因为它们可以直接引用这些类型。
// models/user.ts
export interface User {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
createdAt: Date;
}
export interface CreateUserInput {
name: string;
email: string;
role?: "admin" | "editor" | "viewer";
}
优先级 3:被依赖少的模块
找那些”被很多文件 import、但它自己不 import 太多东西”的模块。这种模块改完后,所有依赖它的文件都能享受到类型检查的好处——即使它们自己还是 .js。
优先级 4:入口文件 / 页面组件
最后改入口。入口文件 import 了几乎所有东西,最后一个改可以确保所有依赖都已经有类型标注了。
判断依赖关系最快的方式:在 VS Code 里对某个文件右键 → “查找所有引用”(Find All References),看看有多少文件依赖它。引用越少、越纯粹的模块,越适合先改。
第四步:逐步收紧 strict
当你觉得大部分关键模块都已经有类型标注了,开始收紧检查选项——但不要一步到位,逐步开:
// 阶段一:先开这些,工作量最小、收益最高
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true
}
}
noImplicitAny 要求所有变量和参数要么有显式类型标注、要么能从上下文推断出来。strictNullChecks 区分 null / undefined 和其他类型——这是 TypeScript 类型系统最有价值的能力之一。
修完这两项的报错后,再打开完整的 strict:
{
"compilerOptions": {
"strict": true
}
}
NoteTypeScript 7.0 的
strict已经是默认true了。如果你的项目从一开始就是用 7.0 建的,那strict就是自动开的。这里的”逐步收紧”是针对从旧 JS 项目迁移过来的情况。
处理第三方 JS 库
你的旧 JS 项目大概率用了很多 npm 包。迁移到 TypeScript 后,import 这些库时 tsc 会报错:“找不到模块 xxx 的类型声明文件”。
解决方式有三档:
第一档:安装 @types 包(90% 的情况能搞定)
绝大多数主流 JS 库的维护者(或社区)已经提供了类型声明包,以 @types/ 为前缀发布在 npm 上:
npm install -D @types/lodash @types/react @types/express
TypeScript 会自动识别这些包。你的 import 语句不需要做任何修改。
TypeScript 7.0 的重要变更:
types的默认值从”自动引入所有@types/*”变成了[]。如果你需要全局类型(比如node、jest),必须在 tsconfig 里显式写出来:{ "compilerOptions": { "types": ["node", "jest"] } }
第二档:库自带类型声明(开箱即用)
越来越多 JS 库直接在包里附带了 .d.ts 文件。安装后直接用就行——比如 date-fns、zod、immer 等。
判断方法:看库的 package.json 里有没有 "types" 字段。有的话就是自带类型。
第三档:自己写 .d.ts(冷门库或不维护的库)
如果上述两种情况都没有——比如一个内部私有包、或者一个 2018 年后就没更新过的 npm 包——那就自己写声明文件。
在项目根目录建一个 types/ 文件夹,在里面创建同名声明文件:
// types/old-lib.d.ts
declare module "old-lib" {
export function doSomething(input: string): number;
export function doAnotherThing(options: {
flag: boolean;
}): string;
}
然后在 tsconfig 里告诉 TypeScript 这个文件夹:
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./types"]
}
}
你不需要给整个库写完整声明——只写你实际用到的函数和接口。缺什么加什么,够用就行。
Tip写
.d.ts的时候如果类型搞错了(比如参数类型写反了),tsc 会直接报错——因为 TypeScript 会用你声明的类型去检查调用代码。这是好事,帮你发现声明和实际 API 不一致的地方。
常见踩坑
以下是 JS 项目迁移到 TypeScript 时最常遇到的几个坑。每个都能在 Stack Overflow 上搜到几十个相关问题。
坑 1:this 问题
JavaScript 里 this 的指向取决于调用方式。TypeScript 默认不知道函数里的 this 是什么类型,会报 "this" implicitly has type "any"。
// ❌ 报错
class Button {
text: string;
handleClick() {
console.log(this.text); // 'this' implicitly has type 'any'
}
}
两种解法:
// 方案 A:用箭头函数(推荐)
class Button {
text: string;
handleClick = () => {
console.log(this.text); // ✅ this 自动绑定
};
}
// 方案 B:显式声明 this 参数
function handleCallback(this: HTMLButtonElement, event: MouseEvent) {
console.log(this.textContent);
}
坑 2:动态属性
JavaScript 里随手给对象加属性是家常便饭:
const config = {};
if (isDev) {
config.debug = true; // JS 里完全 OK
}
TypeScript 不允许对空对象随便塞属性。解法:
// 方案 A:声明时给出完整类型
interface Config {
debug?: boolean;
apiUrl?: string;
}
const config: Config = {};
if (isDev) {
config.debug = true; // ✅
}
// 方案 B:用索引签名(不推荐作为首选方案)
const config: Record<string, unknown> = {};
config.debug = true; // ✅ 但失去了类型安全
坑 3:隐式 any
迁移时最常见的报错类型:TypeScript 推断不出类型,默认给了 any,但又没打开 noImplicitAny 去报错。等 strict 一开,几百条错误一起扑面而来。
早期策略:迁移阶段 noImplicitAny 可以先不开,但每改一个文件,顺手把函数参数的类型补上。与其最后一次性修 300 个 any,不如每次改文件时顺便加上类型——不痛苦、不打断节奏。
// 迁移中的 JS 写法
function processData(data, options) {
return transform(data, options.flag);
}
// 改名成 .ts 后顺手补类型
function processData(data: DataRow[], options: ProcessOptions): Result[] {
return transform(data, options.flag);
}
坑 4:CommonJS 的 module.exports
很多老 JS 项目使用 CommonJS 规范:
// utils.js
function add(a, b) { return a + b; }
module.exports = { add };
// 另一个文件
const { add } = require("./utils");
TypeScript 支持这种写法,但有一些注意事项:
// utils.ts —— 推荐用 ES module 语法
export function add(a: number, b: number): number {
return a + b;
}
// 另一个文件 —— 推荐用 import
import { add } from "./utils";
如果老项目实在不想改 import 写法,可以用 export = 语法保持兼容:
// utils.ts
function add(a: number, b: number): number {
return a + b;
}
export = { add };
// 消费方
import utils = require("./utils");
// 或
import * as utils from "./utils";
但强烈建议趁迁移的机会统一换成 ES module 的 import / export 语法——这是未来的方向,TypeScript 7.0 默认 module: "esnext" 已经说明了官方态度。
坑 5:node_modules 里的类型冲突
开了 checkJs 之后,有时候 tsc 会报 node_modules 里某个包的类型错误——不是你的代码有问题,是那个包自己的 .d.ts 有问题。
node_modules/some-lib/index.d.ts:42:5 - error TS2345: ...
解法:
{
"compilerOptions": {
"skipLibCheck": true
}
}
skipLibCheck 跳过所有 .d.ts 文件(包括 node_modules 里)的类型检查。迁移初期建议打开,等你的代码全部迁移完了再考虑关掉它做更严格的检查。
迁移时间线参考
这里给一个粗略的时间线,具体取决于项目规模:
| 项目规模 | 预计周期 | 建议节奏 |
|---|---|---|
| 小(<50 文件) | 1–3 天 | 连续改完,一步到位 |
| 中(50–300 文件) | 1–4 周 | 每天改几个模块,不强求一气呵成 |
| 大(>300 文件) | 1–6 个月 | 渐进式,按模块优先级逐个击破 |
关键心态:不要把迁移当成一个”做完就完了”的项目。它是一种持续改进——今天改了 5 个文件,类型覆盖率从 12% 提到 18%,这就已经是成果了。
回顾:你从这本书里学到了什么
这一章是本书”工程化与生态”的第一篇,也是全书倒数第三章。从第 1 章”TypeScript 是什么”开始,你走过了:
- 基础类型、数组、元组、枚举(第 8–14 章)
- 函数、泛型函数(第 17–22 章)
- 接口、类型别名、类的完整体系(第 23–37 章)
- 泛型进阶、工具类型、条件类型、映射类型(第 38–50 章)
- 类型守卫、类型兼容性等高阶话题(第 51–53 章)
然后进入工程化部分,本章教你如何把一个现有的 JavaScript 项目平滑迁移到 TypeScript。从”不动代码只加 tsconfig”到”逐步加类型标注再到全面 strict”——这条路已经被成千上万的项目验证过,你不用再摸着石头过河了。
下一步
你在读的是本书最后三章。第 79 章会讲构建工具集成——Vite、Webpack 怎么跟 TypeScript 配合。第 80 章是 TypeScript 7.0 的完整迁移指南——从 5.x/6.x 升级到 7.0 需要做什么、哪些配置需要改、框架项目该怎么处理。
读完这两章,你就不是一个只会在 Playground 里写 TypeScript 的人了——你有能力在生产环境里搭建 TypeScript 工具链、维护和升级它。