
1. 先还原现场一个“一屏报错 构建变慢”的真实项目大概一个月前同事把一个刚拉完依赖的前端仓库丢给我看说“项目跑不起来了”。TS 编译那一栏直接滚了一屏红色粗略数了下有四十多个报错而且不是那种集中在某个文件的小问题是散落在src各个目录里的系统级错误。更麻烦的是每次改动一个文件IDE 热更新的等待时间从原来的十几秒涨到了将近两分钟项目窗口右下角的那个进度条几乎看不到头。第一反应是“谁把代码写坏了”但 git diff 看了一圈近期没有任何人动过公共类型文件。后来用tsc --noEmit在命令行跑了一遍才发现问题出在依赖升级之后某个工具库的版本从 2.x 跳到了 3.x类型定义整体重写加上项目里tsconfig.json的strict模式一直开着连锁反应就来了。这一下把两个问题同时暴露出来一方面 TS 报错不是孤立的一个源头的类型变化会牵扯出几十个下游报错另一方面项目一直采用全量编译方式任何小改动都会触发整个src目录重新做类型检查依赖类型一多编译自然越来越慢。这篇文章就围绕这两条线展开。先讲我怎么一步步把这些报错从四十多个压到 0再讲编译效率到底卡在哪最后给出前后端项目都能直接抄的提速配置。内容面向被 TS 报错折磨过、或者觉得tsc编译慢到影响心情的开发者尤其适合那些项目规模已经到了一两万行以上、构建时间开始让人焦虑的团队。2. 先分清楚这一屏报错到底是怎么来的2.1 链式报错 vs 源头报错处理整屏报错的第一原则不要从上往下一个个改。大多数时候屏幕上的四十个错误里只有三四个是真正的根因其余都是链式反应。举一个非常典型的例子。工具库A升级后把某个函数参数的联合类型收窄了A的调用方B中本来传string | number的地方现在只能传string于是B报错。接着C模块里调用了B的结果期望它是一个number但B内部因为类型收窄被迫改动了返回逻辑返回类型从number变成了string | null于是C也报错。再到后续的D、E……错误像多米诺骨牌一样倒下去。所以拿到报错列表时我习惯先按“报错所在文件的引用关系”排个序先看被引用最多的底层模块比如utils、types、api层再看上层业务组件。底层修好了上面很多错误会自己消失。反着来只会越改越乱因为上游类型一变刚才的修改又成新的报错。2.2 按错误类型快速归类除了按引用关系排优先级还要按错误类型归类。我在那次排错中把报错分成了三类处理方式完全不同模块解析类Cannot find module ./xxx或Cannot find module some-lib。这类报错一般是路径别名、依赖缺失、导出方式不匹配导致改的是tsconfig.json的paths、moduleResolution或者补依赖。类型不兼容类Type string is not assignable to type number。这类报错是重灾区多半是依赖升级后类型定义变化或业务代码里的隐式转换。严格模式类Object is possibly undefined、TS2532等。这类是strictNullChecks引发的代码本身逻辑可能没问题但类型上没有做收窄。这三类混在一起时我先修模块解析类因为路径都解析不了的话后面两类报错的定位本身就不可信。再修类型不兼容类最后才是严格模式类。这个顺序在那个项目里很管用四十多个错误中大概有七八个是Cannot find module引起的连锁失败把paths配好之后直接少了三分之一。3. 高频报错复盘从报错信息倒推定位思路3.1 Cannot find module 类先查模块解析链那次项目里最典型的一个报错是Cannot find module /utils/debounce代码里用的是别名路径指向src/utils/debounce文件确实存在。问题出在tsconfig.json的baseUrl和paths没有正确覆盖新加的目录结构。项目之前用的是相对路径最近一次配置整理时把baseUrl写成了./src但paths里只配了/*: [./*]导致/utils/debounce实际解析到了src/utils/debounce却找不到。正确写法在当时的项目中是这样的{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }这里容易踩的坑是baseUrl被设置为./src后paths的相对基准就变成了./src此时如果你仍然写/*: [src/*]实际路径会变成src/src/xxx。所以凡是报Cannot find module但文件确实在的项目先改这里。另一个容易被忽略的是moduleResolution。如果项目是module: ESNext且代码里用了不带后缀的.ts相对导入务必确认moduleResolution是bundler或node而不是classic。classic模式只按目录逐级找index.ts对现代工具链很不友好。3.2 Type X is not assignable to type Y 类关注依赖版本与类型导出那次报错里数量最多的就是这类核心出在一个老牌日期处理库的类型定义升级。某版本之后库的format函数参数类型做了收窄原来接受string | Date | number现在只接受Date | number而我们代码里有大量format(someString)的调用。这种报错不要急着改业务代码。先判断类型收窄是合理收紧还是误伤。我当时的做法去该库的changelog和.d.ts文件里确认新版对输入的显式约束确认是有意为之后统一在上游把字符串先转成日期对象而不是在几十个调用点上逐个处理。如果确认是库本身的类型 bug优先降级或换库而不是用as any硬压。一次两次as any还能接受几十处as any会让后续类型检查形同虚设。项目中还有一个as的坑有时候你以为自己在做类型断言实际上把错误压掉了但真正的问题是某个函数返回了null。我会先确认返回值的可能值再决定是加防御性判断还是改上游逻辑。3.3 Object is possibly undefined 与严格空值检查把错误压制在源头这类报错在打开strict模式后基本每天都会见Object is possibly undefined、TS2532: Object is possibly undefined。很多人第一反应是加!非空断言但非空断言只解决“当前这一行”的报错解决不了“上游为什么可能为空”的问题。我处理这类报错有一个固定套路。先看报错变量的来源如果来自函数参数就在函数入口做收窄比如if (!value) return或if (value undefined) throw new Error(...)。如果来自API返回值就在数据处理层统一过滤空值比如.filter(Boolean)、?? defaultValue而不是在每个组件里反复判断。如果来自Map.get()或Record索引访问考虑用Map.has()先判断或者改用??给默认值。把约束放在入口和边界处后续每个使用点都不用再挨个处理报错数量会肉眼可见地下降。而且这类修改对编译效率也有正面影响类型检查器需要做的控制流分析变少代码路径更清晰tsc在类型收窄上的开销也会小一点。4. 编译效率的隐形瓶颈tsconfig 没配好报错和慢是同时发生的4.1 全量重编译include/exclude 比你想的更影响性能当报错清零后接下来就该解决编译慢的问题。我先做了一个简单测试在不同的include/exclude配置下跑tsc --noEmit耗时差异非常明显。默认情况下很多项目的tsconfig.json长这样{ compilerOptions: { strict: true, target: ES2020, module: ESNext, moduleResolution: node }, include: [src, tests] }这个配置的问题在于它让tsc对src和tests下的每一个.ts、.tsx文件做全量类型检查不管你有没有改过。项目小的时候无所谓但当src膨胀到几千个文件加上tests、配置文件、脚手架模板每次保存都等于把整个项目重新审一遍。排查慢的问题时先用下面的命令确认tsc到底检查了哪些文件tsc --noEmit --listFiles 2/dev/null | wc -l如果这个数字是一万加那恭喜你你的编译慢问题九成出在“不该被检查的文件也被检查了”。我当时那个项目listFiles输出了两万多个文件其中光是node_modules里的.d.ts就占了一大半。虽然node_modules默认不参与业务类型检查但某些配置下tsc会去解析它们的类型声明尤其是当你引用了某些包的深层路径时。4.2 skipLibCheck 与 lib 加载的浪费另一个常被忽视的性能点skipLibCheck。很多老项目从创建那天起就没开过这个选项导致tsc把node_modules里所有.d.ts文件都做一遍完整类型检查。注意业务代码的类型安全性不会被这个选项削弱它跳过的只是“声明文件内部的类型自洽检查”对你的代码没有任何影响。以那个项目为例加上skipLibCheck: true之后tsc的耗时直接从 47 秒降到了 32 秒。这还只是这一个选项的收益如果再配合增量编译效果会更明显。lib配置也会影响性能。很多项目的lib是默认的包含DOM、DOM.Iterable、ESNext等一大堆东西。如果你的项目只跑在 Node.js 环境纯后端或 Node 脚本项目可以把lib收敛成{ compilerOptions: { target: ES2022, lib: [ES2022] } }少加载一个DOM声明类型环境里的全局类型变少tsc在解析每个文件时需要考虑的全局符号也会减少。这个优化对大项目收益很明显但有一个前提项目里确实没有使用document、window等 DOM API。如果是浏览器项目别动lib。4.3 测量编译耗时先量化再优化“感觉变快了”不算数要量化。我一般用tsc --noEmit --diagnostics跑一次看结尾的耗时输出Files: 1200 Lines: 56000 Nodes: 210000 Identifiers: 78000 Symbols: 60000 Types: 11000 Memory used: 220000k I/O read: 18.0s I/O write: 0.0s Parse time: 2.5s Bind time: 1.8s Check time: 28.6s Emit time: 0.0s Total time: 52.9s注意这里的I/O read和Check time。I/O read高说明文件解析数量大Check time高说明类型计算复杂。我那次优化的直接目标就是把I/O read降下来I/O read从 18 秒降到 6 秒时Total time自然崩到了 20 秒以内。这种量化还有一个好处每次改动tsconfig或升级依赖之后能够立刻判断是变快了还是变慢了而不是靠玄学体会。5. 落地提速一套既能保证类型安全又能快速反馈的配置5.1 开启增量编译incremental / tsBuildInfoFile解决全量编译慢最直接的手段是增量编译。tsc本身内置了这个能力只是很多项目没有用起来{ compilerOptions: { incremental: true, tsBuildInfoFile: ./node_modules/.cache/tsbuildinfo } }配置完成后第一次编译仍然会全量做但会在node_modules/.cache目录下生成一个.tsbuildinfo文件记录每个文件的版本信息和依赖关系。之后你再运行tsc或 IDE 触发的编译它只检查“自上次构建以来发生变化”的文件及其受影响的依赖链。我这里把tsBuildInfoFile指向node_modules/.cache是为了不让构建缓存污染项目根目录也不会误入 git 版本管理。如果你用 CI不要忘记保留该缓存的持久化否则每次 CI 都从全量编译开始增量等于没开。要注意增量编译对“改一个公共类型文件”的场景帮助有限因为公共类型变更会牵连大量下游文件。但日常业务开发中80% 的改动都局限在单模块内这时候增量编译的体验非常好。我那个项目开了增量之后日常单文件改动的编译时间从 40 多秒直接降到秒级。5.2 合理的 noEmit 与声明文件策略另一个常见问题是项目里分不清“纯类型检查”和“产物输出”两个阶段。如果你只是在写业务代码开发期根本不需要tsc生成 JS 文件——那是打包工具Vite/Webpack/esbuild做的事。直接在tsconfig.json中开启{ compilerOptions: { noEmit: true } }noEmit让tsc只做类型检查跳过 emit 阶段可以省下不少 I/O 开销。如果你同时使用incremental注意noEmit模式下增量信息依然会被正常记录没问题。不过有一类项目例外你正在开发一个要被其他项目引用的 npm 库那就必须生成.d.ts声明文件。这时候建议把类型检查与声明文件生成拆开开发期用noEmit模式快速检查发布前单独跑一次tsc --emitDeclarationOnly或tsc -p tsconfig.build.json来输出声明。不要把声明文件生成塞进每次开发的编译里。5.3 用 esbuild/swc 跑开发期编译tsc 只做产物输出如果配置优化后依然觉得慢可以考虑换工具链。我自己在团队里推过一种方案开发期用tsx或esbuild直接转译 TS绕开类型检查类型检查做成一个单独的tsc --noEmit命令放在pre-commit或者 CI 里兜底。以 Node.js 后端项目为例直接用tsx运行 TS 文件npx tsx watch src/index.tstsx基于 esbuild转译速度是tsc的几十倍冷启动也快很多。整个过程不检查类型只做语法层面的编译。这样做的风险是类型错误不会在运行前暴露。所以一定要有一个“正式检查”的入口比如tsc --noEmit在提交前或 CI 里跑一次保证代码质量。这就是把“快速反馈”和“严格校验”分离开来的思路。前端 Vite 项目同理Vite 内部用的也是 esbuild通常你只需要在build脚本里保留vue-tsc --noEmit vite build这样的类型检查步骤开发期完全可以把类型检查交给 IDE 的 TS Language Server而不是让命令行构建去重复做一遍。6. 更彻底的项目级方案Project References 拆分编译单元6.1 为什么单 project 编译在大中型仓库里注定慢当项目进一步增长即使开了增量编译、skipLibCheck、noEmit也会觉得编译时间不够理想。这是因为一个巨大的 TypeScript Project 内部维护着一张巨型依赖图任何一次编辑都要在这张图里做全量类型收窄和符号绑定。TS 官方针对这个问题给出的方案是 Project References把一个大项目拆成多个小项目每个小项目独立编译、暴露自己的.d.ts作为对外接口。比如一个 monorepo 风格的仓库可以拆成packages/shared、packages/api、packages/app三个子项目。app依赖apiapi依赖shared。每次改动shared时只需要重新构建shared和它的下游api、app而如果改动的是app自身shared和api的检查结果可以直接复用上次的构建产物。这种方案的收益在持续集成中更加明显。没有 Project References 时CI 每次从零编译整个仓库有了拆分之后CI 可以缓存底层包的类型产物上层包的编译工作量大幅下降。6.2 拆分步骤与注意点几点实操经验按步骤来先按依赖方向拆分不要按目录拍脑袋。从底层工具库开始逐层向上。每个子项目必须设置composite: true这是 Project References 的硬性要求。composite: true会自动开启declaration、declarationMap、incremental等选项。根目录建一个tsconfig.json只做 references 聚合不直接编译任何代码{ files: [], references: [ { path: ./packages/shared }, { path: ./packages/api }, { path: ./packages/app } ] }在子项目里把对外暴露的入口明确写在include中避免把整个src无差别纳入。子项目之间用路径导入时务必让tsc能识别到“对方项目”而不是“对方源码”。也就是说api项目应该引用shared构建后生成的.d.ts而不是直接去解析shared/src下的.ts文件。这一步很容易搞错配错了就退化成单项目编译收益全丢。拆分这件事有一定成本第一次搭可能要花半天到一天。但如果你的仓库已经有 5 万行以上代码、编译时间超过 40 秒这笔投入非常值得。小项目不建议做反而增加心智负担。6.3 改造后的三者配合拆分完成后我一般把开发、构建、检查三条链路分开配置场景命令行作用开发调试tsx watch src/index.tsNode 项目或 Vite 自带 dev server快速反馈不检查类型类型检查tsc --noEmit -p tsconfig.json或按需tsc --build严格校验提交前必跑产物构建tsc -p tsconfig.build.json或交给打包器输出 JS 与声明文件这样划分之后开发期的等待时间不再是tsc主导类型安全也有专门的检查环节兜底。我在实际项目中最大的体会是类型检查不该和编译耦合得太紧它们应该像“单元测试”和“代码运行”一样被分开对待。7. 最后分享几点实际体会把报错清零和编译提速放在一起做本质上是同一个问题你对 TypeScript 编译模型的理解决定了你能优化到什么程度。很多人觉得报错多就是代码写错了其实多数情况下是配置和依赖的类型定义在打架同样编译慢也不完全是项目大的错很多时候只是tsconfig用了默认值从没针对自己的项目调整过。按我现在的习惯每隔几个月会主动把项目的tsconfig.json拿出来看一遍检查这几项skipLibCheck有没有开、incremental有没有配、include/exclude有没有纳入多余文件、moduleResolution是否和实际工具链匹配。检查成本很低收益却很稳定。另外一个小技巧如果连续几次遇到同一个库的类型报错尽早决定要不要包一层自己维护的“类型适配层”而不是在各处零散 workaround。适配层统一收敛外部类型变化业务代码的破坏面会小得多后续升级依赖时也能快速定位问题边界。