从 JS 迁移

2026-09-09 00:00    #typescript  

把老 JavaScript 项目迁到 TypeScript,最大的风险不是技术问题,而是一次性开 strict 导致上千个错误、项目卡死。正确做法是分阶段推进。

分阶段策略

 1// 从 JS 迁移的分阶段配置(这里用注释演示,实际写在 tsconfig.json 里)
 2//
 3// 阶段 0:先让项目跑起来
 4// {
 5//   "compilerOptions": {
 6//     "allowJs": true,
 7//     "checkJs": false,
 8//     "noEmit": true,
 9//     "strict": false
10//   }
11// }
12//
13// 阶段 1:逐文件改后缀(.js → .ts),一次一个
14//
15// 阶段 2:收紧配置(按模块逐步开,不要全局一次开)
16// {
17//   "compilerOptions": {
18//     "strict": true,
19//     "noImplicitAny": true,
20//     "strictNullChecks": true
21//   }
22// }
23//
24// 阶段 3:治理 any
25
26// ---- 这个文件演示迁移中的常见模式 ----
27
28// ---- 模式 1:any → unknown,强制调用方收窄 ----
29// 改前:function parse(json: string): any { return JSON.parse(json) }
30function parse(json: string): unknown {
31  return JSON.parse(json);
32}
33
34// 调用方必须收窄
35function parseUser(json: string): { name: string } {
36  const raw = parse(json);
37  if (typeof raw === "object" && raw !== null && "name" in raw) {
38    const name = (raw as { name: unknown }).name;
39    if (typeof name === "string") return { name };
40  }
41  throw new Error("invalid user");
42}
43
44// ---- 模式 2:边界处做运行时校验 ----
45function readEnv(key: string): string | undefined {
46  const v = process.env[key];
47  return typeof v === "string" ? v : undefined;
48}
49
50// ---- 模式 3:用类型谓词收窄外部数据 ----
51interface ApiResponse { id: number; name: string }
52
53function isApiResponse(v: unknown): v is ApiResponse {
54  return (
55    typeof v === "object" && v !== null &&
56    typeof (v as ApiResponse).id === "number" &&
57    typeof (v as ApiResponse).name === "string"
58  );
59}
60
61// ---- 模式 4:逐步替换 any 为具体类型 ----
62// 改前:function process(data: any) { return data.items.map((x: any) => x.id) }
63interface Data { items: { id: number }[] }
64// 注意:不要用 process 当函数名,它会遮蔽 Node 的全局 process
65function extractIds(data: Data): number[] {
66  return data.items.map((x) => x.id);
67}
68
69// ---- 模式 5:CommonJS → ESM ----
70// 改前(老 JS 代码)
71//   const fs = require("fs");
72//   module.exports = { run };
73// 改后
74import fs from "node:fs";
75export function run(): string {
76  return fs.existsSync(".") ? "ok" : "no";
77}
78
79// ---- 模式 6:类字段初始化的语义差异 ----
80// useDefineForClassFields 默认为 true(target es2022+)时,
81// 子类字段会覆盖父类访问器(报 TS2610)
82class Base {
83  set value(v: number) { void v; }
84}
85class Derived extends Base {
86  // 如果依赖老行为(走 setter),需要声明成访问器或设 useDefineForClassFields: false
87  private _value = 0;
88  override set value(v: number) { this._value = v; }
89  get value(): number { return this._value; }
90}
91
92const d = new Derived();
93d.value = 1;
94
95console.log(parseUser('{"name":"a"}'), readEnv("PATH") !== undefined, isApiResponse({ id: 1, name: "a" }));
96console.log(extractIds({ items: [{ id: 1 }, { id: 2 }] }), run(), d.value);
1{ name: 'a' } true true
2[ 1, 2 ] ok 1

阶段 0:先让项目跑起来

1{
2  "compilerOptions": {
3    "allowJs": true,
4    "checkJs": false,
5    "noEmit": true,
6    "strict": false
7  }
8}

这一步的目标只是让 tsc --noEmit 能跑通,不改任何代码。

阶段 1:逐文件改后缀

把 .js 改成 .ts,一次一个文件,改完就修这个文件的类型错误。不要批量改。

阶段 2:收紧配置

1{
2  "compilerOptions": {
3    "strict": true
4  }
5}

按模块逐步开。老项目全局一次开 strict 会瞬间出现大量错误。可以先用目录级的 tsconfig.json 分批推进。

阶段 3:治理 any

用 noImplicitAny 找出隐式 any,用 lint 限制显式 any:

1{ "rules": { "@typescript-eslint/no-explicit-any": "warn" } }

any 治理的实用顺序

第一步:any → unknown

这是收益最大的一步。unknown 强制调用方收窄(见 类型收窄),能立刻暴露大量隐藏的假设:

1// 改前
2function parse(json: string): any { return JSON.parse(json) }
3
4// 改后
5function parse(json: string): unknown { return JSON.parse(json) }

改完后调用方全部报错——这正是价值所在,每个报错点都是一个需要处理的假设。

第二步:边界处做运行时校验

JSON.parse、process.env、HTTP 响应这些地方,类型系统无法保证正确性:

1function readEnv(key: string): string | undefined {
2  const v = process.env[key];
3  return typeof v === "string" ? v : undefined;
4}

用类型谓词封装校验逻辑:

1function isApiResponse(v: unknown): v is ApiResponse {
2  return (
3    typeof v === "object" && v !== null &&
4    typeof (v as ApiResponse).id === "number" &&
5    typeof (v as ApiResponse).name === "string"
6  );
7}
复杂边界考虑用 schema 库

手写谓词在字段多时很啰嗦。zod / valibot 这类库能在运行时校验的同时推导出类型,是更彻底的方案。代价是多一个依赖。

第三步:内部代码补类型

真正的业务逻辑,类型可以从数据结构推导出来,不需要运行时校验。

常见坑

坑一:strictNullChecks 打开后错误爆炸

老代码里 obj.foo.bar 假设 foo 一定存在。打开后全部报错。

对策:先只在新文件上开,或按目录分批。TypeScript 没有官方的"文件级 strict 开关",但可以:

坑二:this 类型推断差异

1// JS 里没问题
2const obj = {
3  count: 0,
4  inc() { this.count++; }
5};

noImplicitThis 会要求注解 this。对象方法简写通常能自动推断,但把函数拆出去就会报错:

1const inc = obj.inc;
2inc();   // this 丢失

对策:用箭头函数绑定,或显式声明 this 参数(见 函数类型)。

坑三:CommonJS 与 ESM 混用

1// 老代码
2const fs = require("fs");
3module.exports = { run };
4
5// TS
6import fs from "node:fs";
7export function run(): string { }

esModuleInterop: true + module: "nodenext" 能处理大部分情况,但 require/import 混用的文件需要逐个改。

坑四:类字段初始化语义变化

useDefineForClassFields 默认为 true(target 为 es2022+)时,子类字段会覆盖父类访问器:

1class Base {
2  set value(v: number) { void v; }
3}
4class Derived extends Base {
5  value = 1;
6}
7// error TS2610: 'value' is defined as an accessor in class 'Base', but is
8// overridden here in 'Derived' as an instance property.

对策:改用访问器,或设 useDefineForClassFields: false(如果依赖老行为)。

坑五:不要用 process 当函数名

这是个实际踩到的坑:

1// ✗ 遮蔽了 Node 的全局 process
2function process(data: Data) { }
3
4// 之后所有 process.env 都会报错:
5// error TS2339: Property 'env' does not exist on type '(data: Data) => number[]'.

对策:换个名字(extractIds、handleData)。

迁移检查清单

检查项说明
allowJs + checkJs: false阶段 0 的基础
逐文件改后缀不要批量
目录级 tsconfig.json分批收紧严格度
any → unknown收益最大的一步
边界处运行时校验JSON.parse、env、HTTP
类型谓词封装校验或用 zod
esModuleInteropCJS/ESM 混用
useDefineForClassFields类字段语义
检查全局变量遮蔽如 process、name、length

相关