前端构建速度优化:别只怪打包工具慢
构建慢很容易被归因到工具。
Webpack 慢,就换 Vite;Vite 慢,就等 Rolldown;CI 慢,就加机器。工具升级当然有价值,但很多项目慢在依赖太重、类型检查太散、代码生成太粗、缓存没用起来,跟工具本身关系不大。
我遇到过一个项目,本地冷启动要几十秒,大家一直说“Webpack 不行”。后来分析发现,首页直接 import 了整个图标库、整个日期库、多语言包和一堆后台页面组件。换工具能快一点,但不解决依赖该怎么按需引入,迟早还会慢回来。
构建速度优化要先找瓶颈,不要先换信仰。
先分清慢在哪里
“构建慢”是个太粗的说法,得先拆开——它至少可能慢在装依赖、开发服务器启动、首次页面编译、热更新、类型检查、生产打包、CI 拉缓存这几个完全不同的环节上,而每个环节对应的解法几乎没有交集。热更新慢,大概率是模块依赖链太长,改一个文件牵动一大片重新编译;生产打包慢,通常卡在压缩和 sourcemap 生成这两步;类型检查慢,往往是 TS 没划项目边界,一处改动触发全量检查;装依赖慢,多半是依赖太多加上 lockfile 没缓存。把“慢”笼统地甩给打包工具,就是因为没先做这一步拆分——瓶颈在装依赖,你去换 Vite 也没用。
所以第一步永远是量化,把各阶段耗时先记下来,哪怕只是手动掐表:
1npm install: 45s 2dev server ready: 12s 3first page load: 8s 4typecheck: 35s 5build: 70s
有了拆分,才知道该动哪里。掐表虽然土,但比"感觉构建变慢了"精确得多——它把一个笼统的抱怨变成了几个能对比的数字,下次再慢,一比就知道是哪个环节退化了。稍微正式一点的做法是让工具自己吐时间:多数打包器和 CI 平台都能打印各阶段耗时,Vite 有 --profile,webpack 能出 stats.json,CI 平台的每个 step 也都标着秒数。把这些数字定期记一笔,构建性能就有了基线,退化能被及时发现,而不是等到某天大家都觉得"CI 怎么这么慢了"才回头查——那时候往往已经是好几处一起退化,更难拆。
装依赖这一步,本身就能省出不少时间
拆完之后如果发现装依赖占了大头,先别急着优化构建。装依赖慢,最直接的一刀是换包管理器。这一年 pnpm 已经是很多团队的默认选择,它用全局内容寻址 store 加硬链接的方式,避免了 npm/yarn 那种每个项目都把依赖完整复制一份的开销,冷装和热装都明显快,磁盘占用也小。如果还在用 npm,光是切到 pnpm 往往就能把安装时间砍掉一大截。
CI 上装依赖有个容易忽略的区别:npm install 会去解析版本、可能改动 lockfile,而 npm ci / pnpm install --frozen-lockfile 严格按 lockfile 装、跳过解析,既更快也更可复现。CI 里应该一律用后者——顺带还能防住“本地能跑、CI 挂了”这类锁文件漂移问题。--frozen-lockfile 还有个副作用是好事:如果有人改了 package.json 却忘了更新 lockfile,这个模式会直接报错而不是默默帮他改,等于在 CI 里加了一道"依赖声明和锁文件必须一致"的门禁。本地开发用普通 install 图方便,CI 用 frozen 图确定性,这个分工基本是标配。
还有一个反直觉的点:装依赖慢有时不是网络慢,而是某些包在装的时候要跑 postinstall 脚本,编译原生模块、下载二进制(比如某些图像处理、Puppeteer 之类)。这类包一多,安装时间就被它们的构建脚本拖住了。排查时留意一下 install 日志里有没有大段的编译输出,能不能用纯 JS 的替代品换掉,或者把它们的二进制也纳入缓存。
pnpm 这一年对 postinstall 脚本的态度也变了,出于供应链安全考虑,它默认不再自动执行依赖的构建脚本,要显式用 onlyBuiltDependencies 之类的配置把信任的包列出来才跑。这个变化第一次遇到会有点措手不及——某个原本靠 postinstall 下二进制的包突然"装了但用不了",其实是脚本被默认拦下了。理解这个默认行为,一方面能少踩坑,另一方面它也是个提醒:postinstall 是供应链攻击的常见入口,把跑构建脚本的包收敛到一个明确的白名单里,既安全、又顺带让你看清到底哪些依赖在装的时候干了额外的活。
CI 上还有个省时间的小开关是跳过不必要的可选依赖和脚本。生产构建镜像里不需要的那些开发期二进制(比如本地才用的浏览器、可视化工具),可以通过 --ignore-scripts 或者环境变量跳过它们的下载编译,只在真正需要的环境里装。这类"按环境裁剪依赖安装范围"的手段,比笼统地优化网络更有针对性。
依赖是最常见的隐形成本
依赖越多,构建工具要解析、转换、预构建的东西越多。真正拖慢构建的依赖问题,几乎都能归到几种反复出现的写法上:为了用一个函数引入整个工具库、图标库按包全量导入、日期库把所有 locale 都打进来、编辑器和图表地图这类重组件在首屏同步加载、以及同一个包在依赖树里存了两三个版本。这些单看都不起眼,叠在一起就是构建时间里那笔说不清道不明的固定开销。
最典型的是全量导入:
1import * as Icons from '@icon-pack/all'
这类写法很容易让构建和运行时都变重。更好的方式是按需:
1import { SearchIcon } from '@icon-pack/icons/SearchIcon'
重型组件要懒加载:
1const ChartPanel = lazy(() => import('./ChartPanel'))
构建速度和运行性能经常是同一件事。依赖拆得清楚,两个都会受益。
按需导入这件事,现在很多库自己就把路铺好了,前提是你得用对入口。像图标库、工具库通常同时提供了 esm 的具名导出和 cjs 的整包导出,从支持 tree-shaking 的 ESM 入口具名导入(import { x } from 'lib'),打包器就能只带走 x;但如果这个库或者你的构建配置退回了 CJS,或者库标了 sideEffects 让打包器不敢摇,按需导入的好处就没了。所以"我明明写的是按需导入,产物怎么还是很大"这种情况,得回去看这个库到底走没走 ESM、package.json 里的 sideEffects 字段是怎么标的——问题常常不在你的 import 写法,而在库的打包形态。这也是分析产物图的价值:它能告诉你某个你以为按需了的库,实际到底带进来多少。
“多个版本的同一依赖并存”这一条值得展开讲。Monorepo 或者依赖层级深的项目里,很容易出现同一个包被安装了两三个不同版本——A 包依赖 [email protected],B 包依赖 [email protected],包管理器为了满足各自的版本范围,会在 node_modules 里塞进两份。构建工具要分别解析、转换这两份内容,产物体积也会翻倍。排查这个问题可以用包管理器自带的重复依赖分析:
1npm ls lodash 2pnpm why lodash
多版本并存除了拖慢构建、撑大产物,还有个更阴的后果是运行时 bug。有些库在模块作用域里存全局状态——React 的 hooks 分发、某些库的单例注册、样式方案的插入去重——如果依赖树里同时存在两份,两份各自持有一套状态,就会出现"明明装了却行为诡异"的问题,最经典的就是 React 报"Invalid hook call",十有八九是 react 被装了两份。所以去重不只是构建优化,有时候是修 bug。
如果确认多个版本没有实质差异,可以在包管理器层面强制去重。pnpm 和 yarn 都支持 overrides/resolutions 字段锁定统一版本:
1{ 2 "pnpm": { 3 "overrides": { 4 "lodash": "4.17.21" 5 } 6 } 7}
但这类强制覆盖要谨慎,如果某个依赖明确要求某个次版本的特性,强行统一可能引入运行时问题,改完至少要跑一遍相关模块的测试。
TypeScript 要有项目边界
TypeScript 项目大了以后,类型检查会变慢。
常见问题是所有代码都在一个 tsconfig 里,任何改动都可能牵动全量检查。Monorepo 里更明显。
可以考虑使用 project references,把项目拆成一个个独立的包:
1{ 2 "references": [ 3 { "path": "./packages/ui" }, 4 { "path": "./packages/shared" }, 5 { "path": "./apps/admin" } 6 ] 7}
这样 TypeScript 可以复用构建信息,包之间通过声明文件通信。project references 的关键收益是增量:改了 apps/admin,只有它和依赖它的包需要重新检查,packages/ui、packages/shared 如果没动,直接复用上次的 .tsbuildinfo,不用重跑。配合 tsc --build(注意是 --build 而不是普通的 tsc)才能吃到这个增量能力,光拆 references 不走 --build 是不生效的,这一步很容易漏。
还有一个现实建议:开发服务器的转译和类型检查可以分开。很多工具在 dev 阶段用 esbuild 或 swc 快速转译,类型检查放到单独进程或 CI。这里要理解 esbuild/swc 为什么快——它们只做转译(把 TS 语法擦成 JS),根本不做类型检查,所以它们看到 const x: number = 'foo' 也不会报错,照样转译通过。这不是 bug,是分工:把"能不能跑"和"类型对不对"拆成两件事,dev 阶段只要"能跑",类型对不对交给旁边的 tsc --watch 或者编辑器实时报,两条线并行,热更新就不会被类型检查卡住。
但不要因此取消类型检查。只是把它从“每次保存都阻塞页面刷新”改成“后台持续检查或提交前检查”。实践里我会在本地开一个 tsc --watch --noEmit 常驻,编辑器里也开着类型提示,提交前的 git hook 再跑一次全量,CI 里做最终把关。这样类型错误在编辑器里就能实时看到,既没阻塞热更新,也不会漏到线上——把类型检查从"构建的一部分"挪成"和构建并行的一条独立线",是这一年 dev 体验能明显变好的一个点。
热更新慢,多半是模块粒度没分好
开发时最影响手感的其实不是冷启动,而是热更新——改一行代码要等两三秒才在浏览器里看到效果,一天下来累积的等待很可观。基于原生 ESM 的 dev server(Vite 这类)之所以启动快,是因为它不预先打包,浏览器请求哪个模块才现编译哪个,改动只影响相关的少数模块。但这套机制有个前提:模块得拆得干净。如果一个 utils/index.ts 里堆了几十个不相干的工具函数,被大半个项目 import,那改动它就会触发一大片模块失效重编,热更新自然慢。
常见的几个热更新杀手:巨大的 barrel 文件(一个 index.ts re-export 一整个目录)、循环依赖导致 HMR 判断不出该只替换哪块、只能整页刷新,以及在模块顶层做了重计算的文件。barrel 文件尤其隐蔽——它让 import 写起来干净,代价却是把本该独立的模块粘成了一大块,改一个动一片。定位这类问题可以看 dev server 的日志,热更新时它通常会打印“重新编译了哪些模块”,如果改一个小文件却牵连一长串,就该去查是不是被某个大 barrel 或循环依赖串起来了。
barrel 文件的坏处不止拖慢热更新,它还会拖累生产构建的 tree-shaking。从一个 re-export 了整个目录的 index.ts 里只 import 一个函数,理论上打包器应该能摇掉其余的,但一旦这个 barrel 或它 re-export 的某个模块带了副作用、或者打包器保守起见不敢摇,就会把一整个目录的代码都拽进产物。所以 barrel 是那种"开发慢、产物也可能变大"的两头不讨好,能少建就少建,真要建也尽量让它只 re-export、不夹带任何顶层副作用。
HMR 整页刷新最恼人,因为它把"改一行看效果"退化成了"改一行等整页重载加状态丢失"。除了循环依赖,还有一类触发点是改到了 HMR 认不出该怎么局部替换的模块——比如一个被到处 import 的纯工具模块,框架的 HMR 插件不知道该怎么局部替换它,只能保守地整页刷新。React 的 Fast Refresh、Vue 的 HMR 都对"组件文件"有特殊处理能做到局部热替换,但如果你把组件和一堆非组件的导出混在同一个文件里,Fast Refresh 可能判定这个文件不安全热替换、退回整页刷新。让组件文件保持"只导出组件"这一条纪律,往往就能把一批莫名其妙的整页刷新消掉。
另一个容易忽略的点是 dev 阶段的 optimizeDeps(Vite 的依赖预构建)。第一次启动或新增依赖时,Vite 会用 esbuild 把 CommonJS 依赖预打包成 ESM,这一步慢是正常的,但如果每次启动都重跑,往往是缓存目录(node_modules/.vite)没被保留,或者某个依赖的版本/配置一直在变导致预构建缓存反复失效。把这个缓存目录纳入 CI 缓存、稳住依赖版本,就能避免每次开发都白等一遍预构建。
预构建存在的理由值得说清楚,不然容易误以为它是纯粹的负担。基于原生 ESM 的 dev server 靠浏览器按需请求模块,但很多依赖是 CommonJS 写的、或者一个包内部拆成了几百个小模块——如果不预构建,浏览器会为这几百个小模块各发一个请求,网络瀑布长得吓人,反而比预打包还慢。预构建就是用 esbuild 把这些依赖先合成少数几个 ESM 文件,把"几百个请求"压成"几个请求"。所以它慢的那一下是在替后续省事,问题只在于别让它反复重跑。
预构建缓存反复失效有几个常见诱因值得留意。一是依赖版本在 lockfile 里不稳定,每次装出来的版本略有不同,Vite 算出的缓存指纹就变了;二是 optimizeDeps.include/exclude 配置动来动去,或者用了动态 import 让 Vite 在运行中发现新依赖、触发二次预构建(表现就是开发到一半页面突然刷新一下)。对付第二种,可以把那些确定会用到、但 Vite 静态扫不出来的依赖显式写进 optimizeDeps.include,让它一次预构建到位,别等运行时才发现:
1// vite.config.ts 2export default defineConfig({ 3 optimizeDeps: { 4 include: ['lodash-es', 'some-cjs-only-dep'], 5 }, 6})
这条对那种"首屏还挺快、开发几分钟后莫名其妙全页刷新一次"的抖动特别有效——那多半就是运行中新发现依赖触发的二次预构建。
缓存要真的命中
CI 里最常见的浪费,是每次都重新安装、重新构建、重新生成——明明上一次的产物大部分都能复用,却因为没缓存或缓存没命中,每次都从零来一遍。值得缓存的东西不止 node_modules:包管理器自己的下载缓存、pnpm 的全局 store、构建工具的持久化缓存、TypeScript 的 .tsbuildinfo 增量信息,以及 Next.js / Vite / Babel 各自的缓存目录,都是能省下大块时间的地方。
但缓存不是在 CI 配置里写一行 cache 就万事大吉,关键是命中率和 key 怎么设计。
如果 cache key 每次都变,等于没缓存。如果 key 太宽,可能用到错误缓存。常见做法是用 lockfile 作为依赖缓存 key:
1node-cache-${hashFiles('package-lock.json')}
构建缓存还要注意环境变量。不同环境变量可能影响产物——比如 NODE_ENV、各种 VITE_ 前缀的构建时变量会被内联进产物,同一份源码在不同变量下打出来是不一样的东西。如果缓存 key 里没把这些相关的环境变量算进去,就可能发生"拿测试环境的缓存产物发到了生产"这类很隐蔽的错,表现是接口地址、开关配置莫名其妙串了环境。所以构建缓存的 key 除了源码和 lockfile,还得带上会影响产物的关键环境变量的指纹,宁可缓存粒度细一点、命中率低一点,也别让缓存跨环境串味。
判断缓存到底有没有起作用,不能只看 CI 配置里写没写 cache 字段,要看实际命中率。多数 CI 平台会在日志里打印 cache 是 hit 还是 miss,这行日志比配置文件本身更值得盯着看。命中率长期偏低通常有三种原因:key 里混入了每次都变的内容(比如把完整 commit hash 当 key);缓存范围划得太粗,一个子包改动让整个 monorepo 的缓存失效;或者 CI 环境和本地环境的锁文件版本不一致,导致 key 计算出来的哈希对不上。
pnpm 的 store 缓存值得单独说一句。它和 npm/yarn 的 node_modules 缓存不是一回事——pnpm store 是全局的内容寻址存储,node_modules 里的文件是硬链接过去的。如果 CI 只缓存了项目内的 node_modules,没缓存 pnpm store,那这份缓存的意义有限,因为下次拉取新依赖时 store 里还是没有,照样要走网络下载;反过来只缓存 store、不缓存 node_modules,装依赖这一步会稍慢(要重新建硬链接),但灵活性更好、缓存体积也更小。多数 CI 平台的官方 pnpm 缓存方案会缓存 store 目录本身,这个选择不是随意的。
缓存体积本身也是个容易被忽略的成本项。缓存越大,CI 每次上传下载缓存的时间就越长,大到一定程度,"拉缓存"这一步的耗时甚至能超过它省下的时间,得不偿失。所以 store 缓存这种"只存内容、体积小"的方案,除了灵活,在缓存传输开销上也占便宜。判断缓存策略好不好,除了看命中率,还得看缓存本身多大、传输花多久——这几个数字要一起看,只盯命中率可能掉进"缓存是命中了,但为了这份命中先花了半分钟拉一个巨大缓存"的陷阱。
代码生成不要每次全量跑
很多项目有接口类型生成、路由生成、图标生成、国际化生成。
如果每次 dev 启动都把这些全量跑一遍,启动就会白白慢一截。更合理的方式是让生成变成增量的:输入文件没变就直接跳过、把生成结果提交进仓库或当成缓存复用、watch 模式只处理变化的那几个文件、CI 里则加一步校验生成结果是不是最新的(防止有人改了契约却忘了重新生成)。
接口类型尤其值得这么处理——它应该在契约变化时才生成,而不是每次 dev 启动都去拉一遍远程 Swagger。把生成绑死在启动流程里有个隐蔽的代价:远程服务一慢或者一抖,本地开发就跟着卡在那里起不来,明明是网络问题却表现成“构建慢”。把这类外部依赖从启动关键路径上摘下来,是提升开发体验里性价比很高的一步。
具体做法是把生成结果当成源码提交进仓库,日常 dev 直接读提交好的产物、不联网;只在需要更新契约时手动跑一次生成脚本。这样谁都能离线开发,契约什么时候更新也变成一个显式的、有 diff 可审的动作,而不是每次启动隐式拉一遍、还拉的是哪一版都说不清。CI 里再加一步校验:重新生成一次,比对和仓库里提交的是否一致,不一致就说明有人改了契约却忘了重新生成、或者生成产物被手改过,直接卡住。这道校验把"生成产物和契约脱节"这类最难查的漂移挡在了合并之前。
判断一个生成任务该不该做增量,标准是看它的输入变不变。图标生成、路由生成这类输入是本地文件的,天然适合用 watch 只处理变化的文件、或者用输入哈希做缓存跳过;国际化文案生成如果输入是远程翻译平台,那就和接口类型一样,别绑进启动、改成按需拉。把这些生成任务从"每次启动全量跑"改成"输入没变就跳过",启动那几秒钟的白等就省下来了,积少成多对一天几十次重启的开发体验影响不小。
sourcemap 和压缩很贵
生产构建里,压缩和 sourcemap 往往占不少时间。
开发环境没必要开完整 sourcemap,选 cheap / eval 这类重速度的就够了;测试环境能调试即可;生产环境则要按监控平台的需要生成,且注意上传之后不要把 .map 公开到 CDN 上——那等于把源码送出去。这三档不是随便定的,它们对应的是三种完全不同的诉求:开发重迭代速度、测试重可调试、生产重排查精度和安全,混为一谈就会要么慢、要么泄露源码。
生产 sourcemap 的正确姿势是"生成、上传给监控平台、然后从公开产物里删掉"。Sentry 这类错误监控平台需要 sourcemap 才能把线上的压缩堆栈还原成源码位置,所以你得生成;但生成完应该在 CI 里把 .map 单独上传给监控平台,再确保部署到 CDN 的目录里不包含这些 .map 文件——两件事都要做。只上传不删,源码就挂在 CDN 上任人下载;只删不上传,线上报错的堆栈永远是一堆压缩后的乱码,排查无从下手。很多团队踩过的坑是产物构建脚本里 .map 和 .js 一起发出去了,得专门在部署步骤里加一道过滤。
压缩器也会影响速度。Terser 压缩效果稳但慢,esbuild 压缩快但在某些边角上压缩效果和 Terser 有差异(比如对某些副作用的保守处理)。选哪个要看项目要求,不要盲目追求最小体积——为了产物小几 KB 把构建拖慢一倍,多数时候是不划算的取舍。这一年 swc、esbuild 这类基于原生语言的工具链已经很成熟,在能接受压缩差异的项目里,换用它们做压缩往往是构建提速里最直接的一步。
换压缩器前值得先量一下这个取舍到底值不值。做法很简单:同一份代码分别用 Terser 和 esbuild 压一遍,比对产物体积差多少、构建时间差多少。我实测过几个中等规模项目,esbuild 压缩比 Terser 快好几倍,产物大不了百分之几——对绝大多数业务应用,这个体积差完全不值得多花那几倍构建时间。只有那种对首屏字节数极度敏感、或者要压到极致的库作者,才需要为 Terser 那点额外压缩率买单。把这个取舍摆成两个具体数字,比"听说 Terser 压得更小"这种模糊印象好决策得多。
Vite 5 默认用的是 esbuild 压缩,这也是它开箱构建就快的原因之一;如果你在 Vite 项目里把 build.minify 特意换回了 'terser',那多半是有具体理由(某个 esbuild 压缩后行为异常的边角 case),否则默认的 esbuild 就是速度和体积平衡得最好的选择,不用手动折腾。
sourcemap 的类型选择直接影响生成速度,不同类型之间差距不小。eval 系列生成最快,但只能定位到文件不能精确到行列,调试体验差;cheap-module-source-map 这类省略了列信息,生成速度和可用性之间比较平衡;完整的 source-map 精度最高但生成最慢,通常只用在需要精确堆栈的生产排查场景。开发环境选前两种、生产环境按需选完整版,这个分层本身就能省下不少构建时间,不需要额外配置技巧。
还有一个容易被忽略的点:sourcemap 生成和压缩器的选择会互相影响构建的并行度。如果构建工具单进程跑压缩加 sourcemap 映射,大型项目这一步可能占到总构建时间的三分之一以上。多数现代压缩器(esbuild、swc 内置的压缩)默认支持多核并行,Terser 需要显式配置 parallel: true 才能利用多核。检查这个开关有没有打开,往往比纠结用哪个压缩器更立竿见影。
分块策略和 CI 并行:让慢的部分同时进行
生产打包慢,除了压缩本身,还有一个常被忽略的因素是分块(chunk splitting)策略。分块太碎,浏览器要发一堆小请求、构建时也要处理更多产物文件;分块太粗,一个改动就让一大块缓存失效、用户每次都得重下大文件。合理的做法是把稳定的第三方依赖单独拆成一个长期缓存的 vendor chunk,业务代码按路由自然分块。这件事对构建速度和运行时加载是同一个方向上的收益——产物结构清楚了,增量构建能复用的部分才多。
分块和长期缓存的联动值得展开。第三方依赖变动频率低,把它们单独拆成 vendor chunk、配上内容哈希文件名,用户装过一次之后,只要依赖没升级,这个 chunk 的哈希就不变,浏览器长期缓存命中,发版只需要重下变动的业务 chunk。这里有个经典坑:把所有 node_modules 一股脑塞进一个巨型 vendor chunk,看似干净,实际是任何一个小依赖升级都会让整个 vendor 哈希变、用户重下几百 KB。更细的做法是按稳定性分层,把真正稳定的核心库(框架本身)和相对易变的业务依赖分开:
1// Rollup/Vite 手动分块 2manualChunks(id) { 3 if (id.includes('node_modules')) { 4 if (/react|react-dom|scheduler/.test(id)) return 'framework' 5 return 'vendor' 6 } 7}
但手动分块也别过度,分得太细会让 chunk 之间的依赖关系变复杂,反而增加请求数和构建时处理成本。多数项目其实用打包器的默认分块策略就够好,只在确实观察到"某个大依赖频繁拖累缓存"时才手动干预。这又回到那句话:先量、再动,别一上来就手搓一套 manualChunks。
到了 CI 层面,很多时候单纯优化单步已经到头了,真正的提速来自把互不依赖的步骤并行起来。类型检查、单元测试、lint、构建,这四件事之间大多没有先后依赖,串行跑是四者之和,拆成并行的 job 同时跑,总时长就压到最慢那个。CI 平台基本都支持 job 级并行和 matrix,把测试按目录分片跑也是常见手段。这一步的性价比往往被低估——不用改一行业务代码,只是把已有的工作重新编排,墙上时间就能砍掉一半以上。
需要提醒的是并行不是越多越好:并行 job 太多会争抢缓存、争抢下载带宽,反而互相拖慢,还会占用更多 CI 额度。合理的并行度得结合项目实际的步骤耗时去分,把最慢的两三步拆开并行,通常就已经吃到大部分收益了。
并行还有个隐藏成本是"每个 job 都要重新装一遍依赖"。把 lint、test、build 拆成三个独立 job,它们各自都要 checkout、装依赖、拉缓存,如果依赖装得慢,这份开销乘以三,并行省下的时间可能又被装依赖吃回去。所以拆并行的前提是依赖缓存真的命中——缓存命中的情况下装依赖只要几秒,并行才划算;缓存老是 miss 的话,拆得越并行、重复的装依赖开销越大。这也是为什么前面缓存那一节要先做,它是并行能不能吃到收益的地基。有些 CI 平台支持在多个 job 之间共享一份构建产物或 node_modules(artifact 传递),能缓解这个重复开销,但配置起来又多一层复杂度,得看项目规模值不值。
monorepo 里还有一层更聪明的并行是按"受影响的包"来跑。Turborepo、Nx 这类工具能算出这次改动影响了哪些包,只对受影响的包跑构建和测试,没被碰到的包直接复用上次结果。相比"每次全量并行跑所有包",这是从另一个方向省——不是把活并行得更快,而是干脆不干没必要的活。这套增量编排在包多、彼此依赖清晰的 monorepo 里收益很大,但它要求包和包之间的依赖关系划得干净,一旦划分乱了,"受影响分析"就会保守地把一大片都标成受影响,增量的好处就打折了。
别凭感觉猜瓶颈,让工具把时间摊开给你看
前面所有优化都建立在“知道时间花在哪”这个前提上,而这件事不该靠猜。现在的构建工具基本都带了自省能力,用它们把耗时摊开,比凭经验拍脑袋准得多。webpack 侧可以用 webpack --profile --json > stats.json 导出构建统计,再丢进 webpack-bundle-analyzer 之类的可视化工具,哪个模块占了多大体积、哪条依赖链最深,一眼能看出来;Vite(这一年基本已是新项目的默认,5.x 是主力)也有 rollup-plugin-visualizer 做产物分析,vite build --profile 还能生成 CPU profile 定位到具体是哪一步慢。
分析产物时有个常见的“惊吓”:某个不起眼的功能引入了一个特别重的依赖。之前我们排查一次打包变慢,分析图上赫然一大块,点开是某个日期处理引入了 moment,连带把所有语言包一起打了进来——一个格式化需求,换成体积小得多的 date-fns 之后产物直接瘦了一圈。这类问题不看分析图根本发现不了,因为它藏在某个三层深的间接依赖里,代码里搜不到。
看分析图时有个门道:别只盯着最大的那块,也要看"意料之外"的块。最大的块往往是框架本身、意料之中,真正值得处理的是那些"这功能怎么会这么大"的块——一个简单的富文本展示带进来一整个编辑器、一个图表 tooltip 拖来了整个图表库的全部图表类型。这些才是能砍的地方。分析图的价值不在于告诉你产物由什么组成,而在于让"名不副实"的依赖现形,把"我以为很小"和"实际很大"对上账。
类型检查慢也一样有专门的自省手段。TypeScript 的 tsc --generateTrace ./trace 能导出一份可以在浏览器里打开的性能追踪,直接告诉你是哪个文件、哪个类型的检查最耗时。大型项目里类型检查失控,十有八九是某几个复杂的泛型或者巨大的联合类型在拖后腿,靠这个 trace 能精确定位,而不是笼统地“感觉 TS 变慢了”。
tsc --diagnostics(或者 --extendedDiagnostics)是更轻量的一步,它不生成 trace,直接在命令行打出一份汇总:检查了多少文件、多少类型、各阶段花了多少时间、内存用了多少。日常想快速判断"这次慢是文件变多了还是某个类型变复杂了",先看这份汇总,Instantiations 和 Types 这两个数字异常大通常就是复杂泛型在爆炸,再上 --generateTrace 深挖。这一年也有社区工具能把这份 trace 分析得更友好,直接标出最贵的那几个类型实例化点,比人肉翻 trace 省事。
要提醒的是这些 trace/diagnostics 是给"类型检查真的成了瓶颈"时用的,不是每次都得跑。多数项目类型检查慢的根因还是没有按项目拆分、全量检查,先把 project references 和增量做好,再谈用 trace 去抠某个复杂类型——顺序反了就是拿放大镜找一个其实靠拆分粒度就能解决的问题。
换工具之前,先把代价算清楚
把上面的路都走完,如果确实是工具本身到了瓶颈,再谈迁移也不迟。2024 年这个选择比前几年热闹——Vite 5 已经稳定成主力,Next 15 RC 里的 Turbopack dev 也进入了可以认真评估的阶段,社区还在盯着更下一步的 Rolldown。诱惑很大,但迁移工具从来不是免费的:插件生态要重新对齐(有些 webpack loader 在新工具里没有对等实现)、构建产物的细微差异要重新验证、团队的调试习惯和 CI 脚本都得跟着改。
我的经验是,工具迁移带来的收益,如果只是把一个本可以通过"削依赖、拆分粒度、命中缓存"解决的问题延后了,那这笔迁移成本大概率不划算——慢的根因还在,换个工具只是把它推到下一个版本再爆发。真正值得迁移的信号,是你已经把依赖和缓存都收拾干净了,瓶颈仍然清晰地落在工具的架构上(比如 webpack 的全量 bundle 模型在超大项目上的冷启动,确实是 Vite 这类基于原生 ESM 的工具能结构性改善的)。带着这个前提去迁移,才是拿工具的长处去补自己的短板,而不是拿迁移当逃避诊断的借口。
迁移代价里最容易被低估的是 dev 和 prod 用不同引擎带来的行为差异。Vite 的 dev 走原生 ESM + esbuild,prod 走 Rollup,两套管线在边角上并不完全一致——某个模块在 dev 下跑得好好的,prod 打出来可能因为 tree-shaking 或者 CommonJS/ESM 互操作的细微差别而出问题。这不是 Vite 的缺陷,是"两套引擎"这个架构自带的成本,迁移后一定要在 prod 产物上完整回归一遍,不能光看 dev 顺不顺。同理从 webpack 迁走,那些依赖 webpack 特有能力(require.context、特定的 magic comment、某些 loader 链)的代码都得找对等实现,没有对等的就得改写,这部分工作量往往在评估时被漏算。
Turbopack 和 Rolldown 这两个更下一代的东西,2024 年我基本是当"关注但不押注"的态度。Turbopack 在 Next 15 里 dev 模式已经能认真评估了,但生产构建还没完全稳定;Rolldown 想做 Rollup 的 Rust 重写、给 Vite 当统一的底层引擎,方向很诱人,但年内还在早期,我只在个人项目里跑过 demo,不敢往生产上放。构建工具这块这两年变化太快,追新的诱惑很大,但我给自己定的线是:生产链路只用已经被大量项目验证过的(现在就是 Vite 5 这一档),新东西在内部工具或个人项目里试水,等它熬过早期再谈迁生产。慢一步,比追新踩坑再回滚划算。
我的排查顺序
构建慢时,我会按这个顺序查:
- 量化安装、启动、热更新、类型检查、生产构建分别耗时
- 分析依赖体积和重复依赖
- 懒加载首屏不需要的重型模块
- 检查 TypeScript 项目怎么拆分、类型检查方式对不对
- 配 CI 和本地缓存,并确认命中率
- 优化代码生成流程
- 调整 sourcemap 和压缩策略
- 最后再考虑工具迁移
这个顺序不是死规矩,但它背后的逻辑是稳的:先量化再动手,从"改了立竿见影、成本又低"的地方(削依赖、命中缓存)往"收益大但代价也大"的地方(换工具)走。多数项目在走到第五步之前,构建时间就已经好转了一大截,根本用不着迁移。真正走到最后一步的,往往是那些前面几步都做扎实、瓶颈确实卡在工具架构上的项目——而这样的项目,反而是最清楚自己为什么要迁、迁完能省在哪的。
工具升级是手段,不是诊断。构建速度真正变快,通常来自依赖变少、模块划得清楚、缓存能命中、无效工作被跳过。开头那个"本地冷启动几十秒、大家都说 Webpack 不行"的项目,最后就是靠拆首页那几个全量 import、把重型页面组件懒加载解决的,工具一行没换。这也是我每次遇到"构建慢"先做拆分、后谈换工具的原因。