楼层: 首页/ 软件技术/ 前端基础/ 第 10 章 构建工具与 Monorepo:Vite / Webpack / Rollup / Turborepo
仓

第 10 章 构建工具与 Monorepo:Vite / Webpack / Rollup / Turborepo

Bundlers, Tree-shaking, Monorepo & Dependency Caching

第 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 缓存问题,这么解

# 依赖预构建缓存和实际 node_modules 不一致(改了依赖版本、或装了新包) rm -rf node_modules/.vite && pnpm dev # 排查"依赖解析到两份副本"(典型症状:Context 失效、instanceof 为 false) pnpm why react # 看有几个版本被装进来 pnpm dedupe # 尝试合并到同一版本 # 或在 vite.config.ts 里强制只留一份: resolve: { dedupe: ["react", "react-dom"] } # 某个 CJS 库 import 报错(默认导入拿不到) optimizeDeps: { include: ["lodash-es", "some-cjs-lib"] }
构建工具的三个高频误区

误区一:用 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 却包还是那么大"。

Tree-shaking 的三个前提
前提说明与常见失效原因
① 用 ESM只有 import/export 是静态可分析的。require() 可以写在 if 里、可以拼字符串,打包器只能整个保留。所以依赖 CJS 版本的库(如 lodash 而非 lodash-es)基本摇不动。
② 代码无副作用打包器不敢随便删"可能有副作用"的模块(怕删掉了 polyfill、样式注入、全局注册)。在自家包的 package.json 里声明 "sideEffects": false(或只列出有副作用的文件),能大幅提升摇树效果。
③ 压缩器懂得标记真正"删"是压缩阶段(terser/esbuild minify)做的。调用第三方函数时加 /*#__PURE__*/ 注释,等于告诉压缩器"这个调用没副作用,返回值没人用就可以删"。

两个真实影响包体积的写法差异

// ❌ 摇不动:lodash 是 CJS,整个库都会进包(几百 KB) import _ from "lodash"; _.debounce(fn, 300); // ✅ 能摇:ESM 版本,只留下 debounce 一个函数 import debounce from "lodash-es/debounce"; // 直接指到具体文件,最稳 import { debounce } from "lodash-es"; // 也行,前提是库本身 sideEffects 干净 debounce(fn, 300); // ⚠️ barrel file(大 index.ts 转出一切)会拖慢 dev、还会削弱摇树: // import { Button } from "@/components"; // 这个 index 里 re-export 了 200 个组件 // → 优先直连:import { Button } from "@/components/Button"; // 给压缩器的提示:这个调用没有副作用 export const config = /*#__PURE__*/ createConfig({ ... });
// package.json:库作者必写的一行 { "name": "@acme/ui", "type": "module", "files": ["dist"], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": ["**/*.css"], // CSS 导入有副作用必须保留,其余都可摇 "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }

代码分割与产物分析:别靠猜

装个可视化插件,看清每一块 chunk 是谁贡献的

pnpm add -D rollup-plugin-visualizer # 构建后自动打开 treemap,按面积看谁最占地方
// vite.config.ts import { visualizer } from "rollup-plugin-visualizer"; export default defineConfig({ plugins: [react(), visualizer({ filename: "stats.html", gzipSize: true, open: true })], build: { rollupOptions: { output: { manualChunks: { // 只拆"大 + 更新少"的:浏览器能长期缓存,业务代码改了它也不用重下 react: ["react", "react-dom"], charts: ["echarts"], vendor: ["lodash-es", "dayjs", "zod"], }, }, }, }, });
  • 看 gzip / brotli 后的体积,不是原始体积:几 MB 的源码压缩后可能只剩 200KB,别被吓到,也别被"压缩后看着还行"麻痹。
  • 给主包设预算:例如"首屏 JS(gzip)不超过 200KB",用 size-limit 或 bundlesize 挂进 CI,超了直接让流水线红掉。有预算才有约束,不然包体积只会一直涨。
  • 依赖要定期体检:同一个功能装了三份日期库(dayjs + moment + date-fns)是常见浪费;pnpm why <包名> 能揪出"谁把它带进来的"。

Monorepo:一个仓库装多个包

当项目长成"一个组件库 + 两个后台 + 一个小程序",你面临一个选择:拆成多个仓库(multirepo)还是一个仓库多个包(monorepo)。Monorepo 的价值在改一处、所有用它的地方立刻生效,且版本永远同步;代价是工程复杂度上升——构建、测试、发布都得有工具管,不然"跑一次 CI 等 20 分钟"会把人逼疯。

Monorepo 两大工具:Turborepo 与 Nx
对比项TurborepoNx
定位轻量任务编排器(task runner)。只干"按依赖顺序跑任务 + 缓存"。一体化开发平台:任务编排 + 代码生成 + 依赖图分析 + 模块边界约束。
配置量很小,一个 turbo.json 基本够用,一天上手。较重,插件和生成器多,学习曲线陡。
缓存本地缓存 + 远程缓存(Vercel Remote Cache,也可自托管)。本地 + Nx Cloud(含分布式任务执行),能力更强。
适合谁中小团队,只想解决"CI 太慢 + 依赖顺序乱"。大团队/大仓库,需要强规范、强边界、代码生成统一风格。

pnpm workspace:先声明"哪些目录是包"

# pnpm-workspace.yaml packages: - "apps/*" # 应用:web 后台、小程序、管理端 - "packages/*" # 库:ui 组件库、shared 工具、config 配置 - "!**/test/**" # 包之间互相引用直接写 workspace 协议,pnpm 会用软链,不会去 registry 下载 # packages/web/package.json: # "dependencies": { "@acme/ui": "workspace:*" }

turbo.json:声明任务依赖、输入输出,缓存的正确姿势

{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { // 先跑依赖包的 build(ui 要先产出 dist,web 才能 import) "dependsOn": ["^build"], // 只有这些目录变了,这个任务才需要重跑 —— 决定缓存命中率的关键 "inputs": ["src/**", "package.json", "tsconfig.json"], // 这些产物会被缓存,命中缓存时直接还原、跳过执行 "outputs": ["dist/**", ".next/**"] }, "test": { "dependsOn": ["build"], "outputs": ["coverage/**"] }, "lint": { "outputs": [] }, "dev": { "cache": false, "persistent": true } } }

日常最常用的三个 turbo 命令

# 全仓库跑 build(自动按依赖拓扑排序,并复用缓存) pnpm turbo build # 只跑"受改动影响"的包 —— monorepo 提效的核心一招 pnpm turbo build --filter=...[origin/main] # 只跑某个包及其依赖 pnpm turbo test --filter=@acme/ui... # 看看这次任务为什么没命中缓存(排查缓存失效的必备命令) pnpm turbo build --dry=json

依赖缓存: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 服务与前端都适用)

FROM node:22-alpine WORKDIR /app # ① 先只复制"描述依赖"的文件 —— 这层缓存只要依赖没变就一直命中 COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile # ② 再复制源码 —— 源码变了只影响下面这层,不动依赖层 COPY . . RUN pnpm build CMD ["node", "dist/server.js"]
记
本章小结

① 认准三个角色:转译器(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 分层,一个都别漏。

本章面试题(工程化 / Monorepo)

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,任何一次代码改动都会导致重新安装全部依赖。