第 10 章 构建工具与 Monorepo:Vite / Webpack / Rollup / Turborepo
第 6 章讲了"用什么工具跑项目",这一章讲工具之间的分工和取舍,以及仓库规模变大之后怎么办。你会在技术评审里听到这样的话:"这个库用 Rollup 打"、"首屏包太大,拆一下 chunk"、"我们上 Turborepo 吧,CI 要 20 分钟太慢了"——这一章就是让你听得懂、接得住。后端同学可以把构建工具类比成编译器 + 打包器,把 Monorepo 类比成 Maven 多模块。
论一个现代前端项目,其实有三个不同的"工具角色"
① 转译器(transpiler):把 TS / JSX / 新语法变成浏览器认识的 JS。esbuild、SWC、Babel 干这个。特点是"逐文件、不做跨文件分析",所以极快——esbuild 慢一点就慢在它是 Go 写的(比 JS 快 10~100 倍)。
② 打包器(bundler):从入口开始顺着 import 把几百个文件合成少数几个产物,顺便做 tree-shaking、代码分割、压缩。Rollup、Webpack、esbuild、Rspack 干这个。
③ 开发服务器(dev server):提供热更新(HMR)、代理、按需编译。Vite 在这一层是明显优势项。
所以"Vite vs Webpack"这个问法本身不太准确:Vite 是"开发服务器 + 生产打包器(Rollup/Rolldown)"的组合体,它自己不重造一个打包器,而是站在 esbuild 和 Rollup 肩膀上。
Vite / Webpack / Rollup / esbuild 怎么选
| 工具 | 底层语言 | 强项 | 什么时候用 / 什么时候别用 |
|---|---|---|---|
| Vite 6 / 7 | JS + esbuild + Rollup | 开发启动快、HMR 精准、配置少、生态成熟(框架模板齐全)。 | 新项目默认选它。别用在"需要大量自定义 webpack loader 才能跑起来"的老遗留项目上。 |
| Webpack 5 | JS | 生态最全(loader/plugin 几乎什么都有)、Module Federation(微前端)、细粒度控制。 | 老项目维护、需要微前端联邦、有硬性 loader 依赖。新项目别主动选它,配置成本高、构建慢。 |
| Rollup | JS | 产物干净:tree-shaking 最彻底、没有 runtime 包装、支持多种输出格式(ESM/CJS/UMD)。 | 打库(组件库、工具库)首选。它不是为"大型应用 + 代码分割 + HMR"设计的,别拿它当应用打包器。 |
| esbuild | Go | 快到离谱,几十毫秒起。Vite 依赖预构建、压缩都用它。 | 单文件/小项目直接打、脚本工具、需要极致速度的场景。不支持完整 TS 类型检查(只做类型擦除),类型检查仍要 tsc。 |
| Rspack | Rust | Webpack 兼容 API,但构建速度是它的数倍到十倍级。 | 想从 Webpack 平滑迁移又想要速度的场景,配置基本能复用。 |
| Turbopack | Rust | Vercel 出品,增量计算做得很深,Next.js 里已在用。 | 用 Next.js 时跟着官方走;独立的通用项目还在演进,以官方文档为准。 |
Vite 深水区:为什么快,什么时候不灵
快开发与生产走的是两条完全不同的路
开发时:不打包。浏览器原生支持 ESM,Vite 就当一个"按需转译服务器"——你请求 /src/App.tsx,它现场转译这一个文件返回,浏览器再去请求它 import 的下一层。项目有 5000 个模块也不影响启动,因为启动时一个都没编译。
node_modules 例外:会预构建。第三方库有成千上万个小文件、还常常是 CJS,直接按需发请求会给浏览器造成"请求风暴"。Vite 用 esbuild 提前把它们打包成少数几个文件,缓存在 node_modules/.vite 里,只有依赖变了才重新构建。
生产时:老老实实打包。Rollup 做 tree-shaking、代码分割、压缩、哈希命名。所以"Vite 生产构建慢"是正常的——它优化的是开发体验,生产构建和 Webpack 一个量级(都是 Rollup/JS 系)。想两者都快,答案就是 Rust/Go 重写的打包器(Rolldown / Rspack),这也是 2025 之后的主线。
三个"卡住新手"的 Vite 缓存问题,这么解
误区一:用 esbuild/SWC 换掉 tsc,就等于有类型检查了。它们只做"类型擦除",把 : string 删掉就完事,不会告诉你类型错了。CI 里必须单独跑一次 tsc --noEmit(或 tsc -b),vite build 通过 ≠ 类型没问题。
误区二:dev 能跑就等于 build 能过。两者走的路径不同(dev 不打包、build 要打包),发布前一定要在本地跑一次 pnpm build && pnpm preview。常见的"dev 好好的、上线白屏"多半是环境变量没配、或者用了只在 dev 存在的条件编译分支。
误区三:盲目加 manualChunks 拆包。拆得太碎会产生几十个 HTTP 请求,HTTP/2 下也许还行,但会让缓存命中变得难以预测。原则是先看产物分析报告,只拆"体积大 + 更新频率低"的那几块(如 react、echarts)。
Tree-shaking:删掉没用的代码,它也有前提
Tree-shaking 听起来像"自动优化",其实它依赖三个前提同时成立,缺一个就失效——这也解释了为什么很多人"写了 tree-shaking 却包还是那么大"。
| 前提 | 说明与常见失效原因 |
|---|---|
| ① 用 ESM | 只有 import/export 是静态可分析的。require() 可以写在 if 里、可以拼字符串,打包器只能整个保留。所以依赖 CJS 版本的库(如 lodash 而非 lodash-es)基本摇不动。 |
| ② 代码无副作用 | 打包器不敢随便删"可能有副作用"的模块(怕删掉了 polyfill、样式注入、全局注册)。在自家包的 package.json 里声明 "sideEffects": false(或只列出有副作用的文件),能大幅提升摇树效果。 |
| ③ 压缩器懂得标记 | 真正"删"是压缩阶段(terser/esbuild minify)做的。调用第三方函数时加 /*#__PURE__*/ 注释,等于告诉压缩器"这个调用没副作用,返回值没人用就可以删"。 |
两个真实影响包体积的写法差异
代码分割与产物分析:别靠猜
装个可视化插件,看清每一块 chunk 是谁贡献的
- 看 gzip / brotli 后的体积,不是原始体积:几 MB 的源码压缩后可能只剩 200KB,别被吓到,也别被"压缩后看着还行"麻痹。
- 给主包设预算:例如"首屏 JS(gzip)不超过 200KB",用
size-limit或bundlesize挂进 CI,超了直接让流水线红掉。有预算才有约束,不然包体积只会一直涨。 - 依赖要定期体检:同一个功能装了三份日期库(dayjs + moment + date-fns)是常见浪费;
pnpm why <包名>能揪出"谁把它带进来的"。
Monorepo:一个仓库装多个包
当项目长成"一个组件库 + 两个后台 + 一个小程序",你面临一个选择:拆成多个仓库(multirepo)还是一个仓库多个包(monorepo)。Monorepo 的价值在改一处、所有用它的地方立刻生效,且版本永远同步;代价是工程复杂度上升——构建、测试、发布都得有工具管,不然"跑一次 CI 等 20 分钟"会把人逼疯。
| 对比项 | Turborepo | Nx |
|---|---|---|
| 定位 | 轻量任务编排器(task runner)。只干"按依赖顺序跑任务 + 缓存"。 | 一体化开发平台:任务编排 + 代码生成 + 依赖图分析 + 模块边界约束。 |
| 配置量 | 很小,一个 turbo.json 基本够用,一天上手。 | 较重,插件和生成器多,学习曲线陡。 |
| 缓存 | 本地缓存 + 远程缓存(Vercel Remote Cache,也可自托管)。 | 本地 + Nx Cloud(含分布式任务执行),能力更强。 |
| 适合谁 | 中小团队,只想解决"CI 太慢 + 依赖顺序乱"。 | 大团队/大仓库,需要强规范、强边界、代码生成统一风格。 |
pnpm workspace:先声明"哪些目录是包"
turbo.json:声明任务依赖、输入输出,缓存的正确姿势
日常最常用的三个 turbo 命令
依赖缓存:CI 从 10 分钟降到 1 分钟
"构建慢"90% 不是代码问题,而是每次都从头装依赖、从头编译。缓存做好,提速是数量级的。
| 缓存层 | 怎么做 |
|---|---|
| 依赖缓存 | CI 里用 actions/setup-node 的 cache: "pnpm",并严格用 pnpm install --frozen-lockfile 保证可复现;lockfile 是缓存的 key,依赖没变就直接还原,不再下载。 |
| 任务缓存 | Turborepo inputs / outputs 配好,命中就直接还原 dist,任务根本不执行。远程缓存能让整个团队、整个 CI 共享同一份缓存。 |
| Docker 层次缓存 | 先 COPY package.json pnpm-lock.yaml 再 pnpm install,最后才 COPY 源码。顺序反了每次改代码都会重装依赖。记得配好 .dockerignore(排除 node_modules、dist)。 |
| 打包器缓存 | Webpack 的 cache: { type: "filesystem" }、Vite 的 node_modules/.vite。CI 里给这些目录加速缓存,能省下二次构建的大头。 |
Dockerfile 的正确分层顺序(Node 服务与前端都适用)
① 认准三个角色:转译器(esbuild/SWC/Babel)、打包器(Rollup/Webpack/Rspack)、开发服务器(Vite 强项)。新项目默认 Vite,打库用 Rollup,老项目留 Webpack。
② Tree-shaking 三前提:ESM + 声明 sideEffects + 压缩器能识别副作用;别用大 barrel 文件,别依赖 CJS 版本的库。
③ 拆包看报告再动手,只拆"大 + 更新少"的依赖;给首屏 JS 设体积预算并挂进 CI。
④ Monorepo = pnpm workspace(管包)+ Turborepo/Nx(管任务);缓存是提效核心:lockfile key、turbo inputs/outputs、Docker 分层,一个都别漏。
1. 为什么 Vite 开发快,但生产构建并不比 Webpack 快多少?
看答案
开发时 Vite 不打包,靠浏览器原生 ESM 按需请求,只转译你访问的那个模块,所以启动时间与项目规模无关;生产构建则必须用打包器(Rollup)做完整依赖图分析、tree-shaking、分包和压缩,这是"打包本身"的开销,跟 Webpack 属同一量级。要两端都快,得换 Rust/Go 重写的打包器(Rolldown、Rspack)。
2. 引入了 lodash 只用了 debounce,为什么包还是大了几百 KB?怎么改?
看答案
lodash 主包是 CJS,打包器无法静态分析出你用了哪些导出,只能整个保留。改成 lodash-es(ESM)并按具体路径导入,例如 import debounce from "lodash-es/debounce",tree-shaking 才能只留下这一个函数。
3. 什么是"幽灵依赖",为什么 pnpm 能避免、npm 不能?
看答案
幽灵依赖指你没在 package.json 声明,却能在代码里 import 到的包。npm 把依赖全部提升(flatten)到顶层 node_modules,于是间接依赖也能被解析到。pnpm 只在项目 node_modules 下平铺你直接声明的包(其余放在 .pnpm 并用软链引用),所以没声明就是真找不到,等于强制你诚实声明依赖。
4. Monorepo 里 Turborepo 的缓存什么情况下会失效?怎么排查?
看答案
缓存 key 由任务的 inputs、依赖任务的产物 hash、环境变量、命令等共同决定。常见失效原因:inputs 写得太宽(把整个仓库都算进去)、漏配了 outputs 导致没东西可还原、环境变量变化、或者依赖包源码变动。用 turbo build --dry=json 能看到每个任务的缓存命中判断依据和 hash。
5. Docker 构建前端镜像,为什么要把 COPY package.json pnpm-lock.yaml 单独放一行、放在复制源码之前?
看答案
Docker 按层缓存,某一层的输入变了,它和它之后的所有层都要重建。依赖清单单独一层后,日常改代码只让"复制源码 + build"这两层失效,最耗时的 pnpm install 层会一直命中缓存。如果先 COPY 全部源码再 install,任何一次代码改动都会导致重新安装全部依赖。