解决 TypeScript 编译速度慢的问题:项目引用、增量编译与 SWC 实践

TypeScript 给大型前端项目带来了类型安全,但也常常带来一个令人头疼的问题:编译越来越慢。当项目膨胀到数百个文件、多个子包相互依赖时,一次 tsc 可能消耗几十秒甚至几分钟,严重拖慢开发与 CI 流程。本文将围绕三种主流优化手段——项目引用(Project References)增量编译(Incremental Compilation)SWC 转译——说明它们的原理、适用场景和落地方式,帮助你系统性地解决编译速度问题。

为什么 TypeScript 编译会变慢

在动手优化之前,先理解瓶颈来源会更有针对性。常见原因包括:

  • 全量类型检查:每次运行 tsc 都会重新解析并检查所有文件,没有缓存。
  • 单体项目结构:所有代码放在一个 tsconfig.json 下,任何改动都会触发整个项目的重新检查。
  • 类型依赖链过长:一个基础类型文件的改动,会级联影响大量下游文件。
  • 编译与转译耦合tsc 同时承担类型检查和代码生成,而代码生成本身并不需要那么重的处理。

针对这些原因,下面三种方案分别从结构拆分缓存复用职责分离三个角度切入。

一、项目引用:把大项目拆成可独立编译的单元

项目引用(Project References)是 TypeScript 官方提供的多包编译方案。它允许你把项目拆成多个子项目,每个子项目有自己的 tsconfig.json,并通过 references 字段声明依赖关系。

基本配置

假设项目分为 coreutilsapp 三个包:

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}
// packages/app/tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" },
    { "path": "../utils" }
  ],
  "include": ["src"]
}

关键点:

  • 被引用的项目必须开启 composite: true,并生成声明文件(declaration: true)。
  • 使用 tsc --build(或 tsc -b)来编译,它会根据依赖图自动决定编译顺序。
  • TypeScript 会为每个子项目生成 .tsbuildinfo 文件,记录编译状态,未改动的子项目会被跳过。

收益与代价

项目引用的最大价值在于增量边界清晰:修改 app 不会导致 core 重新编译。在 monorepo 中配合 pnpm workspace 或 npm workspaces 使用时效果尤其明显。

代价是配置复杂度上升,需要合理划分包边界。如果拆分过细,反而会因为大量小项目而增加管理成本。建议按业务域或复用层次划分,而不是按文件类型划分。

二、增量编译:让 tsc 记住上次的结果

增量编译是最容易落地的优化。它通过 .tsbuildinfo 缓存上次编译的文件签名与依赖信息,下次编译时只处理发生变化的文件及其影响范围。

开启方式

{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./node_modules/.cache/tsconfig.tsbuildinfo"
  }
}

几个实践要点:

  • 缓存文件位置:默认生成在 outDir 或项目根目录,建议统一放到 node_modules/.cache,避免污染仓库,也方便在 CI 中做缓存。
  • composite 的关系:开启 composite 会自动启用增量编译,因此项目引用场景下无需重复配置。
  • CI 中的缓存:在 GitHub Actions 等环境中,缓存 .tsbuildinfo 文件可以显著缩短流水线时间,但要确保缓存 key 与源码、依赖版本关联,避免脏缓存。
- uses: actions/cache@v4
  with:
    path: node_modules/.cache
    key: tsbuildinfo-${{ hashFiles('**/tsconfig.json', 'pnpm-lock.yaml') }}

增量编译对重复编译场景收益最大,比如本地开发中的多次 tsc --watch,或 CI 中基于缓存的二次构建。但如果是全新环境、冷启动编译,它无法带来提升。

三、SWC:把转译从 tsc 中剥离

前面两种方案优化的都是 tsc 本身,而 SWC 走的是另一条路:用 Rust 编写的超快编译器替代 tsc 做代码转译,把类型检查交给独立的 tsc --noEmit

为什么快

SWC 基于 Rust,天然支持多线程,且不做类型检查——它只负责把 TypeScript/JSX 转成目标 JavaScript。在大型项目中,转译速度通常是 tsc10 到 20 倍

在项目中接入

以 Vite 为例,安装插件即可:

npm install -D @vitejs/plugin-react-swc
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
  plugins: [react()],
})

如果使用 Next.js,可以在 next.config.js 中启用:

module.exports = {
  experimental: {
    swcPlugins: [],
  },
  swcMinify: true,
}

对于纯 tsc 项目,可以用 @swc/cli

npx swc src -d dist --config-file .swcrc

类型检查怎么办

SWC 不检查类型,所以需要单独运行:

{
  "scripts": {
    "build": "swc src -d dist",
    "typecheck": "tsc --noEmit"
  }
}

在 CI 中把 typecheck 作为独立步骤,与构建并行执行,既保证了类型安全,又不拖慢产物生成。

适用边界

SWC 并非万能。它不支持 const enum 的某些跨文件行为、部分装饰器元数据场景,以及依赖类型信息做代码生成的库(如某些 NestJS 场景)。在这些情况下,要么保留 tsc,要么使用 ts-jest/babel 等替代方案。建议在引入前跑一遍完整测试。

组合实践:一套可落地的方案

三种手段并不互斥,实际项目中往往组合使用:

  1. 结构层面:用项目引用把 monorepo 拆成若干可独立编译的包。
  2. 缓存层面:为每个包开启增量编译,并在 CI 中缓存 .tsbuildinfo
  3. 构建层面:开发与生产构建使用 SWC 转译,类型检查作为独立任务运行。

一个典型的脚本编排如下:

{
  "scripts": {
    "dev": "vite",
    "build": "swc src -d dist && tsc --noEmit",
    "build:ci": "tsc -b --verbose && swc src -d dist"
  }
}

此外,还有一些辅助手段值得关注:

  • skipLibCheck: true:跳过 node_modules 中声明文件的类型检查,通常能省下可观的時間。
  • isolatedModules: true:让每个文件可独立转译,与 SWC、Babel 等工具的行为保持一致。
  • 限制 include 范围:避免把测试、脚本等无关目录纳入主编译流程。

小结

TypeScript 编译慢并不是无解的难题,关键在于分清瓶颈所在:

  • 如果是重复编译慢,优先开启增量编译
  • 如果是单体项目导致全量检查,考虑用项目引用拆分边界。
  • 如果是转译本身成为瓶颈,用 SWC 把代码生成与类型检查解耦。

三者结合,可以让大型 TypeScript 项目在保持类型安全的同时,把编译时间从分钟级压到秒级。建议从增量编译入手,逐步引入项目引用,最后根据构建链路决定是否切换到 SWC,循序渐进地获得收益。

未经允许不得转载:任鹏个人博客 » 解决 TypeScript 编译速度慢的问题:项目引用、增量编译与 SWC 实践

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏