Skip to content

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 原生 ESMVite 快在哪

一、构建流程全景 ​

无论 Webpack、Rollup 还是 Vite,核心都是一条流水线:

源码 ──① 解析(Parse)──► AST ──② 变换(Transform)──► 新 AST
     ──③ 生成(Generate)──► 目标代码 + SourceMap
     ──④ 模块图(Module Graph)──► 依赖分析
     ──⑤ 优化(Tree Shaking / 压缩 / 分包)──► 产物
阶段做什么代表工具
Parse源码 → ASTacorn、@babel/parser、esbuild
Transform遍历并改写 ASTBabel plugin、SWC、esbuild
GenerateAST → 代码@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 ​

工具语言速度生态
BabelJS慢最全
SWCRust快 20~70×兼容主流 preset
esbuildGo快 10~100×偏构建,插件生态较新

新项目可以用 SWC/esbuild 提速;但自定义 Babel 插件仍是 JS 生态不可替代的部分。


三、Tree Shaking:为什么"没生效" ​

3.1 前置条件(缺一不可) ​

条件说明
① 必须是 ESMimport/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 特别快 ​

维度WebpackVite
开发服务器先打包再启动(项目越大越慢)原生 ESM,按需编译,秒起
HMR 粒度需重建依赖图的一部分只重新编译改动的那一个文件
预构建无(每次全量)用 esbuild 预构建依赖(快 10~100×)

六、工具选型:Webpack / Vite / Rollup / esbuild ​

工具定位强项适合
Webpack全能打包器生态最全、能力最全面(各种 loader/plugin)复杂老项目、需要深度定制
Vite开发服务器 + 构建(开发用 ESM,生产用 Rollup)开发体验、秒级启动与 HMR新项目的默认选择
Rollup专注打包产物干净、Tree Shaking 好库/组件库打包
esbuild极速编译器速度(Go 编写)作为底层工具被集成(Vite 用它做预构建与压缩)
Rspack / TurbopackRust/Webpack 兼容的新一代兼容 Webpack 生态 + 快需要 Webpack 能力又嫌慢

选型速查 ​

场景选
业务应用(新项目)Vite
组件库 / SDKRollup(或 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 随便加"真有副作用的文件会被误删,要精确声明

动手练习 ​

  1. 用 AST Explorer 查看 const a = 1 的 AST,再写一个 Babel 插件把 var 转成 let。
  2. 造一个"Tree Shaking 失效"的最小复现(export default {} + 顶层副作用),再用 sideEffects 修好。
  3. 给项目接上 rollup-plugin-visualizer,找出体积最大的三个依赖并评估替换方案。

相关文档 ​