Skip to content

Webpack

什么是 Webpack

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 Webpack 处理应用程序时,它会从一个或多个入口点出发,递归地构建一个依赖图(dependency graph),然后将所有模块打包成一个或多个 bundle 文件。

Webpack 的核心定位:

源代码(各种模块)              产物(浏览器可执行文件)

 ┌──────────┐
 │  app.js  │──→ import Header
 └──────────┘         │
      │          ┌────▼──────┐
      │          │ header.js │──→ import './header.css'
      │          └───────────┘         │
      │               │           ┌────▼───────┐        ┌───────────┐
      │               │           │ header.css │   ═══▶ │ bundle.js │
      │          ┌────▼──────┐    └────────────┘        └───────────┘
      │          │ utils.js  │                          ┌───────────┐
      │          └───────────┘                     ═══▶ │ style.css │
      │                                                 └───────────┘
 ┌────▼──────┐
 │ index.css │
 └───────────┘

    依赖图                                    Bundle

Webpack 之所以强大,在于它将一切资源——JS、CSS、图片、字体、JSON 等——都视为模块,通过 Loader 和 Plugin 的扩展机制实现对任意类型资源的处理。


核心概念

Entry(入口)

Entry 是 Webpack 构建依赖图的起点。Webpack 从 Entry 开始,递归解析所有依赖模块。

js
module.exports = {
  entry: './src/index.js',
}

多入口配置:

js
module.exports = {
  entry: {
    app: './src/app.js',
    admin: './src/admin.js',
  },
}

Output(输出)

Output 告诉 Webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。

js
const path = require('path')

module.exports = {
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    clean: true,
  },
}

Loader(加载器)

Webpack 原生只能理解 JavaScript 和 JSON 文件。Loader 让 Webpack 能够处理其他类型的文件,并将它们转换为有效的模块。Loader 本质上是一个导出为函数的 JavaScript 模块,接收源文件内容作为参数,返回转换后的内容。

js
module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
      { test: /\.ts$/, use: 'ts-loader' },
    ],
  },
}

Plugin(插件)

Plugin 用于执行范围更广的任务,从打包优化、资源管理到环境变量注入,无所不能。Plugin 通过监听 Webpack 构建过程中广播的事件钩子,在合适的时机执行自定义逻辑。

js
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
}

Module / Chunk / Bundle 的区别

这三个概念是理解 Webpack 的基础,它们分别对应构建流程的不同阶段:

开发阶段           构建阶段           产出阶段

Module    ─────▶  Chunk    ─────▶  Bundle
(源文件)         (模块集合)        (输出文件)

src/               Webpack内部        dist/
├── index.js  ──┐  ┌─────────┐   ┌─ app.a1b2c3.js
├── utils.js  ──┼─▶│ chunk-app│──▶├─ vendor.d4e5f6.js
├── header.js ──┘  └─────────┘   └─ app.g7h8i9.css
├── lodash   ────▶ chunk-vendor
└── style.css ──▶  chunk-css
概念阶段描述
Module开发时源代码中的每个文件就是一个 Module,是 Webpack 处理的基本单元
Chunk编译时Webpack 根据入口和依赖关系将 Module 分组,形成 Chunk,是 Webpack 内部的中间产物
Bundle输出时Chunk 经过压缩、优化后输出的最终文件,是浏览器实际加载的资源

Chunk 的产生来源有三种:

  1. Entry 入口:每个 entry 会产生至少一个 chunk
  2. 动态导入import() 语法会产生新的 chunk
  3. SplitChunks:代码分割抽取的公共 chunk

构建流程

Webpack 的构建过程可以分为四个核心阶段:初始化 → 编译(make)→ 生成(seal)→ 输出(emit)

                        Webpack 完整构建流程

 ┌─────────────────────────────────────────────────────────────────┐
 │                        1. 初始化阶段                             │
 │                                                                 │
 │   读取配置文件(webpack.config.js)                               │
 │          ↓                                                      │
 │   合并 Shell 参数与配置文件参数                                    │
 │          ↓                                                      │
 │   创建 Compiler 对象                                             │
 │          ↓                                                      │
 │   注册所有配置的 Plugin(调用 plugin.apply(compiler))             │
 │          ↓                                                      │
 │   触发 environment / afterEnvironment 钩子                       │
 └──────────────────────────┬──────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────────┐
 │                      2. 编译阶段(make)                         │
 │                                                                 │
 │   触发 compiler.hooks.make 钩子                                  │
 │          ↓                                                      │
 │   从 Entry 开始,调用 loader 对模块进行转译                        │
 │          ↓                                                      │
 │   调用 acorn 将转译后的代码解析为 AST                              │
 │          ↓                                                      │
 │   遍历 AST 找出依赖(import / require)                           │
 │          ↓                                                      │
 │   对每个依赖递归执行上述步骤                                       │
 │          ↓                                                      │
 │   构建完成完整的 Module 依赖图(ModuleGraph)                      │
 └──────────────────────────┬──────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────────┐
 │                      3. 生成阶段(seal)                         │
 │                                                                 │
 │   根据 Entry 和依赖关系将 Module 分配到不同的 Chunk                │
 │          ↓                                                      │
 │   执行 SplitChunks 优化                                         │
 │          ↓                                                      │
 │   执行 Tree Shaking(标记未使用导出)                              │
 │          ↓                                                      │
 │   为每个 Chunk 生成最终代码(调用 Template 渲染)                   │
 │          ↓                                                      │
 │   生成 ChunkGraph 和最终的 Assets                                │
 └──────────────────────────┬──────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────────┐
 │                      4. 输出阶段(emit)                         │
 │                                                                 │
 │   触发 compiler.hooks.emit 钩子                                  │
 │          ↓                                                      │
 │   根据 output 配置确定输出路径和文件名                              │
 │          ↓                                                      │
 │   将 Assets 内容写入文件系统                                      │
 │          ↓                                                      │
 │   触发 compiler.hooks.done 钩子                                  │
 │          ↓                                                      │
 │   构建完成                                                       │
 └─────────────────────────────────────────────────────────────────┘

各阶段涉及的核心对象:

阶段核心对象职责
初始化Compiler全局唯一,代表完整的 Webpack 环境配置
编译Compilation一次编译过程的上下文,包含模块、依赖、Chunk 等信息
编译NormalModule代表一个具体的模块
编译ModuleGraph记录模块之间的依赖关系
生成ChunkGraph记录 Chunk 和 Module 的映射关系
输出assets最终要输出到磁盘的文件内容

Loader 机制

Loader 的本质

Loader 本质上是一个函数转换器。它接收源文件内容(字符串或 Buffer),经过处理后返回新的内容。Webpack 就像一条流水线,源文件依次经过多个 Loader 的加工,最终变成 Webpack 能理解的 JavaScript 模块。

源文件内容 ──▶ Loader A ──▶ Loader B ──▶ Loader C ──▶ Webpack 可处理的 JS

一个最基本的 Loader 结构:

js
module.exports = function (source) {
  const result = someTransform(source)
  return result
}

执行顺序

Loader 的执行顺序是从右到左、从下到上,这是 Webpack 的核心设计。以 CSS 处理为例:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'postcss-loader'],
      },
    ],
  },
}
执行顺序(从右到左):

.css 文件

postcss-loader     ← 第 1 步:处理 CSS 新语法、添加浏览器前缀

css-loader         ← 第 2 步:处理 @import / url(),将 CSS 转为 JS 模块

style-loader       ← 第 3 步:将 CSS 注入到 DOM 的 <style> 标签中

Webpack 最终处理

等价的从下到上写法:

js
module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: 'postcss-loader' },
      { test: /\.css$/, use: 'css-loader' },
      { test: /\.css$/, use: 'style-loader' },
    ],
  },
}

为什么是从右到左?因为 Webpack 采用了**函数组合(compose)**的方式:styleLoader(cssLoader(postcssLoader(source))),数学上函数组合 f(g(h(x))) 的执行顺序就是从内到外,对应配置数组的从右到左。

常用 Loader

Loader功能输入 → 输出
babel-loaderES6+ 转 ES5JSX/ES6+ → ES5
ts-loaderTypeScript 编译.ts/.tsx → .js
css-loader解析 CSS 中的 @import 和 url().css → JS 模块
style-loader将 CSS 注入 DOMJS 模块 → <style> 标签
postcss-loaderCSS 后处理(autoprefixer 等).css → .css
sass-loaderSass/SCSS 编译.scss → .css
file-loader处理文件资源,输出文件并返回 URL文件 → URL 字符串
url-loader类似 file-loader,小文件转 base64文件 → base64/URL
raw-loader将文件内容作为字符串导入文件 → 字符串
thread-loader多线程加速 Loader 执行

Loader 的 pitch 阶段

每个 Loader 除了正常的执行函数(normal)外,还可以有一个 pitch 函数。Loader 的执行分为两个阶段:pitch 阶段(从左到右)和 normal 阶段(从右到左)。

配置:use: ['a-loader', 'b-loader', 'c-loader']

Pitch 阶段(从左到右)              Normal 阶段(从右到左)

a-loader.pitch()                   c-loader()
       ↓                                ↓
b-loader.pitch()                   b-loader()
       ↓                                ↓
c-loader.pitch()                   a-loader()
       ↓                                ↓
    读取源文件                        最终结果

关键机制:如果某个 Loader 的 pitch 函数有返回值,则跳过后续 Loader,直接进入前面 Loader 的 normal 阶段(熔断机制):

a-loader.pitch()

b-loader.pitch()  ──返回值──▶  跳过 c-loader
       │                            │
       └────────────────────────────▶ a-loader()

                                      最终结果

style-loader 正是利用了 pitch 机制。在 pitch 阶段拦截请求,返回一段内联的 JS 代码来加载 CSS,而不需要在 normal 阶段处理 css-loader 的输出。

pitch 函数的签名:

js
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  data.value = 42
}

data 对象在 pitch 和 normal 阶段之间共享,可用于传递数据。

手写一个 Loader

实现一个 markdown-loader,将 Markdown 文件转换为 HTML 字符串导出:

js
const { marked } = require('marked')

module.exports = function (source) {
  const html = marked(source)
  return `export default ${JSON.stringify(html)}`
}

使用方式:

js
module.exports = {
  module: {
    rules: [
      { test: /\.md$/, use: './loaders/markdown-loader.js' },
    ],
  },
}
js
import content from './readme.md'
document.getElementById('app').innerHTML = content

支持异步 Loader 的写法(当 Loader 中有异步操作时):

js
const { marked } = require('marked')

module.exports = function (source) {
  const callback = this.async()

  marked(source, (err, html) => {
    if (err) return callback(err)
    callback(null, `export default ${JSON.stringify(html)}`)
  })
}

Loader 中的 this 上下文(Loader Context)提供了许多有用的 API:

API描述
this.async()声明这是一个异步 Loader,返回 callback 函数
this.callback(err, content, sourceMap, meta)同步返回多个结果
this.cacheable(flag)标记 Loader 是否可缓存(默认 true)
this.resourcePath当前处理文件的绝对路径
this.queryLoader 的 options 参数
this.emitFile(name, content)输出一个文件到构建目录
this.addDependency(file)添加文件依赖,当文件变化时触发重新编译

Plugin 机制

Plugin 的本质

Plugin 的本质是基于 Tapable 事件流框架的钩子系统。Webpack 在构建过程中会触发一系列事件钩子(Hook),Plugin 通过监听这些钩子来执行自定义逻辑。

Tapable 事件流体系:

Compiler(全生命周期)

  ├── environment
  ├── afterEnvironment
  ├── entryOption
  ├── afterPlugins
  ├── beforeRun
  ├── run
  ├── beforeCompile
  ├── compile
  ├── make ─────────────────┐
  │                         ↓
  │                   Compilation(单次编译)
  │                     ├── buildModule
  │                     ├── succeedModule
  │                     ├── seal
  │                     ├── optimize
  │                     ├── optimizeChunks
  │                     ├── optimizeModules
  │                     ├── afterOptimizeTree
  │                     ├── processAssets
  │                     └── afterSeal
  │                         │
  ├── afterCompile ◀────────┘
  ├── emit
  ├── afterEmit
  └── done

Tapable 核心钩子类型

Tapable 提供了多种类型的 Hook,满足不同的执行需求:

Hook 类型执行方式描述
SyncHook同步串行不关心返回值
SyncBailHook同步串行熔断某个回调返回非 undefined 则停止
SyncWaterfallHook同步串行瀑布上一个回调的返回值传给下一个
SyncLoopHook同步循环回调返回非 undefined 则从头重新执行
AsyncParallelHook异步并行所有回调并行执行
AsyncSeriesHook异步串行一个接一个执行
AsyncSeriesBailHook异步串行熔断异步版本的 BailHook
AsyncSeriesWaterfallHook异步串行瀑布异步版本的 WaterfallHook

Tapable 使用示例:

js
const { SyncHook, AsyncSeriesHook } = require('tapable')

const hook = new SyncHook(['arg1', 'arg2'])

hook.tap('PluginA', (arg1, arg2) => {
  console.log('PluginA:', arg1, arg2)
})

hook.tap('PluginB', (arg1, arg2) => {
  console.log('PluginB:', arg1, arg2)
})

hook.call('hello', 'world')

Compiler 与 Compilation 的区别

对比项CompilerCompilation
生命周期Webpack 启动到结束,全局唯一每次编译(包括 watch 模式下的重新编译)创建一个新的
职责管理整个构建流程、Plugin 注册管理一次具体的编译过程中的模块、依赖、Chunk
包含内容Webpack 配置、Plugin 实例、文件系统模块(modules)、chunks、assets
获取方式plugin.apply(compiler)compiler.hooks.compilation
典型钩子run、emit、donebuildModule、seal、processAssets
Compiler 与 Compilation 的关系:

┌──────────────────────────────────────────────────┐
│                   Compiler                        │
│              (全局唯一,管理整个构建)               │
│                                                   │
│   ┌────────────────────────────────────────────┐  │
│   │         Compilation #1(首次编译)           │  │
│   │  modules / chunks / assets / dependencies  │  │
│   └────────────────────────────────────────────┘  │
│                                                   │
│   ┌────────────────────────────────────────────┐  │
│   │         Compilation #2(watch 触发)         │  │
│   │  modules / chunks / assets / dependencies  │  │
│   └────────────────────────────────────────────┘  │
│                                                   │
│   ┌────────────────────────────────────────────┐  │
│   │         Compilation #3(watch 触发)         │  │
│   │  modules / chunks / assets / dependencies  │  │
│   └────────────────────────────────────────────┘  │
│                       ...                         │
└──────────────────────────────────────────────────┘

常用 Plugin

Plugin功能
HtmlWebpackPlugin自动生成 HTML 文件,并注入打包后的 JS/CSS
MiniCssExtractPlugin将 CSS 提取为独立文件(替代 style-loader)
DefinePlugin在编译时定义全局常量(如环境变量)
CopyWebpackPlugin复制文件到输出目录(如 public 目录下的静态资源)
TerserWebpackPluginJS 压缩(Webpack 5 内置,production 模式自动启用)
CssMinimizerWebpackPluginCSS 压缩
BundleAnalyzerPlugin可视化分析 bundle 体积
CleanWebpackPlugin清理输出目录(Webpack 5 中可用 output.clean 替代)
ProgressPlugin显示构建进度

DefinePlugin 使用示例:

js
const webpack = require('webpack')

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production'),
      __APP_VERSION__: JSON.stringify('1.0.0'),
      PRODUCTION: JSON.stringify(true),
    }),
  ],
}

MiniCssExtractPlugin 使用示例:

js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
    }),
  ],
}

手写一个 Plugin

实现一个 FileListPlugin,在构建完成后自动生成一个包含所有输出文件列表的 filelist.md

js
class FileListPlugin {
  constructor(options = {}) {
    this.filename = options.filename || 'filelist.md'
  }

  apply(compiler) {
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      const fileList = Object.keys(compilation.assets)

      let content = '# Build File List\n\n'
      content += `Total: ${fileList.length} files\n\n`
      fileList.forEach((filename) => {
        const size = compilation.assets[filename].size()
        content += `- ${filename} (${(size / 1024).toFixed(2)} KB)\n`
      })

      compilation.assets[this.filename] = {
        source: () => content,
        size: () => content.length,
      }

      callback()
    })
  }
}

module.exports = FileListPlugin

Webpack 5 推荐使用 processAssets 钩子:

js
class FileListPlugin {
  constructor(options = {}) {
    this.filename = options.filename || 'filelist.md'
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('FileListPlugin', (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: 'FileListPlugin',
          stage: compilation.constructor.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          const fileList = Object.keys(assets)
          let content = '# Build File List\n\n'
          content += `Total: ${fileList.length} files\n\n`

          fileList.forEach((filename) => {
            const size = assets[filename].size()
            content += `- ${filename} (${(size / 1024).toFixed(2)} KB)\n`
          })

          compilation.emitAsset(
            this.filename,
            new compiler.webpack.sources.RawSource(content)
          )
        }
      )
    })
  }
}

module.exports = FileListPlugin

使用方式:

js
const FileListPlugin = require('./plugins/FileListPlugin')

module.exports = {
  plugins: [
    new FileListPlugin({ filename: 'filelist.md' }),
  ],
}

Plugin 开发的核心要点:

  1. Plugin 是一个具有 apply 方法的类(或对象)
  2. apply 方法接收 compiler 实例
  3. 通过 compiler.hooks.xxx.tap/tapAsync/tapPromise 注册钩子
  4. 在合适的时机执行自定义逻辑

HMR 热更新原理

什么是 HMR

HMR(Hot Module Replacement,模块热替换)允许在运行时更新模块而无需完整刷新页面。它保留了应用的当前状态(如表单输入、滚动位置),只替换被修改的模块,极大提升了开发体验。

HMR 完整流程

                     HMR 热更新完整流程

 ┌────────────┐     ┌──────────────────┐     ┌──────────────┐
 │   编辑器    │     │  Webpack DevServer│     │   浏览器      │
 │            │     │    (Node.js)      │     │   (Client)   │
 └─────┬──────┘     └────────┬─────────┘     └──────┬───────┘
       │                     │                       │
  1. 修改文件               │                       │
       │                     │                       │
       ├────── 文件变化 ──────▶                       │
       │              (fs.watch)                     │
       │                     │                       │
       │            2. Webpack 重新编译               │
       │               增量编译变更模块               │
       │                     │                       │
       │            3. 编译完成,生成                  │
       │               新的 hash 值                  │
       │               manifest.json                 │
       │               updated chunk                 │
       │                     │                       │
       │                     ├── 4. WebSocket 推送 ──▶│
       │                     │   { type: 'hash',     │
       │                     │     hash: 'abc123' }  │
       │                     │                       │
       │                     │   { type: 'ok' }      │
       │                     │                       │
       │                     │              5. 客户端接收到
       │                     │                 新 hash
       │                     │                       │
       │                     │◀── 6. HTTP 请求 ──────┤
       │                     │   hot-update.json     │
       │                     │   (manifest 文件)     │
       │                     │                       │
       │                     ├── 7. 返回 manifest ──▶│
       │                     │   { c: {main: true},  │
       │                     │     r: [],            │
       │                     │     m: [] }           │
       │                     │                       │
       │                     │◀── 8. HTTP 请求 ──────┤
       │                     │   hot-update.js       │
       │                     │   (更新的 chunk)      │
       │                     │                       │
       │                     ├── 9. 返回更新代码 ───▶│
       │                     │                       │
       │                     │             10. HMR Runtime
       │                     │                 应用更新
       │                     │                 替换旧模块
       │                     │                 执行 accept 回调
       │                     │                       │
       │                     │             11. 页面局部更新
       │                     │                 不刷新页面
       │                     │                       │

流程详细拆解:

第 1-3 步:服务端编译

当文件发生变化,webpack-dev-server 监听到文件系统变更(通过 chokidar 库),触发 Webpack 重新编译。编译完成后产生两个关键文件:

  • [hash].hot-update.json(manifest):记录了哪些 chunk 发生了变化
  • [chunkId].[hash].hot-update.js:包含变更模块的新代码

第 4 步:WebSocket 通知

DevServer 通过 WebSocket 连接向客户端推送两条消息:

  • { type: 'hash', hash: 'newHash' }:新的编译 hash
  • { type: 'ok' }:通知编译完成

第 5-9 步:客户端获取更新

客户端 HMR Runtime 收到通知后,通过 HTTP 请求下载 manifest 和更新的 chunk 文件。这里使用 HTTP 而非 WebSocket 传输是因为更新内容可能较大,HTTP 更适合传输大文件。

第 10-11 步:模块替换

HMR Runtime 拿到更新代码后:

  1. 找到旧模块和新模块的对应关系
  2. 删除旧模块缓存
  3. 将新模块代码添加到 modules 对象
  4. 从上层模块开始向上冒泡,查找注册了 module.hot.accept 的模块
  5. 执行 accept 回调函数
  6. 如果冒泡到入口都没有找到 accept,则退化为完整页面刷新

module.hot.accept API

js
if (module.hot) {
  module.hot.accept('./component.js', () => {
    const newComponent = require('./component.js')
    document.getElementById('root').innerHTML = ''
    newComponent.render()
  })
}

module.hot.accept 有两种使用方式:

js
module.hot.accept('./dep', callback)

接受指定依赖模块的更新,当 ./dep 更新时执行 callback。

js
module.hot.accept()

接受自身的更新。当模块自身代码变化时,直接替换,不通知上层模块。

框架的 HMR 支持

在实际开发中,我们很少需要手写 module.hot.accept。主流框架都有对应的 HMR 支持:

框架HMR 方案特点
ReactReact Fast Refresh保留组件状态,支持 Hooks
Vuevue-loader + vue-hot-reload-api保留组件状态,支持模板/脚本/样式独立热更新
CSSstyle-loader / MiniCssExtractPlugin天然支持 HMR,修改样式即时生效

代码分割与优化

代码分割的三种方式

代码分割(Code Splitting)策略:

┌────────────────────────────────────────────────────────────────┐
│                         代码分割                                │
│                                                                │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────────┐    │
│  │   多入口分割   │  │   动态导入    │  │  SplitChunksPlugin │    │
│  │              │  │  import()    │  │   自动抽取公共模块   │    │
│  │  手动指定多个  │  │              │  │                   │    │
│  │  entry 入口   │  │  按需加载     │  │   vendor 抽取     │    │
│  │              │  │  路由懒加载   │  │   公共 chunk 抽取   │    │
│  └──────────────┘  └──────────────┘  └───────────────────┘    │
│                                                                │
│   适用:多页应用     适用:SPA路由     适用:所有场景            │
│                     按需加载组件                                │
└────────────────────────────────────────────────────────────────┘

方式一:多入口分割

js
module.exports = {
  entry: {
    app: './src/app.js',
    admin: './src/admin.js',
  },
  output: {
    filename: '[name].[contenthash:8].js',
  },
}

方式二:动态导入(import())

js
const Home = () => import('./views/Home.vue')
const About = () => import('./views/About.vue')

button.addEventListener('click', async () => {
  const { default: module } = await import('./heavy-module.js')
  module.init()
})

动态导入会让 Webpack 自动将被导入的模块分割为独立的 chunk。还可以通过魔法注释(Magic Comments)控制 chunk 名称和加载行为:

js
import(
  /* webpackChunkName: "dashboard" */
  /* webpackPrefetch: true */
  './views/Dashboard.vue'
)

常用魔法注释:

注释作用
webpackChunkName指定 chunk 名称
webpackPrefetch: true浏览器空闲时预加载(<link rel="prefetch">
webpackPreload: true与父 chunk 并行加载(<link rel="preload">
webpackMode设置解析动态导入的模式(lazy / eager / weak / lazy-once)

方式三:SplitChunksPlugin

Webpack 5 内置的 SplitChunksPlugin 用于自动抽取公共模块。

SplitChunks 配置详解

js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
        },
        common: {
          minChunks: 2,
          name: 'common',
          chunks: 'all',
          priority: 5,
          reuseExistingChunk: true,
        },
      },
    },
  },
}

核心配置项解析:

配置项默认值说明
chunks'async''all' 表示同步异步都分割,'async' 只分割异步,'initial' 只分割同步
minSize20000生成 chunk 的最小体积(单位 byte)
maxSize0超过此大小的 chunk 会尝试继续拆分
minChunks1模块至少被多少个 chunk 引用才会被抽取
maxAsyncRequests30按需加载时最大并行请求数
maxInitialRequests30入口点最大并行请求数
cacheGroups缓存组配置,定义分割规则

chunks 的三种取值对比:

chunks: 'async'(默认)       chunks: 'initial'          chunks: 'all'

只分割异步导入的模块          只分割同步导入的模块          同步 + 异步都分割
import() 产生的 chunk        entry 直接依赖             最全面的分割策略
                             的 node_modules

推荐度:★★☆               推荐度:★★☆              推荐度:★★★

Tree Shaking

Tree Shaking 是一种死代码消除(DCE)技术,它在打包时移除未使用的代码。其原理依赖于 ES Module 的静态结构特性——import 和 export 必须出现在模块顶层,不能在条件语句中使用。

Tree Shaking 原理:

源码:
┌──────────────────────────┐
│  export function add() {} │  ← 被使用 ✓
│  export function sub() {} │  ← 未使用 ✗
│  export function mul() {} │  ← 被使用 ✓
└──────────────────────────┘

入口引用:
import { add, mul } from './math'

打包结果(Tree Shaking 后):
┌──────────────────────────┐
│  function add() {}        │  ← 保留
│  function mul() {}        │  ← 保留
│  // sub 被移除            │
└──────────────────────────┘

Tree Shaking 的两个条件:

  1. 使用 ES Module:CommonJS 的 require() 是动态的,无法在编译时确定导入关系
  2. 标记 sideEffects:在 package.json 中声明模块是否有副作用
json
{
  "name": "my-library",
  "sideEffects": false
}

sideEffects 的三种取值:

"sideEffects": false
所有模块都没有副作用,可以安全地移除未使用的导出

"sideEffects": true
所有模块都有副作用(默认值),Webpack 不会移除任何模块

"sideEffects": ["*.css", "*.scss"]
只有指定的文件有副作用,其他文件可以安全 Tree Shaking

为什么 CSS 文件需要标记为有副作用?因为 import './style.css' 这种导入没有显式使用任何导出,如果不标记为有副作用,Tree Shaking 会认为它是"未使用的导入"而将其移除。

Webpack 5 中 Tree Shaking 的工作机制:

  1. 标记阶段(make):分析模块的导出,标记哪些导出被使用(usedExports
  2. 摇树阶段(seal/optimize):Terser 压缩时移除未使用的代码
js
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,
    minimize: true,
  },
}

Scope Hoisting(作用域提升)

Scope Hoisting 将所有模块的代码按照引用顺序放在一个函数作用域里,适当重命名以防止变量冲突。这样可以减少函数声明和闭包开销,减小代码体积并提升运行速度。

未启用 Scope Hoisting:

(function(modules) {
  // Webpack 运行时
})({
  "./src/a.js": function(module, exports) {
    // a 模块代码
    module.exports = 'a'
  },
  "./src/b.js": function(module, exports, require) {
    // b 模块代码(每个模块一个函数闭包)
    var a = require("./src/a.js")
    module.exports = a + 'b'
  }
})


启用 Scope Hoisting 后:

(function() {
  // a 模块代码
  var a = 'a'
  // b 模块代码(合并到同一作用域)
  var b = a + 'b'
})()

在 Webpack 5 的 production 模式下默认启用(通过 ModuleConcatenationPlugin):

js
module.exports = {
  optimization: {
    concatenateModules: true,
  },
}

Scope Hoisting 的限制:只对 ESM 模块生效,CommonJS 模块无法被合并。因此在项目中尽量使用 ESM 语法。

持久化缓存

持久化缓存的目标是:在代码没有变化时,用户浏览器可以直接使用缓存,避免重复下载。核心是使用 contenthash——文件内容变化时 hash 才变化。

js
module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
  },
}

为了让 contenthash 稳定,还需要固定 moduleId 和 chunkId:

js
module.exports = {
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
  },
}
策略说明使用场景
moduleIds: 'deterministic'根据模块路径生成稳定的短 hash生产环境(Webpack 5 默认)
chunkIds: 'deterministic'根据 chunk 内容生成稳定的短 hash生产环境(Webpack 5 默认)
moduleIds: 'named'使用模块路径作为 ID开发环境,便于调试

推荐的缓存策略架构:

┌──────────────────────────────────────────────────────────┐
│                   缓存友好的分包策略                       │
│                                                          │
│  runtime.js      ← Webpack 运行时,很少变化               │
│  [contenthash]     可设置长期缓存                         │
│                                                          │
│  vendor.js       ← node_modules 第三方库,不频繁变化      │
│  [contenthash]     可设置长期缓存                         │
│                                                          │
│  common.js       ← 业务公共模块,偶尔变化                  │
│  [contenthash]     中期缓存                               │
│                                                          │
│  app.js          ← 业务代码,频繁变化                      │
│  [contenthash]     每次发布都会变                          │
│                                                          │
└──────────────────────────────────────────────────────────┘

抽离运行时代码的配置:

js
module.exports = {
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
}

性能优化

构建速度优化

1. 持久化文件缓存(Webpack 5)

Webpack 5 引入了持久化缓存,将编译结果缓存到文件系统,二次构建速度可提升 80% 以上。

js
module.exports = {
  cache: {
    type: 'filesystem',
    buildDependencies: {
      config: [__filename],
    },
    version: '1.0',
  },
}
首次构建:30s
二次构建(命中缓存):3s

提升幅度:~90%
配置说明
type: 'filesystem'使用文件系统缓存(持久化到磁盘)
type: 'memory'使用内存缓存(默认,重启后失效)
buildDependencies.config当配置文件变化时自动失效缓存
version手动管理缓存版本号

2. 多线程构建(thread-loader)

将耗时的 Loader 放在子线程中运行,利用多核 CPU 加速构建。

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: require('os').cpus().length - 1,
            },
          },
          'babel-loader',
        ],
      },
    ],
  },
}

注意事项:

  • thread-loader 有 600ms 左右的启动开销,只适用于编译耗时的 Loader
  • 子线程中无法使用 this.emitFilethis.addDependency 等 API
  • 不要在小项目中使用,启动开销可能大于收益

3. 缩小构建范围

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve(__dirname, 'src'),
        exclude: /node_modules/,
        use: 'babel-loader',
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
    modules: [path.resolve(__dirname, 'node_modules')],
  },
}

优化手段汇总:

手段说明
include/exclude限制 Loader 处理范围
resolve.extensions减少文件查找范围,高频后缀放前面
resolve.alias避免深层路径查找
resolve.modules指定模块查找目录,避免逐层向上查找
noParse跳过对已打包库的解析(如 jQuery)
js
module.exports = {
  module: {
    noParse: /jquery|lodash/,
  },
}

4. 减少不必要的 Plugin

每个 Plugin 都会增加构建开销,确保在开发环境中移除不必要的 Plugin(如 CSS 压缩、JS 压缩等)。

构建速度优化清单:

┌────────────────────────────────────────────────┐
│  ✅ cache.type: 'filesystem'   持久化缓存       │
│  ✅ thread-loader              多线程编译       │
│  ✅ include/exclude            缩小处理范围     │
│  ✅ resolve.extensions         减少查找次数     │
│  ✅ resolve.alias              路径别名         │
│  ✅ noParse                    跳过解析         │
│  ✅ devtool: 'eval-cheap-module-source-map'     │
│                                开发环境用快速SM  │
│  ✅ 移除不必要的 Plugin         减少钩子开销     │
└────────────────────────────────────────────────┘

产物体积优化

1. Tree Shaking

确保使用 ESM 语法并正确配置 sideEffects(详见代码分割章节)。

2. 代码压缩

js
const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true,
        terserOptions: {
          compress: {
            drop_console: true,
            drop_debugger: true,
          },
        },
      }),
      new CssMinimizerPlugin(),
    ],
  },
}

3. 按需加载

js
const routes = [
  {
    path: '/dashboard',
    component: () => import(
      /* webpackChunkName: "dashboard" */
      './views/Dashboard.vue'
    ),
  },
  {
    path: '/settings',
    component: () => import(
      /* webpackChunkName: "settings" */
      './views/Settings.vue'
    ),
  },
]

4. Externals 排除大型库

将大型库通过 CDN 引入,不打包到 bundle 中:

js
module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
    lodash: '_',
  },
}

在 HTML 模板中通过 <script> 标签引入 CDN 链接:

html
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>

5. 图片资源优化

Webpack 5 内置了 Asset Modules,替代了 file-loader 和 url-loader:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif|svg)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 8 * 1024,
          },
        },
        generator: {
          filename: 'images/[name].[contenthash:8][ext]',
        },
      },
    ],
  },
}

Asset Modules 的四种类型:

类型等效 Loader行为
asset/resourcefile-loader输出文件,返回 URL
asset/inlineurl-loader转为 base64 内联
asset/sourceraw-loader导出文件原始内容
asset自动选择根据 maxSize 阈值自动选择 resource 或 inline

构建分析工具

webpack-bundle-analyzer

可视化展示 bundle 的模块组成和体积分布。

js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
      reportFilename: 'bundle-report.html',
    }),
  ],
}

speed-measure-webpack-plugin

测量每个 Loader 和 Plugin 的执行耗时。

js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()

module.exports = smp.wrap({
  module: {
    rules: [
      { test: /\.js$/, use: 'babel-loader' },
    ],
  },
})
输出示例:

 SMP  ⏱
General output time took 12.5 secs

 SMP  ⏱  Plugins
HtmlWebpackPlugin took 0.32 secs
MiniCssExtractPlugin took 0.18 secs

 SMP  ⏱  Loaders
babel-loader took 8.6 secs
  modules with babel-loader: 245
css-loader took 2.1 secs
  modules with css-loader: 38
产物体积优化清单:

┌────────────────────────────────────────────────┐
│  ✅ Tree Shaking       移除未使用代码           │
│  ✅ Scope Hoisting     合并模块作用域           │
│  ✅ Code Splitting     按需加载                │
│  ✅ TerserPlugin       JS 压缩                │
│  ✅ CssMinimizer       CSS 压缩               │
│  ✅ externals          排除大型库              │
│  ✅ Asset Modules      图片资源优化            │
│  ✅ contenthash        持久化缓存              │
│  ✅ bundle-analyzer    可视化分析              │
└────────────────────────────────────────────────┘

Source Map

Source Map 是一个映射文件,它将编译、打包、压缩后的代码映射回源代码,方便开发者调试。

devtool 配置对比

Webpack 提供了 20 多种 devtool 配置,核心维度是构建速度调试质量的平衡。

devtool构建速度重建速度生产环境质量
(none)最快最快无 Source Map
eval最快生成后的代码
eval-cheap-source-map较快转换后代码(仅行映射)
eval-cheap-module-source-map中等原始源代码(仅行映射)
eval-source-map中等原始源代码
cheap-source-map中等转换后代码(仅行映射)
source-map最慢最慢原始源代码
hidden-source-map最慢最慢原始源代码(不暴露引用)
nosources-source-map最慢最慢仅错误栈信息

推荐配置:

js
module.exports = (env) => ({
  devtool: env.production
    ? 'hidden-source-map'
    : 'eval-cheap-module-source-map',
})
开发环境推荐:eval-cheap-module-source-map
  理由:构建快 + 重建快 + 能映射到原始源码

生产环境推荐:hidden-source-map 或 nosources-source-map
  理由:生成完整 Source Map 但不暴露给用户
  将 .map 文件上传到错误监控系统(如 Sentry)

Webpack 5 新特性

Webpack 5 带来了许多重要的改进,以下是核心变更:

1. 持久化缓存

前文已详细介绍,使用 cache.type: 'filesystem' 将编译结果持久化到磁盘。

2. Module Federation(模块联邦)

允许多个独立构建的应用在运行时共享模块,是微前端架构的重要基础设施。

Module Federation 架构:

┌──────────────────┐     ┌──────────────────┐
│   App A (Host)   │     │  App B (Remote)  │
│                  │     │                  │
│  import Button   │     │  expose Button   │
│  from 'appB'     │◀───▶│  from ./Button   │
│                  │     │                  │
└──────────────────┘     └──────────────────┘
        ↕                        ↕
   独立构建部署              独立构建部署
   加载 appB 的             提供 Button
   Button 组件              给其他应用
js
const { ModuleFederationPlugin } = require('webpack').container

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'appB',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
}

Host 端配置:

js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'appA',
      remotes: {
        appB: 'appB@http://localhost:3001/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
}

3. Asset Modules

内置资源模块类型,替代 file-loader、url-loader、raw-loader(前文已详细介绍)。

4. 不再自动 Polyfill Node.js 核心模块

Webpack 5 不再自动为 Node.js 核心模块提供 polyfill。如果项目中使用了 pathbuffer 等 Node 模块,需要手动安装对应的 polyfill 或设置 resolve.fallback

js
module.exports = {
  resolve: {
    fallback: {
      path: require.resolve('path-browserify'),
      buffer: require.resolve('buffer/'),
      crypto: false,
    },
  },
}

5. 更好的 Tree Shaking

  • 支持嵌套的 Tree Shaking(export * from './module' 中的未使用导出也能被移除)
  • 支持 CommonJS 的 Tree Shaking(有限支持)
  • 支持 sideEffects 更精细的配置

Webpack 4 vs Webpack 5 对比

特性Webpack 4Webpack 5
缓存仅内存缓存支持持久化文件系统缓存
Tree Shaking基础支持嵌套 + CommonJS 支持
资源模块需要 file-loader 等内置 Asset Modules
Node Polyfill自动注入不再自动注入
Module Federation不支持内置支持
moduleIds/chunkIdshasheddeterministic
代码生成ES5 only支持 ES6+ 输出
最低 Node 版本Node 6Node 10.13+

面试高频问题

Q1:Webpack 的构建流程是怎样的?

回答思路:分四个阶段回答——初始化、编译(make)、生成(seal)、输出(emit)。

初始化阶段,Webpack 读取并合并配置参数,创建 Compiler 对象,注册所有 Plugin。编译阶段(make),从 Entry 出发,调用 Loader 对模块进行转译,然后通过 acorn 解析为 AST,找出模块依赖,递归处理所有模块,构建完整的 ModuleGraph。生成阶段(seal),将 Module 分配到 Chunk,执行 SplitChunks 优化和 Tree Shaking,为每个 Chunk 生成最终代码。输出阶段(emit),将最终的 Assets 写入文件系统。

面试追问:Compiler 和 Compilation 的区别是什么? → Compiler 全局唯一,贯穿整个 Webpack 生命周期;Compilation 代表一次编译过程,在 watch 模式下每次文件变化都会创建新的 Compilation。

Q2:Loader 和 Plugin 的区别是什么?

回答思路

Loader 本质是函数转换器,用于将非 JS 文件转换为 Webpack 能处理的模块,工作在模块编译阶段。Plugin 本质是基于 Tapable 的事件监听器,可以监听 Webpack 构建全生命周期的钩子,执行更广泛的任务——从代码优化到资源管理到环境变量注入。

简单记忆:Loader 处理文件转换,Plugin 处理构建流程。

面试追问:Loader 的执行顺序是什么?为什么? → 从右到左、从下到上,因为 Webpack 采用函数组合 compose 的方式 f(g(h(x)))

Q3:Tree Shaking 的原理是什么?为什么需要 ESM?

回答思路

Tree Shaking 依赖 ES Module 的静态结构特性。ESM 的 import/export 必须出现在模块顶层,不能在条件语句中使用,这使得 Webpack 可以在编译时静态分析出哪些导出被使用、哪些未被使用。在编译阶段,Webpack 会标记每个导出的使用状态(usedExports),在优化阶段由 Terser 移除未使用的代码。

CommonJS 的 require() 是动态的,可以出现在 if 语句中,模块的导出也是动态赋值的对象,无法在编译时确定引用关系,所以不支持(或仅有限支持)Tree Shaking。

面试追问:sideEffects 字段的作用? → 告诉 Webpack 哪些模块有副作用,没有副作用的模块如果未被使用可以直接移除整个模块,而不仅仅是移除未使用的导出。

Q4:HMR 热更新的原理是什么?

回答思路

文件修改后,Webpack 重新编译变更的模块,生成新的 hash、manifest 文件和更新的 chunk。DevServer 通过 WebSocket 推送新的 hash 给浏览器。浏览器中的 HMR Runtime 收到通知后,通过 HTTP 请求拉取 manifest(知道哪些 chunk 变了)和更新的 chunk(新的模块代码)。拿到新代码后,HMR Runtime 替换旧模块,向上冒泡查找 module.hot.accept 回调并执行。如果冒泡到入口都没有找到 accept 处理,则退化为完整页面刷新。

面试追问:为什么不通过 WebSocket 直接传输更新代码? → WebSocket 更适合小数据量的实时通知,更新的 chunk 可能体积较大,HTTP 更适合大文件传输,且可利用 HTTP 缓存机制。

Q5:Webpack 有哪些常见的性能优化手段?

回答思路:分构建速度和产物体积两个维度。

构建速度:持久化文件缓存(cache.type: 'filesystem')、多线程编译(thread-loader)、缩小构建范围(include/exclude)、优化 resolve 配置、noParse 跳过已打包库。

产物体积:Tree Shaking + sideEffects、代码分割(SplitChunks + 动态导入)、代码压缩(TerserPlugin)、externals 排除大型库、Scope Hoisting 合并模块作用域、使用 contenthash 做持久化缓存。

分析工具:webpack-bundle-analyzer 分析体积、speed-measure-webpack-plugin 分析构建耗时。

Q6:Webpack 的 Chunk 是如何生成的?

回答思路

Chunk 有三种产生来源:一是 Entry 入口,每个 entry 默认产生一个 chunk;二是动态导入 import() 语法,每个动态导入点产生一个异步 chunk;三是 SplitChunksPlugin 优化,根据配置规则将公共模块抽取为独立的 chunk。

在 seal 阶段,Webpack 先根据 Entry 创建初始 chunk,然后遍历 ModuleGraph,将模块分配到对应的 chunk,再执行 SplitChunks 优化逻辑,最终形成 ChunkGraph。

面试追问:SplitChunks 的 chunks: 'all' / 'async' / 'initial' 有什么区别? → async 只分割异步 chunk(默认值),initial 只分割同步 chunk,all 同步异步都分割(推荐配置)。

Q7:Module Federation 解决了什么问题?

回答思路

Module Federation 解决了微前端架构中模块共享的问题。在传统方案中,多个应用之间共享代码只能通过 npm 包的方式,更新一个共享组件需要所有消费方重新安装依赖并构建部署。

Module Federation 允许多个独立构建的应用在运行时动态共享模块。Remote 应用暴露模块,Host 应用在运行时加载 Remote 的模块,无需重新构建。同时通过 shared 配置避免公共依赖(如 React)被重复加载。

面试追问:Module Federation 和 npm 包共享相比有什么优势? → 运行时加载,无需重新构建消费方;Remote 端独立部署更新,Host 端实时获取最新版本;减少公共依赖的重复打包。

Q8:Webpack 5 相比 Webpack 4 有哪些重要变化?

回答思路

五大核心变化:(1)持久化缓存——支持文件系统缓存,大幅提升二次构建速度;(2)Module Federation——运行时模块共享,支持微前端架构;(3)Asset Modules——内置资源处理,替代 file-loader/url-loader/raw-loader;(4)不再自动 polyfill Node.js 核心模块——减小产物体积,需手动配置 fallback;(5)更好的 Tree Shaking——支持嵌套导出的 Tree Shaking 和有限的 CommonJS Tree Shaking。


延伸阅读

用心学习,用代码说话 💻