主题
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 │
└───────────┘
依赖图 BundleWebpack 之所以强大,在于它将一切资源——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 的产生来源有三种:
- Entry 入口:每个 entry 会产生至少一个 chunk
- 动态导入:
import()语法会产生新的 chunk - 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-loader | ES6+ 转 ES5 | JSX/ES6+ → ES5 |
ts-loader | TypeScript 编译 | .ts/.tsx → .js |
css-loader | 解析 CSS 中的 @import 和 url() | .css → JS 模块 |
style-loader | 将 CSS 注入 DOM | JS 模块 → <style> 标签 |
postcss-loader | CSS 后处理(autoprefixer 等) | .css → .css |
sass-loader | Sass/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.query | Loader 的 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
└── doneTapable 核心钩子类型
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 的区别
| 对比项 | Compiler | Compilation |
|---|---|---|
| 生命周期 | Webpack 启动到结束,全局唯一 | 每次编译(包括 watch 模式下的重新编译)创建一个新的 |
| 职责 | 管理整个构建流程、Plugin 注册 | 管理一次具体的编译过程中的模块、依赖、Chunk |
| 包含内容 | Webpack 配置、Plugin 实例、文件系统 | 模块(modules)、chunks、assets |
| 获取方式 | plugin.apply(compiler) | compiler.hooks.compilation |
| 典型钩子 | run、emit、done | buildModule、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 目录下的静态资源) |
TerserWebpackPlugin | JS 压缩(Webpack 5 内置,production 模式自动启用) |
CssMinimizerWebpackPlugin | CSS 压缩 |
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 = FileListPluginWebpack 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 开发的核心要点:
- Plugin 是一个具有
apply方法的类(或对象) apply方法接收compiler实例- 通过
compiler.hooks.xxx.tap/tapAsync/tapPromise注册钩子 - 在合适的时机执行自定义逻辑
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 拿到更新代码后:
- 找到旧模块和新模块的对应关系
- 删除旧模块缓存
- 将新模块代码添加到 modules 对象
- 从上层模块开始向上冒泡,查找注册了
module.hot.accept的模块 - 执行 accept 回调函数
- 如果冒泡到入口都没有找到 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 方案 | 特点 |
|---|---|---|
| React | React Fast Refresh | 保留组件状态,支持 Hooks |
| Vue | vue-loader + vue-hot-reload-api | 保留组件状态,支持模板/脚本/样式独立热更新 |
| CSS | style-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' 只分割同步 |
minSize | 20000 | 生成 chunk 的最小体积(单位 byte) |
maxSize | 0 | 超过此大小的 chunk 会尝试继续拆分 |
minChunks | 1 | 模块至少被多少个 chunk 引用才会被抽取 |
maxAsyncRequests | 30 | 按需加载时最大并行请求数 |
maxInitialRequests | 30 | 入口点最大并行请求数 |
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 的两个条件:
- 使用 ES Module:CommonJS 的
require()是动态的,无法在编译时确定导入关系 - 标记 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 的工作机制:
- 标记阶段(make):分析模块的导出,标记哪些导出被使用(
usedExports) - 摇树阶段(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.emitFile、this.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/resource | file-loader | 输出文件,返回 URL |
asset/inline | url-loader | 转为 base64 内联 |
asset/source | raw-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。如果项目中使用了 path、buffer 等 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 4 | Webpack 5 |
|---|---|---|
| 缓存 | 仅内存缓存 | 支持持久化文件系统缓存 |
| Tree Shaking | 基础支持 | 嵌套 + CommonJS 支持 |
| 资源模块 | 需要 file-loader 等 | 内置 Asset Modules |
| Node Polyfill | 自动注入 | 不再自动注入 |
| Module Federation | 不支持 | 内置支持 |
| moduleIds/chunkIds | hashed | deterministic |
| 代码生成 | ES5 only | 支持 ES6+ 输出 |
| 最低 Node 版本 | Node 6 | Node 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。