15 从 JS 迁移与生态互操作

渐进迁移路线、无类型依赖处理、any 治理、与 lint 和构建工具的边界

15 从 JS 迁移与生态互操作

把老 JS 项目搬上 TypeScript,难点不在语法,而在如何在不停工的前提下逐步收紧。

⚠️ 6.0 让迁移变难了一点:strict 默认变为 true、types 默认变为 []。所以从 5.x 升级或从零引入时,第一步应该是显式固定这两个选项,而不是直接吃默认值。详见 08 tsconfig。


迁移路线

核心原则:每一步都可运行、可发布、可回滚。

阶段配置产出
0无JS 照跑
1allowJs + noEmitTS 与 JS 共存,开始有编辑器提示
2checkJs + 逐文件 // @ts-check试点文件开始被检查
3重命名 .js → .ts(从叶子文件开始)逐文件强制检查
4打开严格性选项(逐个)逐步收紧
5清理 any / @ts-ignore收尾
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 阶段 1~2 的配置:最小侵入
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,      // 先不检查 JS,只让 TS 文件共存
    "noEmit": true,        // 🔥 产物仍交给原有构建工具
    "strict": false,       // ⚠️ 6.0 默认 true,迁移期显式关掉
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}
1
2
3
4
5
6
7
8
9
// 阶段 3:开始改名,此时开 checkJs 做过渡
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false
  }
}

为什么 noEmit: true 很关键 🔥:让 tsc 只做检查,产物仍由现有的 Webpack/Vite/Babel 管线生成。这样迁移不会改变运行时行为,风险大幅降低。等全部迁完再考虑是否换成 tsc 产物。

改名顺序:从叶子到根

1
2
3
4
5
6
7
8
✅ 正确顺序:
  utils/format.js          ← 无依赖的工具函数,先改
  services/api.js          ← 依赖 utils
  components/Button.jsx    ← 依赖 services
  pages/Home.jsx           ← 依赖 components
  index.js                 ← 最后改入口

🛑 反顺序(先改入口)会一次性暴露所有下游问题
优先迁移理由
纯工具函数、常量表无依赖,类型简单,收益立刻可见
数据模型 / API 类型一次定义,全局受益 🔥
被引用最多的模块改动一处,多处获得类型
新写的代码从第一天就是 TS ✅
最后迁移理由
入口文件依赖最多
构建配置、脚本收益低
第三方包装层等库自己提供类型

不改成 .ts 也能获得大部分类型能力——这是被低估的选项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// @ts-check
/**
 * @typedef {Object} User
 * @property {string} id
 * @property {string} name
 * @property {"admin" | "user"} role
 */

/**
 * 按 ID 查找用户。
 * @param {string} id - 用户 ID
 * @param {{ timeout?: number }} [options] - 选项
 * @returns {Promise<User | null>}
 */
export async function findUser(id, options) {
  // id 和 options 都有类型了 ✅
  const res = await fetch(`/api/users/${id}`, { signal: AbortSignal.timeout(options?.timeout ?? 5000) });
  return res.json();
}

/** @type {User[]} */
const cache = [];

/**
 * @template T
 * @param {T[]} items
 * @param {(item: T) => boolean} pred
 * @returns {T | undefined}
 */
function find(items, pred) { return items.find(pred); }
JSDoc 标签对应 TS 语法
@param {string} xx: string
@returns {number}: number
@type {User[]}const cache: User[]
@typedef {Object} Xinterface X
@property {string} aa: string
@template T<T>
@satisfies {T}satisfies T
@type {import("./x").Y}import type { Y }
// @ts-check开启该文件检查
// @ts-expect-error同 TS

JSDoc 的适用场景:

场景适合
不能改文件扩展名(构建配置限制)✅
想边写边加类型,暂不改名✅
遗留代码,只想修几个热点函数的类型✅
新代码❌ 直接写 .ts 更好 🔥
复杂泛型 / 条件类型❌ JSDoc 表达力不足

💭 JSDoc 是过渡工具,不是终点。语法比 TS 啰嗦、表达力受限、工具支持也略弱。它的价值在于零成本启动——加一行 // @ts-check 就能开始受益。

6.0 的 strict 默认 true,所以迁移项目第一步要显式关掉,然后逐项打开:

1
2
// 迁移起点
{ "compilerOptions": { "strict": false } }

推荐开启顺序(按「修复成本 / 收益比」排序):

顺序选项修复成本收益
1noImplicitAny中🔥🔥🔥 最高,抓住未标注的参数
2strictNullChecks高🔥🔥🔥 抓 null 相关 bug,但改动最多
3noImplicitThis低🔥🔥
4alwaysStrict无🔥
5strictFunctionTypes低🔥🔥
6strictBindCallApply低🔥
7useUnknownInCatchVariables中🔥🔥
8strictPropertyInitialization中🔥🔥
9noUncheckedIndexedAccess高🔥🔥🔥 但不在 strict 里,最后开
10noImplicitOverride低🔥
11exactOptionalPropertyTypes高🔥 语义变化大,最谨慎
1
2
3
4
5
6
7
// 逐项开启(每次只加一个,修完再加下一个)
{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true      // ← 第一步只加这个
  }
}

strictNullChecks 的务实策略(它是最痛的一步):

1
2
3
4
5
6
7
// 1. 先只在部分目录开(用额外 tsconfig)
// tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "strictNullChecks": true },
  "include": ["src/new-feature/**/*"]
}
1
2
3
4
5
6
7
// 2. 或者按目录用 // @ts-strict 之类做不到,就用多配置 + CI 分步检查
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json"
  }
}

🔥 不要试图一次开完。strict: true 一次性打开在成熟项目上通常意味着几千个错误,团队会直接放弃。每次一个选项,配合「新代码必须过、老代码登记 TODO」的策略更现实。


无类型依赖的处理

按这个顺序找,能不自写就不自写:

优先级来源检查方式
1库自带类型(types 字段 / .d.ts)看 node_modules/包名/package.json 的 types
2@types/包名npm view @types/包名 version 🔥
3库作者提供的其它入口查文档
4自己写最小声明最后手段
5🛑 declare module "x": any 一刀切等于放弃类型
1
2
3
4
# 快速判断有没有官方类型
npm view @types/express version        # 有输出 = 存在
npm view express types                 # 看库自己是否声明了 types 字段
ls node_modules/express/*.d.ts         # 看产物里有没有声明

先检查是不是「假缺失」:

症状真实原因
报找不到模块,但 @types 装了types 数组没包含,或 moduleResolution 不匹配
报找不到模块,包在子路径包的 exports 没暴露该子路径
只有某个文件报错该文件不在 include 里
1
2
# 确认解析到底发生了什么
npx tsc --noEmit --traceResolution 2>&1 | grep -A10 "找不到的包名"

原则:只声明你真正用到的,用 unknown 而不是 any。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// src/types/legacy-analytics.d.ts
declare module "legacy-analytics" {
  export interface TrackOptions {
    userId?: string;
    properties?: Record<string, string | number | boolean>;
  }

  export function track(event: string, options?: TrackOptions): void;
  export function identify(userId: string): void;
  export function flush(): Promise<void>;
}

写法要点:

要点说明
只写用到的未用到的 API 不用声明
参数用 unknown 而非 any保留后续收窄的可能 🔥
返回值尽量具体但不确定时用 unknown
加注释写明来源「依据 v2.1 文档」+ 链接
放在 src/types/ 并在 include 内通常自动包含

逐步细化的策略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 第 1 步:先让它能编译(最小信息)
declare module "legacy-lib" {
  const lib: unknown;
  export default lib;
}

// 第 2 步:用到什么补什么
declare module "legacy-lib" {
  export function init(config: { key: string }): void;
  export function send(event: string, payload?: unknown): Promise<void>;
}

// 第 3 步:补充更精确的类型
declare module "legacy-lib" {
  export interface InitConfig {
    key: string;
    endpoint?: string;
    debug?: boolean;
  }
  export function init(config: InitConfig): void;
  export function send(event: string, payload?: Record<string, unknown>): Promise<void>;
}

⚠️ 手写声明是「对运行时的假设」,写错了编译器不会发现。所以:

做法原因
读库的源码或文档别猜 API
写个最小运行时测试验证确认假设成立 🔥
标注 // 依据: <链接>便于日后核对
考虑给 DefinitelyTyped 提 PR惠及他人,也获得 review

如果库没有类型,最好的做法是给它加上——自己用得上,社区也受益。

基本流程:

1
2
3
4
5
6
# 1. fork 并克隆 DefinitelyTyped
git clone https://github.com/<你的用户名>/DefinitelyTyped.git
cd DefinitelyTyped

# 2. 创建类型包目录(结构与 npm 包名一致)
mkdir -p types/my-library
1
2
3
4
5
6
7
8
9
// types/my-library/package.json
{
  "name": "@types/my-library",
  "version": "1.0.0",
  "projects": ["https://github.com/author/my-library"],
  "dependencies": {},
  "types": "index.d.ts",
  "typeScriptVersion": "5.0"
}
1
2
3
4
5
6
7
8
// types/my-library/index.d.ts
export interface Options {
  key: string;
  debug?: boolean;
}

export function init(options: Options): void;
export function send(event: string, payload?: Record<string, unknown>): Promise<void>;
1
2
3
4
5
# 3. 测试(DT 提供工具)
npm test          # 或 npx dtslint types/my-library
npx tsc --noEmit  # 类型检查

# 4. 提交 PR
检查项要求
index.d.ts 无 any(除必要)DT 有 lint 规则
写类型测试(*.test-d.ts)强烈建议
package.json 元数据正确projects、typeScriptVersion
头部注释格式DT 有固定模板
单一 PR 只改一个包便于 review

💭 提 PR 的隐性收益:会被有经验的维护者 review,是提升类型编写能力的好途径。如果嫌流程重,也可以先把声明放在自己项目里,稳定后再提交。


any 治理

any 是迁移最大的技术债来源。它不会自己消失,必须有策略。

不知道有多少 any,就无法制定目标。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 统计显式 any 出现次数
grep -rn ": any" src --include="*.ts" --include="*.tsx" | wc -l

# 分类查看
grep -rn "as any" src --include="*.ts" --include="*.tsx" | wc -l
grep -rn "@ts-ignore" src --include="*.ts" --include="*.tsx" | wc -l
grep -rn "@ts-expect-error" src --include="*.ts" --include="*.tsx" | wc -l
grep -rn "<any>" src --include="*.ts" --include="*.tsx" | wc -l

# 按文件排序,找出重灾区
grep -rc ": any" src --include="*.ts" | sort -t: -k2 -rn | head -20

用 ESLint 规则量化并设门槛:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// eslint.config.js(flat config,typescript-eslint)
export default [
  {
    files: ["**/*.ts", "**/*.tsx"],
    rules: {
      "@typescript-eslint/no-explicit-any": "error",      // 🔥 禁止显式 any
      "@typescript-eslint/no-unsafe-assignment": "error",  // 禁止 any 悄悄传播
      "@typescript-eslint/no-unsafe-member-access": "error",
      "@typescript-eslint/no-unsafe-call": "error",
      "@typescript-eslint/no-unsafe-return": "error",
      "@typescript-eslint/no-floating-promises": "error",  // 漏 await
      "@typescript-eslint/ban-ts-comment": ["error", {
        "ts-ignore": true,        // 🔥 禁止 @ts-ignore
        "ts-expect-error": "allow-with-description",
        "ts-nocheck": "allow-with-description"
      }]
    }
  }
];

🔥 no-unsafe-* 系列规则需要类型信息(要配 parserOptions.project),这正是 typescript-eslint 独有的能力——它能追踪 any 的传播路径,比单纯禁止 : any 有用得多。

把 any 数量纳入 CI 门槛:

1
2
3
4
5
6
7
8
9
#!/bin/bash
# scripts/check-any-budget.sh
MAX_ANY=50
CURRENT=$(grep -rc ": any" src --include="*.ts" | awk -F: '{sum+=$2} END {print sum}')
if [ "$CURRENT" -gt "$MAX_ANY" ]; then
  echo "❌ any 数量 $CURRENT 超过门槛 $MAX_ANY"
  exit 1
fi
echo "✅ any 数量 $CURRENT / $MAX_ANY"
1
2
// package.json
{ "scripts": { "check:any": "bash scripts/check-any-budget.sh" } }

💡 门槛只降不升:每次减少后就调低 MAX_ANY。这样 any 数量单调递减,不会因为「反正已经很多了」而继续恶化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
遇到 any,按顺序问:

1. 这个值的真实类型我确定吗?
   ├─ 确定 → 写上真实类型 ✅
   └─ 不确定 → 继续

2. 它是「外部来的」吗?(API / JSON / env / 用户输入)
   ├─ 是 → 改 unknown + 运行时校验(见 11)🔥
   └─ 否 → 继续

3. 它能用泛型表达吗?
   ├─ 能 → 用泛型 <T> ✅
   └─ 不能 → 继续

4. 它是「任意对象」吗?
   ├─ 是 → Record<string, unknown> ✅
   └─ 否 → 继续

5. 它是「任意值」吗?
   ├─ 是 → unknown ✅
   └─ 否 → 继续

6. 真的是无法表达 → 保留 any,但加注释说明原因 + TODO
原始写法替换为场景
anyunknown任意值,之后要收窄 🔥
anyRecord<string, unknown>任意对象
any[]unknown[]任意数组
any<T>(x: T) => ...通用函数
(x: any) => void(x: unknown) => void回调
Promise<any>Promise<unknown>异步结果
as anyas unknown as T(仍不理想)最后手段
any具体接口最理想 ✅

unknown 替换 any 后的连锁修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 🛑 原代码
function process(data: any) {
  return data.items.map((i: any) => i.name).join(", ");
}

// ✅ 改成 unknown 后,编译器会逼你写清楚
function process(data: unknown): string {
  if (
    typeof data === "object" && data !== null &&
    "items" in data && Array.isArray((data as { items: unknown }).items)
  ) {
    const items = (data as { items: unknown[] }).items;
    return items
      .map((i) => (typeof i === "object" && i !== null && "name" in i
        ? String((i as { name: unknown }).name)
        : ""))
      .join(", ");
  }
  return "";
}

// ✅✅ 更好:用 schema 库,一次到位
import { z } from "zod";

const DataSchema = z.object({
  items: z.array(z.object({ name: z.string() })),
});

function process2(data: unknown): string {
  const parsed = DataSchema.safeParse(data);
  if (!parsed.success) return "";
  return parsed.data.items.map((i) => i.name).join(", ");
}

🔥 注意 unknown 会带来一堆手写收窄代码。这时应该停下来问:「我是不是在重造 schema 库?」——答案通常是「是」。见 11 运行时校验。

1
2
# 统计
grep -rn "@ts-ignore\|@ts-expect-error\|@ts-nocheck" src | wc -l
指令治理策略
@ts-ignore🛑 全部改成 @ts-expect-error 🔥
@ts-expect-error每条必须写原因;定期清理
@ts-nocheck登记为待迁移文件,设期限
1
2
3
4
5
6
7
// ✅ @ts-expect-error 的正确用法:带原因
// @ts-expect-error 上游库类型定义缺失该重载,见 https://github.com/x/y/issues/123
legacyCall(x);

// 🛑 没有原因的抑制 = 定时炸弹
// @ts-expect-error
legacyCall(x);

@ts-expect-error 的自我清理特性 🔥:当上游修复后,该行不再报错,TS 会报:

1
error TS2578: Unused '@ts-expect-error' directive.

于是 CI 会失败,提醒你删除这条已经没有用的抑制。这是 @ts-ignore 完全没有的好处。

1
2
3
4
// 🛑 危险的 @ts-nocheck:整个文件不再检查
// @ts-nocheck
// TODO(2026-Q2): 迁移本文件,见 #1234
export function legacyStuff(x) { /* ... 全是隐式 any ... */ }

@ts-nocheck 的登记表(放在仓库里,避免遗忘):

1
2
3
4
5
<!-- docs/ts-migration-todo.md -->
| 文件 | 原因 | 负责人 | 期限 |
| --- | --- | --- | --- |
| `src/legacy/payment.ts` | 依赖已下线的 SDK | @alice | 2026-Q2 |
| `src/legacy/report.ts` | 复杂类型体操失败 | @bob | 2026-Q3 |

💭 @ts-nocheck 比 any 更危险,因为它让整个文件失去保护,包括新加的代码。如果必须用,一定要有登记和期限,否则会永久留存。


与生态工具的边界

两者互补,不能互相替代。

问题类型检查(tsc)lint(ESLint)
拼错的属性名✅❌
类型不匹配✅❌
漏处理联合分支✅❌
未使用的变量⚠️ 有选项但不推荐✅ 🔥
漏 await❌✅(需类型信息)
any 传播❌✅(需类型信息)
代码风格❌✅
反模式(如 ==)❌✅
Hook 依赖完整性❌✅(react-hooks 规则)
1
2
3
4
5
6
7
// tsconfig 里不要开这些「风格类」检查
{
  "compilerOptions": {
    "noUnusedLocals": false,      // ← 交给 ESLint
    "noUnusedParameters": false   // ← 交给 ESLint
  }
}

💭 建议的类型/lint 分工:

交给 tsc交给 ESLint
类型正确性代码风格
未处理的分支未使用变量
结构不匹配漏 await、any 传播
严格性开关框架特定规则

理由:noUnusedLocals 报错会中断编译,而「有个变量没用」通常不该阻止构建。

typescript-eslint 的「类型感知」规则(需要配置 project):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// eslint.config.js
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,          // 🔥 自动发现 tsconfig
        tsconfigRootDir: import.meta.dirname
      }
    }
  }
);
规则作用
no-floating-promises漏 await 的 Promise 🔥
no-misused-promises把 Promise 传给期望 void 的位置
await-thenable对非 Promise await
no-unsafe-* 系列any 传播路径 🔥
require-awaitasync 函数里没 await
strict-boolean-expressions隐式真值判断(可选,较严格)
switch-exhaustiveness-check穷尽性检查(比 never 技巧更早发现)

🔥 switch-exhaustiveness-check 值得单独提:它在 switch 漏分支时直接报错,不需要你写 default: never 那套技巧。

工具转译类型检查
tsc✅✅
esbuild✅❌
SWC✅❌
Vite(dev)✅❌
Babel✅❌
Bun✅❌
tsx✅❌

🛑 最重要的一条认知:上表里除 tsc 外都不做类型检查。「构建成功」不代表「类型正确」。

1
2
3
4
5
6
7
8
// 典型的前端项目分工
{
  "scripts": {
    "dev": "vite",                        // 快速转译,类型错误不影响 HMR
    "build": "tsc --noEmit && vite build", // 🔥 构建前必须类型检查
    "typecheck": "tsc --noEmit --watch"   // 开发时单独跑
  }
}

为什么打包器不做类型检查:类型检查需要跨文件分析(整个程序的类型信息),而 esbuild/SWC 追求的是单文件、可并行的极致速度。这是刻意的设计取舍,不是缺陷。

isolatedModules 为何必需:单文件转译无法判断「这个 import 是类型还是值」,所以要开 isolatedModules + verbatimModuleSyntax 强制你写清楚。见 07 模块系统。

多包仓库的两种做法:

做法说明适合
各自独立 tsconfig每个包自己检查简单,但重复检查公共依赖
项目引用(references)声明包间依赖,增量构建monorepo 🔥
1
2
3
4
5
6
7
8
9
// 根 tsconfig.json ——solution 文件
{
  "files": [],
  "references": [
    { "path": "./packages/types" },
    { "path": "./packages/core" },
    { "path": "./packages/web" }
  ]
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,          // 🔥 必需
    "declaration": true,        // 🔥 composite 隐含要求
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src"],
  "references": [
    { "path": "../types" }      // 声明依赖
  ]
}
1
2
3
npx tsc --build              # 按拓扑顺序构建所有包
npx tsc --build --watch      # 监听
npx tsc --build --clean      # 清理
项目引用带来的说明
增量构建只重建变化的包 🔥
强制边界不能 import 未声明的依赖包
正确的检查顺序依赖先检查
成本配置复杂,需要 composite + declaration

⚠️ 项目引用的常见坑:

症状原因
Referenced project must have composite: true忘了 composite
File is not listed within the file list of project被引用的文件不在对方的 include 里
改了代码不重新检查需要 --build 而不是 tsc
.tsbuildinfo 陈旧--build --force 或删掉它

💭 小项目不要上项目引用。配置复杂度和收益不成比例。包数超过 5 个、或构建时间超过 30 秒时再考虑。


常见陷阱小结

陷阱症状正确做法
一上来就 strict: true几千个错误,团队放弃逐项开启 🔥
6.0 下没固定 strict吃了新默认值,错误暴增显式 strict: false 起步
6.0 下没配 types找不到 processtypes: ["node"]
迁移时改了产物运行时行为变化noEmit: true,产物交给原工具链
从入口文件开始改名一次性暴露所有问题从叶子文件开始
用 declare module "x": any等于没类型写最小声明
手写声明靠猜运行时崩溃读源码 + 写测试验证
any 没有量化无法制定目标统计 + CI 门槛 🔥
门槛只设不降债越积越多每次减少就调低门槛
保留 @ts-ignore上游修好也不知道全改 @ts-expect-error
@ts-nocheck 无期限永久留存登记表 + 期限
以为打包器会检查类型类型错误进主干CI 里加 tsc --noEmit 🔥
小项目上项目引用配置复杂收益低包多了再上
最后修改 September 20, 2026: 更新 (25684a4ed)