想象你是一家高档餐厅的主厨,要为一场百人宴会准备 20 道菜。传统做法是:哪怕只有一位客人临时加了一道小菜,你也要把冷菜、热菜、汤品、甜点全部从零开始重做一遍——洗菜、切配、起锅、摆盘,忙到深夜。这就是 TypeScript 编译器的“全量编译”:每次改动一行代码,它都重新读取、解析、类型检查、生成全部文件。
聪明的你换了一种方式:把每道菜的做法记在一本厚厚的工作笔记里——哪道菜已经完成、用了哪些食材、哪道菜依赖哪道菜。客人加菜时,你翻开笔记,发现只需要重做那一道新菜,外加两道用了同一种酱汁的旧菜,其余 17 道直接端上来就行。这就是 TypeScript 的“增量编译”:借助 .tsbuildinfo 缓存文件记录编译状态,只重新处理“变了的文件”和“受波及的文件”。
本文将从这本“工作笔记”出发,带你理解编译为什么慢、增量编译如何加速、如何用项目引用分灶管理、如何配合 watch 模式与构建工具,最后给出实战优化清单。
对 AI 从业者而言,掌握编译性能优化是驾驭 AI 辅助编程与大规模前端工程的核心基本功:AI 每秒钟都在产生新代码,编译越快,你的迭代飞轮就转得越快。
可视化演示
展示TS全量编译流程、增量编译的文件变更检测与缓存复用机制、以及全量与增量编译的性能对比,交互切换三种模式理解编译优化原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+captions.full+'
编译性能瓶颈
宴会为什么慢?不是大厨手慢,而是“每道菜都重做”这件事本身。假设宴席上有 100 道菜,每道菜平均要 5 分钟,一次全量备菜就是 500 分钟。TypeScript 编译器也一样:一个 10 万行代码的工程,全量编译动辄几十秒甚至数分钟。如果你改一个字就要等这么久,开发节奏会被彻底拖垮——这就像客人催菜时,你还在从头熬高汤。
编译器的工作分三步:解析(把 .ts 源码拆成语法树)、类型检查(沿着 import 引用关系逐文件核对类型)、产物生成(输出 .js 与声明文件)。全量编译时,这三步对每一个文件都要完整执行,而且类型检查是跨文件的:一个文件里定义了接口,一百个文件引用了它,改动这个接口,那一百个文件都要重新核对一遍。因此耗时随文件数量线性增长,而类型检查的波及面还会让实际耗时远超想象。
所以问题一目了然:全量编译相当于“每次加菜都重做整桌”。要提速,就得像聪明的厨师那样——不做无用功。接下来我们看看那本“工作笔记”是怎么改变一切的。
- T_parse 解析阶段编译器读取源码文件并生成语法树,相当于大厨把菜谱逐字读懂并记在脑中。
- T_check 检查阶段跨文件核对类型是否自洽,相当于核对每道菜的食材是否与其他菜冲突。
- T_emit 产物阶段把 TS 转成可运行的 JS 与声明文件,相当于把半成品装盘出锅。
- N × t_f 线性估算N 是文件总数,t_f 是单文件平均耗时;文件越多,总耗时越高。
增量编译原理
大厨的工作笔记,就是 TypeScript 的 .tsbuildinfo 文件。它记录了三样东西:每个文件的“版本指纹”(内容哈希)、文件之间的依赖关系、以及哪些文件已经完成。有了它,编译器就像有了“记性”——不再是一个每次开工都失忆的临时工。
开启 incremental 编译选项后,tsc 每次编译会先读取 .tsbuildinfo。编译时,它先对比所有文件的哈希,找出真正变更的文件;再沿着依赖图,找出“下游受影响”的文件——比如你在 a.ts 里改了一个接口,import 了 a.ts 的 b.ts、c.ts 都要重新做类型检查。只有这部分文件会被重新处理,其余文件直接从缓存标记为已是最新,连打开都不需要。
记事本的威力在于“知道什么不用做”。不过,当宴会规模大到几十个团队同时备菜时,一本笔记本也不够——我们需要把厨房分成若干个独立灶台,这就是项目引用。
- t_c 变更文件开销重新解析并检查真正被修改的那个文件,相当于一道新菜的制作成本。
- 求和运算符把下游所有受波及文件的开销逐项累加,相当于把同酱汁的几道旧菜一起重做。
- D(c) 下游集合依赖变更文件 c 的全部文件集合,由依赖图推导出来。
- 远小于关系当一次改动只影响少量文件时,增量耗时接近常数,与工程总量几乎无关。
项目引用拆分
一家大餐厅有三层楼:一楼冷菜、二楼热菜、三楼甜点。如果所有菜都挤在一个灶台做,光找锅铲就要半天。Project References 的思路就是把工程拆成多个子项目,每个子项目有自己的菜谱(tsconfig)和成品(编译产物),就像分灶管理,各灶互不干扰。
根 tsconfig.json 通过 references 字段列出子项目;每个子项目必须开启 composite: true(它自动开启 incremental 并强制生成 .d.ts 声明文件)。构建时,被依赖的项目先构建,产出声明文件;依赖方只需读取这些声明文件,而不用重新解析源码。构建顺序由依赖图决定——这就是拓扑排序:先做冷菜,再做热菜,最后拼盘,次序不能乱。
分灶之后,热菜师傅再也不用等冷菜师傅的全部步骤,只需要验收对方的半成品。但还有个问题:灶台要烧起来,得有人盯着食材柜——这就是 watch 模式。
- 求和公式总时间等于所有子项目构建时间相加,分灶后各灶位可独立并行。
- 拓扑约束 j < i项目 i 依赖的项目 j 必须排在 i 前面构建。
- dep(P_i) 依赖集合项目 i 所依赖的兄弟项目集合。
- 声明文件复用收益被依赖项目输出
.d.ts后,下游无需重复解析源码,相当于半成品共用。
Watch模式
宴席筹备期间,采购员会不停把新食材送进后厨。如果每次来一根葱都要把整本笔记翻一遍重算,效率依然很低。聪明的后厨会安排一个“哨兵”守在食材柜前:有新食材进来,他喊一嗓子“xx 换了”,相关灶台才动手。这就是 watch 模式。
tsc --watch 会启动两个机制:文件系统监听(fs.watch)捕捉文件变动事件,以及基于 .tsbuildinfo 的增量重建。为了防止你连续按 Ctrl+S 十次就触发十次编译,事件会先进入队列,经过一个短暂的防抖窗口才触发编译——你连续保存三次,它只编译一次。编译完成后,它还会通过内部 API 通知编辑器刷新类型信息,让 IDE 的错误提示几乎实时更新。
哨兵让大厨不用“一分钟重看一遍 20 道菜谱”。不过,不是每家餐厅都用同一套锅具——不同构建工具,就像是不同流派的厨房。接下来我们串门看看。
- δ_debounce 防抖延迟事件触发后等待的最短间隔,等编辑动作稳定后再动手。
- T_incr 增量编译watch 内部复用的正是增量编译,而非全量重建。
- T_cycle 单轮周期从文件保存到编辑器中看到新类型提示的总时长。
构建工具集成
同样是做菜:传统大锅菜(webpack + ts-loader)讲究原汁原味但慢;快炒店(esbuild/swc)用猛火大铁锅,几分钟出锅却不上妆;中央厨房(babel)负责把半成品切好装盒,不做质量检验;一体化餐厅(vite)直接让快炒师傅掌勺,再单独安排质检员。
webpack 生态里,ts-loader 逐文件用 tsc 转译,类型检查默认串行,很慢;社区常用 fork-ts-checker-webpack-plugin 把类型检查挪到独立进程并行。babel 用 @babel/preset-typescript 只做“剥类型”的转译,速度快但完全不做类型检查。swc 和 esbuild 用 Rust/Go 并行转译,速度比 tsc 快一两个数量级,同样牺牲类型检查。vite 在开发期用 esbuild 预构建依赖,再配合 tsc --noEmit 单独做类型检查。
选哪家厨房取决于你要不要质量检验。但无论哪一派,都有几招通用的“省时秘笈”——下一节我们翻开大厨的锦囊。
- S 加速比代表用新式灶台后比传统灶台快多少倍。
- 分子 T_tsctsc 完整走解析、检查、产物三步的耗时。
- 分母 T_transformesbuild 等只做转译的耗时,少了类型检查环节。
- 权衡关系速度提升来自“不做检查”,所以生产构建仍需 tsc --noEmit 兜底。
优化策略
大厨的锦囊里装着几条老规矩:隔夜高汤不用再尝咸淡(跳过已封装的第三方库检查);同一种切配交给一人专做(隔离转译);三四个帮厨同时开工(并行构建);做好的成品直接入冰箱(缓存复用)。
具体到配置:skipLibCheck: true 让编译器跳过所有 .d.ts 声明文件的类型检查——第三方库通常在发布前已自检,重复检查纯属浪费。isolatedModules 配合 esbuild/swc/ts-loader 的 transpileOnly 模式时,每个文件独立转译、互不等待,天然适合并行。构建器内置的多进程并行(如 webpack 的 parallel 配置)能充分利用多核 CPU。最后,把 .tsbuildinfo 和 node_modules 一起缓存进 CI,'热启动'能省掉大半时间。
锦囊虽小,样样都管用。但真正到了实战,光有锦囊还不够,还要会“望闻问切”——最后我们来一次后厨全面体检。
- T_src 源码耗时工程自身文件的编译开销,这部分无法跳过。
- ε·T_lib 库文件开销被跳过检查的声明文件理论耗时,乘以趋近于零的系数。
- T_after 优化后耗时近似等于源码耗时,库检查成本可忽略。
实践与排查
新来的帮厨说“我们这儿编译好慢”,你不能直接信,要拿秒表逐环节测:从备菜到装盘到底卡在哪一步?这就好比用体检报告说话,而不是凭感觉猜病。
tsc --diagnostics 和 --extendedDiagnostics 会输出各阶段的耗时统计:读文件多少毫秒、类型检查多少毫秒、生成代码多少毫秒,甚至内存占用。排查清单依次看:.tsbuildinfo 是否存在且被 CI 缓存(没有则是冷启动);是否误开全量编译(比如构建脚本里 tsc 没带 incremental);依赖图是否过大(大量文件 import 同一个大模块,导致单点被波及面巨大);是否重复编译 node_modules 里的类型。实践上,把 .tsbuildinfo 提交进版本库或 CI 缓存目录,是收益最大的一步。
体检完,你终于明白:慢不是大厨手笨,而是没带记事本、没分灶、没让哨兵盯梢。把这四件事做齐,百人宴会也能从容不迫。
- T_warm 热启动缓存命中时,先读依赖图再增量编译,成本很低。
- T_cold 冷启动没有
.tsbuildinfo,只能全量重来,接近原始全量耗时。 - 对比意义两者差距就是缓存带来的收益,也解释了为何 CI 上增量效果常打折扣。
AI 应用场景
大型前端工程 CI 构建加速
阶段:前端工程化与持续集成
解决的问题:传统全量编译在 CI 上每次冷启动要 10 分钟以上;配合 AI 代码生成后,改动频率更高,等待更致命。用项目引用拆分 + 提交 .tsbuildinfo 缓存 + 并行构建,构建时间可降到 1 分钟以内。
具体案例:某团队在 monorepo 中把 UI 组件库、业务页面、工具库拆成 5 个子项目,CI 缓存 .tsbuildinfo 与 node_modules;AI 助手改完代码提交后,流水线增量编译 40 秒完成,全量则要 8 分钟。
编辑器内 AI 编程助手的实时类型反馈
阶段:AI 代码生成与实时校验
解决的问题:Copilot 类工具一次补全几十行代码,若编译器全量检查,IDE 要卡顿数秒才能给出类型错误。TS Server 基于增量编译,让错误标记在几百毫秒内刷新。
具体案例:开发者在 VSCode 中用 Continue/Copilot 生成了一段调用某接口的代码,编辑器的 ts.server 立即沿依赖图做增量检查,红波浪线在 0.5 秒内出现,AI 得以迅速自我修正,而不是把错误带到下一次编译。
微前端多团队并行交付
阶段:工程架构与并行开发
解决的问题:多团队同一仓库互相影响,全量构建互相拖累。项目引用把各业务域拆成独立子项目,AI 生成的各模块代码独立增量编译、独立发布。
具体案例:电商中台把商品、订单、用户三个域拆成三个 project,每个子项目独立 watch;AI 改完订单域只编译订单域及其引用方,其余模块零感知,各团队各自验收半成品。
Python 演示
"""Simulate incremental compilation: only rebuild changed files and dependents."""import hashlib class IncrementalCompiler: def __init__(self, source_map): self.source_map = dict(source_map) self.dep_map = {} # 被依赖文件 -> 依赖它的文件列表 self.cache = {} # 文件路径 -> 内容哈希(大厨的记事本) self.compile_count = 0 # 实际重写文件的次数 def register_deps(self, file, deps): # 记录 file 依赖了 deps 中的哪些文件 for dep in deps: self.dep_map.setdefault(dep, []).append(file) def digest(self, path): # 计算文件当前内容的指纹(哈希) return hashlib.md5(self.source_map[path].encode()).hexdigest() def is_stale(self, path): # 内容哈希与笔记不一致 => 文件变了 return self.cache.get(path) != self.digest(path) def rebuild(self, path): # 重新“编译”并更新笔记 self.cache[path] = self.digest(path) self.compile_count += 1 def plan(self): # 找出变更文件,并沿依赖图收集下游 queue, seen = [], set() for path in self.source_map: if self.is_stale(path) and path not in seen: seen.add(path) queue.append(path) for path in list(queue): for downstream in self.dep_map.get(path, []): if downstream not in seen: seen.add(downstream) queue.append(downstream) return queue def run(self, full=False): # full=True 模拟全量编译;否则只处理变更及下游 jobs = list(self.source_map) if full else self.plan() rebuilt = 0 for path in jobs: if self.is_stale(path): self.rebuild(path) rebuilt += 1 return rebuilt, len(jobs) if __name__ == "__main__": src = {} for i in range(8): src["a%d.ts" % i] = "export const v%d = %d;" % (i, i) src["app.ts"] = "import {v0} from './a0'" c = IncrementalCompiler(src) # app.ts / a1 / a4 / a7 都依赖 a0.ts c.register_deps("app.ts", ["a0.ts"]) c.register_deps("a1.ts", ["a0.ts"]) c.register_deps("a4.ts", ["a0.ts"]) c.register_deps("a7.ts", ["a0.ts"]) print("cold start : rebuilt %d, checked %d" % c.run(full=True)) c.source_map["a0.ts"] = "export const v0 = 100;" # 模拟修改 a0 rebuilt, checked = c.run(full=False) print("incremental: rebuilt %d, checked %d" % (rebuilt, checked)) print("total real rebuilds:", c.compile_count)
self.dep_map = {}记录“谁依赖谁”的关系表,相当于菜谱间的关系网。return hashlib.md5(self.source_map[path].encode()).hexdigest()用哈希做“食材指纹”,内容一变指纹就变。if self.is_stale(path) and path not in seen:发现变更文件,并把它们排入待处理队列。for downstream in self.dep_map.get(path, []):遍历下游依赖,把受波及的文件也纳入检查范围。jobs = list(self.source_map) if full else self.plan()全量与增量的分水岭:全量处理所有文件,增量只处理计划队列。c.register_deps("a1.ts", ["a0.ts"])注册依赖关系,告诉编译器 a1 依赖 a0。c.source_map["a0.ts"] = "export const v0 = 100;"模拟真实开发中的一次修改。print("incremental: rebuilt %d, checked %d" % (rebuilt, checked))输出证明:检查面变小,真实重建更少。
常见误区
误区增量编译嘛,就是哪个文件变了就编译哪个文件,其他文件完全不用管,所以才会这么快。
正解编译器必须沿依赖图检查“下游”:你改了 a.ts 的接口,import 它的 b.ts 类型可能不匹配,必须重新检查。好比面粉换了配方,包子馅、面条面团都要重新验货;不过验完发现内容没变,就不会重新生成产物。所以增量编译的检查面是“变更 + 下游”,只是产出面变小。
为何常见:“改哪个编哪个”太符合直觉了,容易忽略类型系统是跨文件的全局约束。
误区我用了 `tsc --watch`,编译飞快,这肯定就是增量编译了吧,不用再单独配置 incremental。
正解watch 是“触发机制”,增量编译是“省力机制”,二者是配套关系:watch 靠文件监听发现变化,再调用增量编译来避免全量。如果没开 incremental,watch 每次仍会全量重建——就像哨兵喊了“菜换了”,灶台师傅却把整桌菜重做一遍。
为何常见:watch 模式在工程里总是显得快,大家把功劳记在监听头上,实际省时的是缓存机制。
误区`.tsbuildinfo` 不就是个缓存吗?缓存删了会自动重新生成,删掉也没关系,还能保持仓库干净。
正解生成当然可以,但删掉等于把冷启动的几十秒甚至几分钟还回来。增量编译的全部收益都在这本“工作笔记”里;它应当进入 CI 缓存甚至版本库,让流水线热启动。笔记可以重抄,但重抄的那一晚你又要加班。
为何常见:大家习惯了 node_modules 缓存可删,误以为所有缓存都一样可有可无。
误区项目引用就是把工程拆成多个 tsconfig.json,每个文件夹一份配置文件就行。
正解项目引用必须有三个要素:根配置用 references 列出子项目、子项目开启 composite: true、按依赖拓扑序构建。只拆 tsconfig 不配 references,tsc 根本不会联动,等于分了灶却没安排出菜顺序,各灶还是各自为战。
为何常见:“拆分”两个字太好理解了,但复合配置的协同机制才是关键,初学者常忽略它。
误区既然增量编译这么快,那生产环境的构建也一直开增量,肯定能提速。
正解增量编译的目标是“改哪里验哪里”,但首次构建一定是全量;当依赖图极其复杂、一次改动波及半个工程时,增量收益会缩水。生产构建常要求干净环境,缓存不可用时只能冷启动。它是最优策略,不是魔法。
为何常见:宣传里只讲热点路径的省时,忽略了冷启动和波及面放大的现实。
面试题
Q1. 增量编译的“记事本”在 TypeScript 中对应哪个文件?
- A.tsconfig 配置文件
- B.构建缓存记录文件← 正确答案
- C.依赖锁定清单文件
- D.编译入口清单文件
答案:B。增量编译的缓存对应 .tsbuildinfo 文件,它记录文件哈希、依赖关系和编译状态。tsconfig 是配置,锁定清单属于包管理器,入口清单不是真实产物。
Q2. 项目第一次执行增量编译时,表现通常是?
- A.与全量编译耗时相当← 正确答案
- B.完全跳过类型检查过程
- C.只编译程序入口文件
- D.不产出任何编译结果
答案:A。首次编译没有 .tsbuildinfo 缓存,相当于大厨手里没有记事本,所有菜都得重做,所以耗时与全量编译相当。缓存建立后,后续编译才会变快。
Q3. 修改 a.ts 中一个接口后,import 它的 b.ts 也会被重新检查,原因是?
- A.b.ts 文件内容随之改变
- B.b.ts 的类型可能受影响← 正确答案
- C.b.ts 的格式需要重排
- D.编译器尚未更新记录
答案:B。类型检查是跨文件的全局约束:a.ts 的接口变了,引用它的 b.ts 可能类型不匹配。好比面粉配方变了,包子馅也要重新验货;b.ts 的内容本身没变,但它的“安全性”需要重新确认。
Q4. 开启项目引用时,被引用的子项目必须开启哪项配置?
- A.复合构建选项开关← 正确答案
- B.错误即停产物开关
- C.严格空值检查开关
- D.模块互操作开关
答案:A。项目引用要求子项目开启 composite: true,它自动启用增量编译并强制生成声明文件。错误即停、严格空值、模块互操作都是独立功能,与项目引用的联动机制无关。
Q5. skipLibCheck 的主要作用是跳过哪类文件的类型检查?
- A.工程内手写的源文件
- B.第三方类型声明文件← 正确答案
- C.编译后生成的产物文件
- D.工程内项目的配置文件
答案:B。skipLibCheck 跳过所有 .d.ts 声明文件(主要来自第三方库)的检查,因为这些库发布前已自检过。好比隔夜高汤不用再尝咸淡。源码、产物、配置都不属于被跳过的对象。
Q6. watch 模式与增量编译的关系是?
- A.两者互斥只能二选一
- B.监听模式依赖增量机制← 正确答案
- C.监听可完全取代增量
- D.增量必须依托监听模式
答案:B。watch 负责“发现变化”(文件监听 + 防抖),增量编译负责“省力重建”,两者是配套关系。没有 watch,增量编译也能在命令行手动触发;没有增量编译,watch 每次只能全量重建。
Q7. 在 CI 流水线上,增量编译的收益常不如本机明显,最可能的原因是?
- A.CI 缺少持久构建缓存← 正确答案
- B.CI 不需要做类型检查
- C.本机的增量机制更先进
- D.CI 网络延迟拖慢编译
答案:A。增量收益依赖 .tsbuildinfo 缓存。如果 CI 没有持久化缓存目录,每次流水线都是冷启动,等于全量编译。好比大厨换了新厨房,记事本没带过去,一切只能从头做。
Q8. 下列哪些做法能有效缩短 TypeScript 编译耗时?
- A.开启跳过声明文件检查← 正确答案
- B.用项目引用拆分工程← 正确答案
- C.启用增量编译复用记录← 正确答案
- D.删除源码的类型标注
答案:ABC。skipLibCheck 减少检查面,项目引用实现分灶并行,增量缓存避免重复劳动,三者都有效。删除类型标注虽然能提速,但破坏了 TypeScript 的核心价值,不能被当作优化手段。
Q9. 关于项目引用的构建顺序,下列说法正确的有?
- A.被依赖项目理应先行← 正确答案
- B.顺序按依赖图排列← 正确答案
- C.各项目可无序并行
- D.前序产物供后续用← 正确答案
答案:ABD。项目引用按拓扑序构建:被依赖项目必须先行,顺序由依赖图决定,前序项目产出的声明文件供后序项目消费。若允许无序并行,后序项目会读到过期或不存在的声明文件。
Q10. 下列哪些情况会导致增量编译失效或收益大幅缩水?
- A.缓存记录文件被删除← 正确答案
- B.切换分支改动幅度极大← 正确答案
- C.修改编译选项导致缓存失效← 正确答案
- D.保存文件后立即构建
答案:ABC。缓存删除后只能冷启动;分支切换若改动波及半个工程,增量退化为接近全量;修改编译选项也会使旧缓存失效。而保存后立即构建正是增量编译的典型使用场景,不会导致失效。