1
| npx tsc --noEmit --extendedDiagnostics
|
实测输出(TS 6.0.3,一个 61 文件的小项目):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| Lines of TypeScript: 183
Lines of JavaScript: 0
Lines of JSON: 0
Lines of Other: 0
Identifiers: 50264
Symbols: 57593
Types: 30622
Instantiations: 32819
Memory used: 117906K
Assignability cache size: 12684
Identity cache size: 0
Subtype cache size: 0
Strict subtype cache size: 4
I/O Read time: 0.01s
Parse time: 0.09s
ResolveModule time: 0.00s
Program time: 0.12s
Bind time: 0.04s
Check time: 0.41s
printTime time: 0.00s
Emit time: 0.00s
Total time: 0.57s
|
指标解读:
| 指标 | 含义 | 关注点 |
|---|
Lines of TypeScript | 参与编译的 TS 行数 | 是否比预期多很多(幽灵文件) |
Identifiers | 标识符总数 | 突然很大说明 @types 被全量加载 |
Symbols | 符号数 | 同上 |
Types | 创建的类型对象数 | 类型定义过多 |
Instantiations | 类型实例化次数 | 🔥 最重要,爆炸就是类型体操过重 |
Memory used | 内存峰值 | 过高会 OOM |
Assignability cache size | 可赋值性缓存条目 | 大量类型比较 |
Parse time | 解析耗时 | 大 → 文件多/大 |
Bind time | 绑定耗时 | 通常很小 |
Check time | 类型检查耗时 | 🔥 通常占总时间的大头 |
Emit time | 产物生成耗时 | 只影响直接产物 |
Total time | 总耗时 | — |
判断标准(经验值 💭):
| 指标 | 健康范围 | 需要警惕 |
|---|
Check time 占比 | < 70% 总时间 | > 85% |
Instantiations | 十万量级 | 百万以上 ⚠️ |
Types | 几万 | 数十万 |
Bind time | 远小于 Check | 与 Check 相当 |
Memory used | < 1–2 GB | > 3 GB(易 OOM) |
🔥 Instantiations 是核心指标。它统计泛型被「填上具体类型」的次数。一个 DeepReadonly<DeepPartial<UnionToIntersection<T>>> 这种组合会在每个使用点产生大量实例化。如果 Instantiations 到百万级,先简化类型,别急着调配置。
想知道具体哪个文件、哪个类型最耗时,用 trace。
1
| npx tsc --noEmit --generateTrace ./trace
|
实测产出:
1
2
3
| trace/
├── trace.json # 时间线(Chrome Trace 格式)
└── types.json # 类型与实例化统计
|
打开方式:把 trace.json 拖进 chrome://tracing,或用 Perfetto UI。
types.json 才是排查类型爆炸的关键——它记录了哪些类型被实例化得最多。可以用脚本聚合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // analyze-types.mjs —— 找出实例化最多的类型
import { readFileSync } from "node:fs";
const data = JSON.parse(readFileSync("./trace/types.json", "utf8"));
const counts = new Map();
function walk(node) {
if (!node || typeof node !== "object") return;
if (node.name && node.count) {
const key = `${node.name} (${node.kind ?? "?"})`;
counts.set(key, (counts.get(key) ?? 0) + node.count);
}
for (const v of Object.values(node)) walk(v);
}
walk(data);
const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30);
for (const [name, count] of top) {
console.log(String(count).padStart(9), name.slice(0, 110));
}
|
典型输出会指出某个类型名被实例化几十万次——那就是要优化的目标。
| 观察到的现象 | 可能原因 |
|---|
| 某个泛型工具类型实例化极多 | 在热点路径反复使用复杂条件类型 |
| 某个第三方类型的实例化占大头 | 该库的类型定义过重 |
大量实例化来自 .d.ts | 某个 @types 包的类型太复杂 |
| 检查时间集中在少数文件 | 这些文件的类型最复杂 |
💡 types.json 比 trace.json 更有用。时间线告诉你「哪慢」,types.json 告诉你「为什么慢」。
| 参数 | 回答什么 |
|---|
--diagnostics | 简版统计 |
--explainFiles | 每个文件为什么被包含 🔥 |
--listFiles | 实际参与编译的文件 |
--listFilesOnly | 只列文件不编译 |
--traceResolution | 模块解析全过程 |
--noErrorTruncation | 完整类型错误 |
1
2
3
4
5
| # 找出意外的文件(幽灵文件、多余 @types)
npx tsc --noEmit --explainFiles | head -60
# 找出编译了多少文件(对比预期)
npx tsc --noEmit --listFiles | wc -l
|
🔥 编译慢的第一嫌疑人是「编译了不该编译的文件」。常见来源:
| 来源 | 表现 |
|---|
include 太宽(含了 dist、node_modules) | 文件数远超预期 |
6.0 之前 types 自动加载全部 @types | Identifiers / Symbols 巨大 |
| 测试文件被纳入产物编译 | dist 里出现 .test.js |
@types 包版本重复 | 同名符号多份 |
1
2
3
4
5
6
7
8
9
| {
"compilerOptions": {
"skipLibCheck": true, // 🔥 收益最大,几乎必开
"incremental": true, // 增量编译
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"
},
"include": ["src"], // 🔥 精确限定
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
|
| 手段 | 收益 | 代价 |
|---|
skipLibCheck: true | 🔥🔥🔥 大 | 不检查 .d.ts(含自己写的) |
精确 include / exclude | 🔥🔥🔥 大 | 需要维护 |
incremental: true | 🔥🔥 二次编译快 | 生成 .tsbuildinfo |
types: [] 显式列出 | 🔥🔥 大 | 需要显式 import 全局类型 |
减少 lib | 🔥 中 | 可能缺类型 |
noUnusedLocals: false | 🔥 小 | 交给 lint |
types 的影响常常被低估:6.0 之前自动加载全部 @types/*,一个装了 40 个 @types 的项目,其中 38 个可能完全用不到,却全部参与类型检查。
1
2
3
4
5
6
7
8
| // 🛑 隐式加载全部 @types(5.x 的行为)
{}
// ✅ 6.0 默认已是 [],显式列出真正需要的
{ "compilerOptions": { "types": ["node"] } }
// ⚠️ 不要为了省事写这个(会加载全部,拖慢编译)
{ "compilerOptions": { "types": ["*"] } }
|
--explainFiles 找出多余文件:
1
| npx tsc --noEmit --explainFiles | grep -E "^node_modules|^\.\./" | head -30
|
性能问题的根因通常在类型设计。
| 反模式 | 问题 | 改法 |
|---|
| 深层递归类型 | 实例化爆炸 | 加深度计数器限制 |
| 在每个使用点展开复杂工具类型 | 重复实例化 | 起个具名别名缓存 🔥 |
| 巨型联合(几百个成员) | 每次比较都遍历 | 分层,或用可辨识联合 |
| 交叉类型嵌套很深 | 展开成本高 | 用 interface extends |
| 条件类型套条件类型 | 组合爆炸 | 拆成多步,用具名中间类型 |
keyof 巨型对象 | 生成巨大联合 | 缩小范围 |
1
2
3
4
5
6
7
8
9
10
| // 🛑 类型内联展开:每个使用点都重新计算
function a<T>(x: T): DeepReadonly<DeepPartial<Prettify<T>>> { /* ... */ }
function b<T>(x: T): DeepReadonly<DeepPartial<Prettify<T>>> { /* ... */ }
function c<T>(x: T): DeepReadonly<DeepPartial<Prettify<T>>> { /* ... */ }
// ✅ 具名别名:编译器可复用计算结果
type FrozenPatch<T> = DeepReadonly<DeepPartial<Prettify<T>>>;
function a2<T>(x: T): FrozenPatch<T> { /* ... */ }
function b2<T>(x: T): FrozenPatch<T> { /* ... */ }
function c2<T>(x: T): FrozenPatch<T> { /* ... */ }
|
1
2
3
4
5
6
7
8
| // 🛑 深递归无限制
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
// ✅ 加深度计数器
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type SafeDeepReadonly<T, D extends number = 5> = D extends 0
? T
: { readonly [K in keyof T]: SafeDeepReadonly<T[K], Prev[D]> };
|
1
2
3
4
5
| // 🛑 深交叉:展开成本随层数增长
type A = B & C & D & E & F & G & H & I & J;
// ✅ 用 interface 继承(编译器处理得更高效,错误信息也更清晰)
interface A2 extends B, C, D, E, F, G, H, I, J {}
|
🔥 interface extends 优于 type &:不仅是可读性,编译器对接口继承的处理路径更短,且错误信息会显示接口名而不是展开整个交叉类型。
1
2
3
4
5
6
7
8
9
| // 根 tsconfig.json ——解决方案文件
{
"files": [],
"references": [
{ "path": "./packages/types" },
{ "path": "./packages/core" },
{ "path": "./packages/web" }
]
}
|
1
2
3
4
5
6
7
8
9
10
11
12
| // packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"incremental": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"],
"references": [{ "path": "../types" }]
}
|
1
2
3
4
| npx tsc --build # 按拓扑顺序增量构建 🔥
npx tsc --build --watch # 监听
npx tsc --build --clean # 清理
npx tsc --build --force # 强制全量
|
| 项目引用带来的 | 说明 |
|---|
| 增量复用 | 未变化的包不重新检查 🔥 |
| 明确边界 | 不能 import 未声明的依赖 |
| 并行构建 | 无依赖关系的包可并行 |
⚠️ 项目引用的成本:
| 成本 | 说明 |
|---|
| 配置复杂 | 每个包都要 composite + declaration |
必须用 --build | 直接 tsc 不走引用逻辑 |
.tsbuildinfo 管理 | 陈旧会导致行为异常 |
| 循环引用不允许 | 需要重构依赖 |
💭 什么时候值得上项目引用:包数 > 5、或全量检查 > 30 秒、或团队规模大。小项目上它纯属负担。
isolatedDeclarations 加速声明生成 🆕:
1
2
3
4
5
6
| {
"compilerOptions": {
"declaration": true,
"isolatedDeclarations": true // 声明生成不再依赖类型推断,可并行
}
}
|
1
2
3
| // 代价:所有导出必须有显式返回类型
export function f(x: number): number { return x * 2; }
// ~~~~~~~~~~~~~~~ 必须写,否则 TS9007
|
| 收益 | 代价 |
|---|
| 声明生成可并行、可缓存 🔥 | 手写所有返回类型 |
| 声明与实现解耦 | 依赖推断的技巧写不出来 |
| 大库构建显著加快 | 需要 declaration 或 composite |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # .github/workflows/ci.yml
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
# 🔥 缓存 .tsbuildinfo,让增量生效
- uses: actions/cache@v4
with:
path: |
**/*.tsbuildinfo
node_modules/.cache
key: tsbuildinfo-${{ hashFiles('**/*.ts', '**/tsconfig*.json') }}
restore-keys: tsbuildinfo-
- run: npm ci
- run: npx tsc --noEmit --incremental
|
| 手段 | 效果 |
|---|
缓存 .tsbuildinfo | 二次 CI 显著变快 🔥 |
缓存 node_modules | 省安装时间 |
--incremental | 必须与缓存配合才有意义 |
| 并行跑 lint / test | 墙钟时间下降 |
⚠️ --incremental 与 --noEmit 一起用需要注意:--noEmit 时 TS 仍会写 .tsbuildinfo(用于缓存检查结果),但不会写产物。确保缓存路径包含所有 *.tsbuildinfo。
在 CI 里设性能门槛(防止劣化):
1
2
3
4
5
6
7
8
9
10
11
12
| #!/bin/bash
# scripts/check-ts-perf.sh
OUTPUT=$(npx tsc --noEmit --extendedDiagnostics 2>&1)
INSTANTIATIONS=$(echo "$OUTPUT" | grep "Instantiations:" | grep -oE "[0-9]+")
MAX=500000
echo "Instantiations: $INSTANTIATIONS (门槛 $MAX)"
if [ "$INSTANTIATIONS" -gt "$MAX" ]; then
echo "❌ 类型实例化数量超门槛,可能有类型体操过重"
echo "$OUTPUT" | tail -20
exit 1
fi
|
💭 这类门槛的价值在于防劣化——单次提交看不出问题,但类型复杂度是「温水煮青蛙」式增长的。