CC 咖啡猫的工作空间 Coding Space

构建工具

从 Webpack 到 Vite 再到 Rust 系工具,前端构建工具的演进本质是对「开发体验」与「构建性能」平衡点的持续迁移。本篇深入每种工具的核心原理、运行时机制和设计取舍。


1. Webpack

1.1 核心概念

概念 说明 本质
Entry 打包入口,指示 Webpack 从哪个模块开始构建依赖图 图遍历起点
Output 打包产物输出路径和文件名规则 序列化结果
Loader 模块转换器,将非 JS 文件转为 Webpack 可处理的模块 文件级转换管道
Plugin 在打包生命周期的各个阶段注入自定义逻辑 事件钩子回调
Module 被解析后的模块对象,包含源码、依赖列表、上下文等 图节点
Mode development / production / none,内置优化策略开关 预设配置集
// 最简配置骨架
module.exports = {
  entry: './src/index.js',
  output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js' },
  module: { rules: [{ test: /\.css$/, use: ['style-loader', 'css-loader'] }] },
  plugins: [new HtmlWebpackPlugin({ template: './index.html' })],
  mode: 'production',
};

1.2 打包流程(三阶段)

初始化 → 编译 → 输出
 初始化阶段:
   - 合并 CLI / 配置文件 / 默认配置
   - 创建 Compiler 对象
   - 加载所有 Plugin(调用 plugin.apply(compiler))
   - 触发 environment / afterEnvironment 钩子

 编译阶段:
   - 从 Entry 开始调用 run → compile → make 钩子
   - 递归创建 Module(`buildModule` → `normalModuleLoader`)
   - 对每个模块调用 Loader 链进行转换
   - 解析依赖(`parser.parse` 将源码转为 AST → 识别 import/require)
   - 生成依赖图(`Compilation.dependencyGraph`)
   - 触发 seal → optimize 系列钩子(Tree Shaking / 代码分割)

 输出阶段:
   - 根据依赖图 chunk 化 → 生成 Chunk 对象
   - 使用 `Template` 类为每个 Chunk 生成源码
   - emit(将文件写入内存 → 触发 afterEmit → done)

1.3 Loader 工作原理

链式调用:Loader 的执行顺序是 从右到左、从下到上(pitch 阶段相反)。

rules: [
  { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] }
]
// 执行顺序: sass-loader(pitch) → css-loader(pitch) → style-loader(pitch)
//          style-loader(normal) → css-loader(normal) → sass-loader(normal)

pitch / normal 双阶段

                     pitch 阶段(从左到右)
  style-loader.pitch → css-loader.pitch → sass-loader.pitch
                                              ↓
                     normal 阶段(从右到左)
  style-loader.normal ← css-loader.normal ← sass-loader.normal
  • pitch:Loader 的 pitch 方法在 normal 之前执行。如果某个 Loader 的 pitch 有返回值,则跳过后续 Loader 的 pitch 和所有后续 Loader 的 normal,直接以前一个 Loader 的 normal 处理该返回值。
  • normal:接收模块源码或上一个 Loader 的处理结果,返回处理后的源码。
// 自定义 Loader 模板
module.exports = function(source, sourceMap, meta) {
  // this 指向 LoaderContext,提供 loaderIndex, remainingRequest, callback 等
  const result = transform(source);
  return result; // 或 this.callback(null, result, sourceMap);
};

1.4 Plugin 原理

Tapable 钩子系统:Webpack 的核心事件架构基于 Tapable 库,提供 9 种钩子类型:

钩子类型 注册方法 执行方式 用途
SyncHook tap 同步串行 普通事件通知
SyncBailHook tap 同步串行,有返回值则停止 熔断校验
SyncWaterfallHook tap 同步串行,上一步结果传给下一步 数据管道
SyncLoopHook tap 循环执行直到返回 undefined 重复任务
AsyncParallelHook tapAsync/tapPromise 异步并行 并行任务
AsyncSeriesHook tapAsync/tapPromise 异步串行 顺序异步

Compiler vs Compilation

Compiler(全局单例)
 ├── 生命周期: run → compile → make → seal → emit → done
 ├── 持有完整配置 options
 ├── 负责文件系统监听(watch mode)
 └── 每次 rebuild 创建新的 Compilation

Compilation(单次构建实例)
 ├── 管理模块依赖图
 ├── 优化(optimize)阶段
 ├── 代码分割(splitChunks)
 └── 产物生成(createChunkAssets)
// 自定义 Plugin 模板
class MyPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      compilation.assets['my-file.js'] = {
        source: () => '// generated content',
        size: () => 18,
      };
      callback();
    });
  }
}

1.5 HMR 原理(Hot Module Replacement)

  WebSocket 通道                          WebSocket 通道
  ┌──────────┐  hash/ok   ┌──────────────┐
  │  Dev Server│ ────────→ │  Browser     │
  │  (webpack- │           │  (HMR Runtime)│
  │  dev-server)│          │              │
  └─────┬─────┘           └──────┬───────┘
        │ JSON manifest          │
        ▼                        ▼
  ┌──────────┐             ┌──────────────┐
  │  Memory  │             │  Accept 模块  │
  │  FileSystem │           │  替换 + 冒泡   │
  └──────────┘             └──────────────┘

流程

  1. 文件变更 → Webpack 重新编译 → 生成 [hash].hot-update.json(manifest)和 [hash].hot-update.js(增量 chunk)
  2. Dev Server 通过 WebSocket 向浏览器推送 {"type":"hash","data":"newHash"}{"type":"ok"}
  3. 浏览器 HMR Runtime 通过 JSONP 拉取增量文件
  4. 模块自替换:module.hot.accept 注册的回调执行
  5. 如果当前模块未 accept,冒泡到父模块;冒泡到顶层则触发全量刷新
// 模块接受热替换
if (module.hot) {
  module.hot.accept('./component.js', () => {
    const NewComponent = require('./component.js');
    render(NewComponent);
  });
}

1.6 Code Splitting(splitChunks)

Webpack 4+ 的 SplitChunksPlugin 替代了 CommonsChunkPlugin,核心配置:

optimization: {
  splitChunks: {
    chunks: 'async',      // all / async / initial
    minSize: 30000,        // 30KB 以上才分割
    minChunks: 1,          // 至少被引用次数
    maxAsyncRequests: 30,  // 按需加载时并行请求上限
    maxInitialRequests: 30,// 入口点并行请求上限
    cacheGroups: {
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        priority: -10,
      },
      default: {
        minChunks: 2,
        priority: -20,
        reuseExistingChunk: true,
      },
    },
  },
}

分割策略:基于模块间的引用图 + 启发式规则(大小、共用次数、异步/同步)自动划分 chunk。

1.7 Tree Shaking

原理:ES Module 的静态结构(import/export 必须在顶层、不能条件导入)使得 Webpack 可以在编译时分析模块间的引用关系,识别并删除「从未被使用的导出」。

// utils.js
export const used = 'used';
export const unused = 'unused';  // 被标记为 /* unused harmony export */

// main.js
import { used } from './utils';
// → 构建后 unused 被剔除

sideEffects 标记:在 package.json 中声明模块是否有副作用:

{
  "sideEffects": false,
  // 或指定有副作用的文件
  "sideEffects": ["*.css", "*.scss"]
}
  • false:告诉 Webpack 所有模块的导入/导出都是纯的,未被引用的导出可以安全删除
  • 数组形式:列表外的文件可以安全 Tree Shaking

注意:Babel 转译时若 @babel/preset-envmodules: "commonjs" 会将 ESM 转为 CJS,Tree Shaking 失效。需设置 modules: false 保留 ESM。

1.8 Source Map 类型

类型 构建速度 映射精度 说明
inline-source-map 源码 Source Map 嵌入为 Data URL,产物最大
eval 转译后 每个模块使用 eval() 包裹,后附 //# sourceURL
cheap-source-map 中等 行级(无列) 只有行映射,不含列信息,速度优于完整 Source Map
cheap-module-source-map 中等 行级(含 Loader 映射) 包含 Loader 处理前后的映射关系
eval-source-map 中等 源码 每个模块使用 eval() + Data URL Source Map
hidden-source-map 源码 生成 .map 文件但不引用,用于错误上报还原
nosources-source-map 源码(无源码内容) 只有行列映射,不包含源码内容

推荐组合

  • 开发:eval-cheap-module-source-map(快速 + 原始源码行号)
  • 生产:hidden-source-map(上传监控平台,不暴露给用户)

1.9 持久化缓存

// Webpack 5 内置持久化缓存
module.exports = {
  cache: {
    type: 'filesystem',          // memory | filesystem
    cacheDirectory: path.resolve(__dirname, '.temp_cache'),
    buildDependencies: {
      config: [__filename],      // 配置文件变更时缓存失效
    },
    version: '1.0',              // 自定义版本号
  },
};

原理:将编译后的模块(已转译/已解析依赖)序列化存储到磁盘。二次构建时跳过未变更模块的转译和依赖解析,直接反序列化使用。缓存失效基于文件内容 hash 和 buildDependencies。


2. Vite

2.1 核心架构

┌──────────────────────────────────────────┐
│                Vite Dev Server            │
│  ┌─────────┐  ┌──────────┐  ┌─────────┐ │
│  │  esbuild  │  │  Koa     │  │  HMR    │ │
│  │ 预构建    │  │ 中间件    │  │  WebSocket│ │
│  └─────────┘  └──────────┘  └─────────┘ │
│  ┌──────────────────────────────────────┐ │
│  │      Rollup(生产构建引擎)            │ │
│  └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘

开发时 vs 生产时

阶段 引擎 方式
开发 esbuild + 原生 ESM Bundleless,按需编译
生产 Rollup Bundle,优化产物

2.2 esbuild 预构建(依赖预打包)

为什么需要预构建?

  1. CommonJS → ESM 转换node_modules 中大部分依赖是 CJS,而 Vite 的开发服务器只提供 ESM 格式,需提前转为 ESM
  2. 模块合并:lodash-es 等库有数百个内部文件,每个文件一个请求会造成大量 HTTP 往返。预构建将分散的模块合并为少量 chunk
// vite.config.js
export default {
  optimizeDeps: {
    include: ['lodash-es', 'vue'],   // 强制预构建
    exclude: ['your-custom-lib'],      // 排除预构建
  },
};

实现:esbuild 用 Go 编写,利用 CPU 多核并行打包,速度是 Webpack/Terser 的 10-100 倍。

2.3 请求拦截(Koa 中间件)

Vite 开发服务器基于 Koa,核心中间件拦截浏览器对 .vue/.ts/.tsx 等非原生模块的请求:

浏览器请求 /src/App.vue
  ↓
Vite Server 拦截
  ↓
编译 App.vue(template → render, script → JS, style → CSS)
  ↓
返回编译后的 ESM 模块(Content-Type: application/javascript)
// 简化的中间件逻辑
app.use(async (ctx, next) => {
  if (ctx.path.endsWith('.vue')) {
    const code = await compileSFC(ctx.path);
    ctx.type = 'js';
    ctx.body = code;
    return;
  }
  if (ctx.path.endsWith('.ts')) {
    const code = esbuild.transformSync(rawCode, { loader: 'ts' });
    ctx.type = 'js';
    ctx.body = code.code;
    return;
  }
  await next();
});

路径重写:将所有裸模块导入(import 'vue')重写为 /@modules/vue.js,指向预构建好的依赖。

2.4 HMR(基于 ESM)

Vite 的 HMR 比 Webpack 更精准、更快速:

 文件变更
   ↓
 Vite Server 通过 WebSocket 推送更新类型(vue-render / full-reload / style-update)
   ↓
 浏览器的 HMR Runtime 根据类型:
   - vue-render: 重新请求编译后的 SFC render 模块
   - style-update: 通过 new CSSStyleSheet 或 link 标签热替换 CSS
   - full-reload: window.location.reload()

关键差异

  • Webpack HMR 需要为每个模块生成 HMR runtime + chunk manifest,额外网络开销
  • Vite 直接利用 ESM 的 import() 动态加载,浏览器原生缓存模块,增量请求只包含变更模块

2.5 生产构建(Rollup)

// vite.config.js
export default {
  build: {
    target: 'es2020',           // 目标浏览器
    outDir: 'dist',
    assetsDir: 'assets',
    cssCodeSplit: true,         // CSS 代码分割
    rollupOptions: {             // 透传 Rollup 配置
      output: {
        manualChunks: (id) => {
          if (id.includes('node_modules')) return 'vendor';
        },
      },
    },
    minify: 'esbuild',          // esbuild / terser
    sourcemap: false,
  },
};

2.6 esbuild vs SWC 对比

维度 esbuild SWC
语言 Go Rust
定位 Bundler + Minifier + Transformer Compiler(Babel 替代品)
转译 内置 TypeScript、JSX 转译 内置 TypeScript、JSX 转译
压缩 极快(esbuild minifier) 需要额外插件
插件 js 插件(通信成本高) wasm/js 插件
Tree Shaking 有限支持(不完整的 ESM) 支持
代码生成 单文件输出为主 更接近 Babel 的 AST 操作
成熟度 较新,Babel 兼容性有坑 较新,next.js/swc 为关键案例

选型建议:开发转译用 esbuild(Vite 默认),Babel 替代选 SWC(if Rust 生态),或继续用 Babel(生态最成熟)。

2.7 Vite 插件机制

Vite 插件基于 Rollup 插件接口,增加了 Vite 特有钩子:

// Vite 插件示例
export default function myPlugin() {
  return {
    name: 'vite-plugin-example',
    
    // Rollup 通用钩子
    resolveId(id) { /* 自定义模块解析 */ },
    load(id) { /* 自定义模块加载 */ },
    transform(code, id) { /* 代码转换 */ },
    
    // Vite 特有钩子
    config(config, env) { /* 修改 Vite 配置 */ },
    configResolved(config) { /* 配置解析后 */ },
    configureServer(server) { /* 添加 Koa 中间件 */ },
    handleHotUpdate({ file, server }) { /* HMR 自定义处理 */ },
  };
}

2.8 为什么 Vite 开发快?

冷启动快

  • 不需要打包整个应用(Bundleless),只需编译当前请求的模块
  • esbuild 预构建比 Webpack 的 node_modules 打包快 10-100 倍
  • 浏览器原生 ESM 加载,节省了 Webpack 的模块包裹和 runtime 生成

更新快

  • 编辑文件后,只需重新编译该文件(而不是像 Webpack 那样重建部分 chunk)
  • 浏览器通过 ESM 的 import() 增量加载,无需全量 manifest
  • 模块缓存:module graph 只替换变更的叶子节点

3. 其他构建工具

3.1 Turbopack(Vercel - Rust)

定位:Next.js 的 Webpack 替代品,基于 Rust 的增量计算引擎。

核心原理

 增量计算(Incremental Computation)
 ┌──────────────────────────────┐
 │  Function Cache (Turbo Engine)│
 │  输入: (文件路径, 内容 hash)    │
 │  输出: (编译结果)              │
 │  缓存: LRU + 函数级细粒度      │
 │  失效: 精确到函数粒度的依赖追踪  │
 └──────────────────────────────┘
  • 函数级缓存:每个编译步骤(解析、转译、分割)都是纯函数,根据输入 hash 决定是否复用
  • 懒计算:只编译当前路由需要的模块,而不是整个应用
  • 并发:Rust 无 GC + CPU 密集任务并行

与 Webpack 对比:Turbopack 在大型项目中首次构建比 Vite 快 10 倍,热更新比 Vite 快(官方数据)。

3.2 Rspack(字节跳动 - Rust)

定位:兼容 Webpack 配置和生态的 Rust 打包器。

// rspack.config.js — 几乎与 Webpack 配置一致
module.exports = {
  entry: './src/index.js',
  output: { filename: 'bundle.js' },
  module: {
    rules: [
      { test: /\.jsx$/, use: { loader: 'builtin:swc-loader' } },
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
    ],
  },
  plugins: [new HtmlWebpackPlugin()],
};

优势

  • 与 Webpack 配置 90%+ 兼容,迁移成本极低
  • Rust 核心,构建速度 5-10 倍于 Webpack
  • 内置 SWC 转译(builtin:swc-loader),免 Babel 配置
  • 支持 Webpack 的 Loader 和 Plugin 生态(通过 @rspack/plugin-node 桥接)

限制:部分 Webpack 插件需要适配,不支持 module.rules 全部特性集。

3.3 Parcel(零配置原理)

核心思想:约定大于配置,内置所有常见 Loader。

parcel build src/index.html  # 无需任何配置文件

原理

  • 文件类型自动推断:.vue@parcel/transformer-vue.ts@parcel/transformer-typescript-tsc
  • 多线程编译(基于 worker_threads)
  • Scope Hoisting:将模块合并为闭包包裹,减少运行时开销
  • 内置 dev server、HMR、图片压缩、PostCSS、Babel
  • 内容哈希:自动基于内容生成文件名

3.4 SWC 编译器

定位:Rust 版的 Babel,提供 Parser → Transform → Code Generator 全链路。

 源码 → Parser → AST → Transform → AST → Codegen → 输出
// @swc/core API
import swc from '@swc/core';

const output = await swc.transform(source, {
  jsc: {
    parser: { syntax: 'typescript', tsx: true },
    transform: {
      react: { runtime: 'automatic' },
    },
  },
  module: { type: 'es6' },
});

性能:单线程比 Babel 快 20 倍,多线程差距更大。Next.js、Parcel、Rspack 都深度使用了 SWC。


4. 核心概念对比

4.1 Bundle vs Bundleless

维度 Bundle(Webpack/Rspack) Bundleless(Vite/Turbopack 开发)
开发启动 全量打包后才启动 直接启动,按需编译
首次加载 需要等待整个应用编译 只编译当前页面模块
缓存策略 模块级缓存(内存) HTTP 缓存 + 浏览器 ESM 缓存
更新成本 重建部分 chunk(依赖边界) 单文件重编译
产物优化 生产时精细优化 生产时仍需 Bundle(Rollup)

本质:Bundleless 的核心不是不打包,而是 延迟打包 —— 开发时只做最小粒度的编译,生产时仍需要全量打包以保证性能。

4.2 ESM vs CJS

// CommonJS - 运行时
const path = require('path');       // 同步加载
module.exports = { a: 1 };
exports.b = 2;                      // module.exports 的引用

// ES Module - 静态
import path from 'path';            // 异步加载,提升到模块顶部
export const a = 1;
export default { b: 2 };
维度 CJS ESM
加载时机 运行时(require 执行到才加载) 编译时(静态分析)
值绑定 值拷贝 动态绑定(export const 只读引用)
Tree Shaking 不支持 天然支持
循环引用 返回已执行部分的 module.exports(可能有 bug) 通过静态分析正确处理
异步 同步 异步(顶层 await)
浏览器 不支持原生 原生支持

4.3 Source Map 调试原理

 源码(src/index.ts)
   ↓ 转译
 目标代码(dist/index.js)
   ↓ 生成
 .map 文件(VLC 层索引)

.map 文件结构

{
  "version": 3,
  "file": "index.js",           // 转译后文件名
  "sources": ["src/index.ts"],  // 原始源文件
  "sourcesContent": ["..."],    // 原始源码内容
  "names": ["console", "log"],  // 标识符名
  "mappings": "AAAA;AACA;AACA;" // Base64 VLQ 编码的位置映射
}

VLQ(Variable Length Quantity)编码

  • 每个位置对应:(目标行, 目标列, 源索引, 源行, 源列, 名称索引)
  • Base64 VLQ 用 6 bits 表示一个值,用连续 segment 表示多个值变化
  • ; 分割行,, 分割 segment

浏览器还原:Chrome DevTools 根据 .map 文件,在 Stack Trace 和 Sources 面板中映射回原始源码位置。

4.4 Babel 工作原理

  Parser        Transform       Generator
  ──────        ─────────       ─────────
  源码 → [词法分析] → Token 流
       → [语法分析] → AST(@babel/parser = acorn 魔改版)
                   → [遍历 + 插件] → 修改后的 AST
                                  → [代码生成] → 目标代码

AST 结构示例

// 源码: const x = a + b;
// 简化的 AST:
{
  type: "VariableDeclaration",
  kind: "const",
  declarations: [{
    type: "VariableDeclarator",
    id: { type: "Identifier", name: "x" },
    init: {
      type: "BinaryExpression",
      operator: "+",
      left: { type: "Identifier", name: "a" },
      right: { type: "Identifier", name: "b" },
    }
  }]
}

插件工作方式

// Babel Plugin 是 Visitor 模式
module.exports = function() {
  return {
    visitor: {
      // 进入 Identifier 节点时触发
      Identifier(path) {
        if (path.node.name === 'a') {
          path.node.name = 'alias_a';
        }
      },
      // 进入函数调用时触发
      CallExpression(path) {
        // 通过 path 操作 AST 节点
        path.replaceWith(
          types.stringLiteral('replaced')
        );
      },
    },
  };
};

Preset:一组插件的集合。@babel/preset-env 根据 targets 自动确定需要哪些转译插件,避免过度转译。

{
  "presets": [
    ["@babel/preset-env", {
      "targets": "> 0.25%, not dead",
      "useBuiltIns": "usage",    // 按需引入 polyfill
      "corejs": 3,
    }]
  ]
}