21 · 构建工具原理:AST 与打包
本篇解决什么问题: "为什么 Tree Shaking 没生效?""为什么 Vite 比 Webpack 快?""HMR 是怎么做到不改状态的?" 这些问题都在构建原理这一层。本篇讲清 AST → 转换 → 打包 → 优化 的完整链路。
📌 与 A02(前端工程化)的分工:A02 讲选型与工具使用,本篇讲底层原理。 📌 与 A28(工具链)的分工:A28 讲规范与门禁,本篇讲编译与产物。
开篇速览
| 概念 | 一句话 | 关联问题 |
|---|---|---|
| AST | 代码的结构化树表示 | Babel 插件、ESLint 规则都基于它 |
| Tree Shaking | 删掉没被引用的导出 | "为什么我的代码没被摇掉?" |
| Code Splitting | 拆成多个 chunk 按需加载 | 首屏体积优化 |
| HMR | 只替换改动的模块 | 为什么 Vite 的 HMR 特别快 |
| Bundle / Bundleless | 打包 vs 原生 ESM | Vite 快在哪 |
一、构建流程全景
无论 Webpack、Rollup 还是 Vite,核心都是一条流水线:
源码 ──① 解析(Parse)──► AST ──② 变换(Transform)──► 新 AST
──③ 生成(Generate)──► 目标代码 + SourceMap
──④ 模块图(Module Graph)──► 依赖分析
──⑤ 优化(Tree Shaking / 压缩 / 分包)──► 产物| 阶段 | 做什么 | 代表工具 |
|---|---|---|
| Parse | 源码 → AST | acorn、@babel/parser、esbuild |
| Transform | 遍历并改写 AST | Babel plugin、SWC、esbuild |
| Generate | AST → 代码 | @babel/generator、esbuild |
| 模块图 | 从入口递归找依赖 | Webpack / Rollup / Vite |
| 优化 | 摇树、压缩、分包 | Terser / esbuild / Rollup |
二、AST:所有编译工具的地基
AST(抽象语法树) 把代码变成可程序化操作的树。
js
const a = 1对应的 AST(简化):
json
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "a" },
"init": { "type": "NumericLiteral", "value": 1 }
}]
}为什么重要:
| 应用 | 基于 AST 做什么 |
|---|---|
| Babel | 把 ES2020+ 语法转成 ES5(箭头函数 → function) |
| ESLint | 遍历 AST 匹配规则(如"禁止 console") |
| Prettier | 按 AST 重新打印代码(保证格式确定) |
| Tree Shaking | 分析 import/export 的引用关系 |
| 代码重构(Codemod) | 批量改 API(jscodeshift) |
2.1 一个最小 Babel 插件
js
// 把所有 console.log(...) 删掉
export default function ({ types: t }) {
return {
visitor: {
CallExpression(path) {
const callee = path.node.callee
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object, { name: 'console' })
) {
path.remove() // ✅ 直接删掉这个节点
}
},
},
}
}插件三要素:
| 概念 | 作用 |
|---|---|
visitor | 声明"访问哪种节点时执行"(访问者模式) |
path | 节点的上下文(含父节点、作用域、增删改 API) |
types(t) | 判断/构造节点的工具(类型守卫) |
⚠️ 插件顺序很关键:Babel 插件从前往后执行,preset 从后往前。顺序错了结果就错。
2.2 现代趋势:SWC / esbuild 替代 Babel
| 工具 | 语言 | 速度 | 生态 |
|---|---|---|---|
| Babel | JS | 慢 | 最全 |
| SWC | Rust | 快 20~70× | 兼容主流 preset |
| esbuild | Go | 快 10~100× | 偏构建,插件生态较新 |
新项目可以用 SWC/esbuild 提速;但自定义 Babel 插件仍是 JS 生态不可替代的部分。
三、Tree Shaking:为什么"没生效"
3.1 前置条件(缺一不可)
| 条件 | 说明 |
|---|---|
| ① 必须是 ESM | import/export 是静态的,可分析;CJS 的 require 不行(见 A09) |
| ② 不能有副作用,或已声明 | 打包器不敢删可能有副作用的代码,除非 sideEffects: false |
| ③ 生产模式 | 开发模式为了调试通常不做激进优化 |
3.2 最常见的"摇不掉"原因
js
// ❌ 情况 1:整个模块有副作用(顶层执行了代码)
// utils.js
console.log('init') // 顶层副作用 → 整个模块被保留
export function add(a, b) { return a + b }
// ✅ 解法:package.json 声明无副作用
{ "sideEffects": false }
// 或精确指定有副作用的文件
{ "sideEffects": ["*.css", "./src/polyfill.js"] }js
// ❌ 情况 2:导出了一个对象,导致"整体引用"
// index.js
export default { add, minus, multiply } // 用了一个,全部保留
// ✅ 改成具名导出
export { add, minus, multiply }js
// ❌ 情况 3:被 Babel 转成了 CJS
// .babelrc 里 "modules": "commonjs" 会让 ESM 变 CJS,Tree Shaking 失效
// ✅ 设置 "modules": false,把模块交给打包器处理
{ "presets": [["@babel/preset-env", { "modules": false }]] }排查顺序:① 确认是 ESM → ② 检查 sideEffects → ③ 检查 Babel 是否转了 CJS → ④ 用 webpack-bundle-analyzer 定位。
四、Code Splitting:把包拆开
目标:首屏只下载必需的代码。
4.1 三种拆法
js
// ① 动态 import(最常用,按路由拆)
const UserPage = () => import('./pages/User')
// ② 配置 splitChunks(把公共依赖抽出来)
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10 },
common: { minChunks: 2, name: 'common', priority: 5 },
},
},
}
// ③ 手动多入口(多页应用)
entry: { app: './src/app.js', admin: './src/admin.js' }4.2 权衡:不是拆得越碎越好
| 过碎的问题 | 说明 |
|---|---|
| 请求数增加 | HTTP/2 下影响变小,但仍非零成本 |
| 重复依赖 | 需要 splitChunks 把公共部分抽出来 |
| 加载瀑布 | 拆出来的 chunk 可能又触发新的请求 |
🎯 经验:按路由拆是收益最大的一档;再往下拆(组件级)要评估实际收益。
五、HMR:为什么能"保住状态"
HMR(Hot Module Replacement) = 不刷新页面,只替换改动的模块。
5.1 流程
① 文件改动 → watcher 监听
② 构建工具重新编译**这个模块**(Vite 只编译改动的,Webpack 要重建部分依赖图)
③ 通过 WebSocket 把**新模块代码 + 更新边界**推给浏览器
④ 浏览器执行新模块,调用该模块的 HMR 回调
⑤ 若没有 accept 回调 → 向上冒泡 → 最终整页刷新js
// 让模块"可热更新"
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
newModule.render() // ✅ 用新模块重渲染,状态自己保留
})
}💡 框架层已封装:React Fast Refresh / Vue HMR 会自动保留组件状态, 业务代码通常不需要手写
accept。
5.2 Vite 为什么 HMR 特别快
| 维度 | Webpack | Vite |
|---|---|---|
| 开发服务器 | 先打包再启动(项目越大越慢) | 原生 ESM,按需编译,秒起 |
| HMR 粒度 | 需重建依赖图的一部分 | 只重新编译改动的那一个文件 |
| 预构建 | 无(每次全量) | 用 esbuild 预构建依赖(快 10~100×) |
六、工具选型:Webpack / Vite / Rollup / esbuild
| 工具 | 定位 | 强项 | 适合 |
|---|---|---|---|
| Webpack | 全能打包器 | 生态最全、能力最全面(各种 loader/plugin) | 复杂老项目、需要深度定制 |
| Vite | 开发服务器 + 构建(开发用 ESM,生产用 Rollup) | 开发体验、秒级启动与 HMR | 新项目的默认选择 |
| Rollup | 专注打包 | 产物干净、Tree Shaking 好 | 库/组件库打包 |
| esbuild | 极速编译器 | 速度(Go 编写) | 作为底层工具被集成(Vite 用它做预构建与压缩) |
| Rspack / Turbopack | Rust/Webpack 兼容的新一代 | 兼容 Webpack 生态 + 快 | 需要 Webpack 能力又嫌慢 |
选型速查
| 场景 | 选 |
|---|---|
| 业务应用(新项目) | Vite |
| 组件库 / SDK | Rollup(或 Vite 的 lib 模式) |
| 老 Webpack 项目、深度定制 | Webpack(或迁移 Rspack) |
| Monorepo 构建编排 | 配 Turborepo / Nx(见 A27) |
七、产物分析:优化前先测量
bash
npm i -D rollup-plugin-visualizer # Vite/Rollup
# 或 webpack-bundle-analyzer # Webpack看三件事:
| 关注点 | 说明 |
|---|---|
| 谁最大 | 通常是 moment(换 dayjs)、lodash(换 lodash-es 按需引)、图表库 |
| 重复依赖 | 同一个库的不同版本被打了两份 |
| 未被摇掉的代码 | 检查 sideEffects 与导出方式 |
详细的度量方法见 A25 · 前端性能度量与诊断。
自检清单
- [ ] 能说出构建流水线的 5 个阶段,以及 AST 在其中的位置
- [ ] 能写出一个最小 Babel 插件(visitor / path / types)
- [ ] 能列出 Tree Shaking 失效的三种常见原因并会排查
- [ ] 能说出 Code Splitting 的三种拆法与"过碎"的代价
- [ ] 能解释 Vite 开发态为什么快(原生 ESM + esbuild 预构建)
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "引入 ES6 语法就自动 Tree Shaking" | 还需 ESM 模块格式 + 无副作用声明 + 生产模式 |
| "分包越细越好" | 请求数与瀑布会抵消收益 |
| "Vite 生产构建也用 esbuild" | 生产用 Rollup(产物质量优先),esbuild 用于开发预构建/压缩 |
"sideEffects: false 随便加" | 真有副作用的文件会被误删,要精确声明 |
动手练习
- 用 AST Explorer 查看
const a = 1的 AST,再写一个 Babel 插件把var转成let。 - 造一个"Tree Shaking 失效"的最小复现(
export default {}+ 顶层副作用),再用sideEffects修好。 - 给项目接上
rollup-plugin-visualizer,找出体积最大的三个依赖并评估替换方案。
相关文档
- ESM 与 CJS 的根本差异 → A09 · JavaScript 运行时
- 工程化选型与工具使用 → A02 · 前端工程化
- 产物体积与首屏 → A10 · Web 性能优化 · A25 · 前端性能度量与诊断
- 工具链与规范 → A28 · 代码生成与规范化工具链