文章

Webpack构建流程深度解析

Webpack 构建是一次递归扫描加流水线加工:从 entry 遍历 import 建依赖图,Loader 转换模块,Plugin 通过 Tapable 钩子注入逻辑。 流程围绕 Compiler(全局管家)与 Compilation(单次编译)展开,面试常考 Tapable 钩子机制与编译、优化、输出三阶段。

Webpack构建流程深度解析

一句话概括

Webpack 的构建流程就是一次”递归扫描 + 流水线加工”:从 entry 出发遍历所有 import 建立依赖图,Loader 管道逐层转换每个模块,Plugin 通过 Tapable 钩子在编译、优化、输出的每个阶段注入自定义逻辑——整个流程围绕 Compiler(全局管家)和 Compilation(单次编译)两个对象展开。

核心知识点

1. 三阶段总览:初始化 → 编译 → 输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
配置文件 + CLI 参数
       │
       ▼
  ┌──────────────┐
  │ ① 初始化     │  Compiler 创建、Plugin 注册(apply 被调用)
  │   约 100ms   │
  └──────┬───────┘
         │ compiler.run()
         ▼
  ┌──────────────┐
  │ ② 编译       │  Compilation 创建 → Entry 解析 → 递归构建模块依赖图
  │   占 80%+    │  → Loader 转换 → 依赖收集 → 重复直到无新模块
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐
  │ ③ 输出       │  生成 Chunk → 优化(Tree Shaking/压缩)→ 写入 dist
  │   约 15-20%  │
  └──────────────┘

每个阶段都通过 Tapable 暴露钩子,Plugin 可以精确控制介入时机。

2. Compiler vs Compilation:两个最核心对象

1
2
3
4
5
6
7
// Compiler:一次 webpack 生命周期只有一个(全局单例)
const compiler = webpack(config);

// Compilation:每次构建都创建新的(watch 模式下每次文件变更都重建)
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
  // compilation 拥有本次构建的所有数据:modules、chunks、assets...
});
 CompilerCompilation
生命周期一次启动 → 关闭一次构建开始 → 结束
持有数据配置、插件、全局钩子模块图、Chunk、Asset
典型钩子run, watchRun, done, failedbuildModule, optimizeChunks, emit

3. 模块解析的递归过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 伪代码:make 阶段核心逻辑
function buildModule(module, compilation) {
  // 1. 读取源文件
  let source = fs.readFileSync(module.path, 'utf-8');
  
  // 2. Loader 链转换(从右到左执行 normal,从左到右执行 pitch)
  source = compilation.applyLoaders(module, source);
  
  // 3. 解析 AST,提取 import/require 语句
  const dependencies = parseAST(source); // acorn 做解析
  
  // 4. 递归处理依赖
  for (const dep of dependencies) {
    const resolved = resolveModule(dep, module.context); // 模块解析算法
    const childModule = createModule(resolved);
    compilation.addModule(childModule);
    buildModule(childModule, compilation); // 递归
  }
  
  // 5. 标记模块完成
  compilation.finishModule(module);
}

模块解析算法:import './utils' 会按顺序查找 ./utils.js → ./utils.json → ./utils/index.js → ./utils/package.json(配 resolve.extensions 和 resolve.mainFields)

4. Chunk 生成策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
optimization: {
  splitChunks: {
    chunks: 'all', // 对所有 chunk 生效(包括异步)
    cacheGroups: {
      vendor: {
        test: /node_modules/,  // node_modules 的代码抽到 vendor chunk
        name: 'vendor',
        priority: 10,          // 优先级高于 default
      },
      common: {
        minChunks: 2,          // 被 ≥2 个 chunk 引用才抽取
        reuseExistingChunk: true,
      }
    }
  },
  runtimeChunk: 'single', // 把 webpack 运行时代码单独抽出(利于长期缓存)
}

Chunk ≠ 入口文件。一个 entry 可以产出多个 chunk(通过动态 import),多个 entry 的共享代码也可以合并到一个 chunk(splitChunks)。Chunk 是输出文件的逻辑分组。

5. Tapable:Webpack 的”插件总线”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Webpack 内部大量使用 Tapable 的 9 种钩子
const { SyncHook, AsyncParallelHook } = require('tapable');

compiler.hooks = {
  make: new AsyncParallelHook(['compilation']),     // 异步并行
  compilation: new SyncHook(['compilation', 'params']), // 同步
  emit: new AsyncSeriesHook(['compilation']),       // 异步串行
  done: new SyncHook(['stats']),
};

// Plugin 注册:tap(同步)/ tapAsync(异步回调)/ tapPromise(返回 Promise)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
  // 在文件输出到磁盘前修改 asset
  const assets = compilation.getAssets();
  // ...
  callback();
});

Tapable 是 Webpack 插件系统的核心——没有它,所有 Plugin 就无法注入生命周期逻辑。

其实你每天都在用

  1. npm run dev 第一次启动慢、后续热更新快——第一次是完整构建(初始化 + 编译 + 输出),热更新只重建变化的模块及其依赖链,Compiler 实例复用、Compilation 对象重建。
  2. 改一个文件,浏览器自动刷新——Webpack Dev Server 用 webpack-dev-middleware 在内存中完成构建(不写磁盘),通过 WebSocket 推送 hash,浏览器对比后 hot accept 或 full reload。这一套流程依赖 compiler.watch() + HMR runtime。
  3. 生产构建比开发慢很多——因为生产模式下 MiniCssExtract、TerserPlugin、CssMinimizer、Tree Shaking 等优化阶段都在发挥作用。optimization.minimize: true 触发的压缩可能占构建时间的 30-50%。
  4. 一个 import 写错了路径,报错信息能精确告诉你查了哪些目录——Webpack 的 enhanced-resolve 库在解析失败时会记录所有尝试过的路径,产生的错误信息就是你的调试线索。
  5. thread-loader 把 Babel 转换放到 worker 线程——Loader 默认在主线程串行执行,thread-loader 利用了 Tapable 的异步钩子把耗时的转换分配到 Worker Pool,对于有大量 .tsx 文件的项目可提速 2-3 倍。

常见误解(FAQ)

  • ❌ 误区:「Loader 是从左到右执行的」 真相:normal 阶段从右到左(use: ['a', 'b', 'c'] 实际顺序是 c → b → a),但 pitch 阶段从左到右。如果某个 loader 的 pitch 返回了结果,后续 loader 的 normal 被跳过——这是 loader 链的”熔断”机制。

  • ❌ 误区:「cache: true 能解决所有性能问题」 真相:Webpack 5 的持久化缓存(filesystem cache)能跳过已缓存模块的重新构建,但模块解析(resolve)和 chunk 生成仍要执行。缓存最有效的场景是”只改了少量文件”的增量构建,全量构建缓存反而有读写开销。

  • ❌ 误区:「Compiler 和 Compilation 的钩子只是名字不同,功能差不多」 真相:Compiler 钩子用于”全局行为”(启动、停止、watch),Compilation 钩子用于”本次构建”(模块解析、优化、输出)。如果在 Compilation 钩子里只监听一次失效(因为每次构建重建 Compilation),必须用 Compiler 的 compilation 钩子来注册。

  • ❌ 误区:「Tree Shaking 发生在 loader 转换阶段」 真相:Tree Shaking 发生在编译阶段结束后、输出阶段开始前的优化阶段。具体步骤:① 标记未使用的 exports(usedExports),② 在 TerserPlugin 压缩时删除死代码。Loader 阶段只是把源码变成标准 ES Module,不负责判断哪些代码有用。

一句话总结

Webpack 的构建流程 = 从 entry 出发的一次图遍历 + 在每个节点上跑 loader 管道 + 在关键节点上触发 plugin 钩子——理解这个模型,性能优化、自定义 loader/plugin、调试构建报错三件事就都在你的射程内了。

本文由作者按照 CC BY 4.0 进行授权