工程化篇 | Webpack与Rspack
前言
如果你做前端超过两年,大概率绕不开 Webpack。哪怕你现在已经用上了 Vite、Turbopack 或者其他更”现代”的工具,面试里依然会被问到 Webpack 的核心概念——因为它几乎定义了前端构建工具的范式。
但说实话,很多人对 Webpack 的理解停留在”改配置”的层面:出了问题就 Google 一个 loader 或 plugin,复制粘贴到 webpack.config.js 里,能跑就行。真要面试官追问:
- entry、output、loader、plugin 之间的关系是什么?
- 构建流程从头到尾经历了哪些阶段?
- HMR 是怎么做到”不刷新页面就更新模块”的?
- Rspack 为什么能比 Webpack 快 10 倍?它和 Webpack 是什么关系?
就开始卡壳了。
本章,我们就从 Webpack 的核心概念讲起,再拆解构建流程和 HMR 原理,最后聊聊 Rspack 的设计思路和迁移策略。读完之后,你对”前端构建”这件事会有一个清晰的认知框架。
诊断自测
在开始正文之前,先用几道题测测你目前的理解程度。答不上来没关系,读完全文再回来看。
Q1:Webpack 中 loader 和 plugin 的区别是什么?能不能用 loader 做 plugin 的事?
点击查看答案
Loader 负责文件转换——把非 JS 文件(CSS、图片、TS 等)转成 Webpack 能理解的模块。它工作在模块级别,处理的是单个文件的内容。
Plugin 负责流程扩展——通过挂载到 Webpack 的生命周期钩子上,可以介入打包的任意阶段,做代码压缩、资源注入、文件清理等事情。
两者职责不同,loader 无法替代 plugin。Loader 只能对单个文件做内容变换,而 plugin 可以访问整个编译对象,操作 chunk、asset 等全局概念。
Q2:Webpack 的 HMR(热模块替换)和浏览器的”自动刷新”有什么本质区别?
点击查看答案
自动刷新(Live Reload)是整页重载——页面状态(表单输入、滚动位置、组件状态等)全部丢失。HMR 是模块级别的替换——只替换修改过的模块,页面其余部分保持不变,状态得以保留。HMR 通过 WebSocket 通知浏览器哪些模块发生了变化,浏览器端的 HMR runtime 负责拉取新模块并替换旧模块。
Q3:Rspack 号称和 Webpack 兼容,但它是用 Rust 写的。它到底是”另一个 Webpack”还是”Webpack 的 Rust 移植版”?
点击查看答案
Rspack 既不是”另一个 Webpack”,也不是完全的”移植版”。它是一个用 Rust 重新实现了 Webpack 核心构建引擎的打包工具,同时提供了与 Webpack 高度兼容的配置接口和插件/loader 体系。它复用了 Webpack 的设计理念(entry/output/loader/plugin),但在底层实现上完全重写,利用 Rust 的性能优势和并行能力来获得数量级的速度提升。大部分 Webpack 的 loader 和 plugin 可以直接在 Rspack 中使用,但涉及深度依赖 Webpack 内部 API 的插件可能需要适配。
一、Webpack 核心概念:五大支柱
要理解 Webpack,先搞清楚它的五个核心概念。它们就像一条流水线上的五个环节,缺一不可。
1.1 Entry(入口)
Entry 告诉 Webpack:“从这里开始,顺着 import / require 把所有依赖找出来。”
// 单入口
module.exports = {
entry: './src/index.js'
};
// 多入口
module.exports = {
entry: {
app: './src/app.js',
admin: './src/admin.js'
}
};
Webpack 会从 entry 出发,递归地构建出一张依赖关系图(Dependency Graph)。这张图是整个打包过程的基础——图里有的模块才会被处理,图外的文件不会被打包进来。
1.2 Output(输出)
Output 告诉 Webpack:“打包好的文件放到哪里,文件名叫什么。”
const path = require('path');
module.exports = {
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
clean: true // 每次构建前清理 dist 目录
}
};
[name] 对应 entry 的 key,[contenthash] 是基于文件内容的哈希值——内容不变,文件名不变,浏览器可以放心地做长缓存。
1.3 Loader(加载器)
Webpack 本身只认识 JavaScript 和 JSON。其他类型的文件——CSS、图片、TypeScript、Sass——怎么办?这就是 Loader 的活。
Loader 的本质是一个函数,接收源文件内容作为输入,输出转换后的内容。
module.exports = {
module: {
rules: [
{
test: /\.css$/, // 匹配 .css 文件
use: ['style-loader', 'css-loader'] // 从右往左执行
},
{
test: /\.ts$/,
use: 'ts-loader'
},
{
test: /\.(png|jpg|gif)$/,
type: 'asset/resource' // Webpack 5 内置的资源模块
}
]
}
};
关键细节:loader 的执行顺序是从右往左(从下往上)。 上面 CSS 的例子中,先执行 css-loader(把 CSS 解析成 JS 模块),再执行 style-loader(把 CSS 注入到 DOM 的 <style> 标签中)。
为什么从右往左?因为 Webpack 内部使用了函数组合的模式——类似 compose(f, g)(x) 等于 f(g(x))。
1.4 Plugin(插件)
如果说 Loader 是”文件翻译官”,那 Plugin 就是”流程管理者”。Plugin 可以介入 Webpack 构建的任意阶段,做 Loader 做不到的事情。
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html' // 自动生成 HTML 并注入打包后的 JS
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css' // 把 CSS 提取为独立文件
})
]
};
Plugin 的原理: Webpack 在编译过程中会触发一系列生命周期钩子(基于 Tapable 库),Plugin 通过 apply 方法注册到这些钩子上,在特定时机执行自定义逻辑。
一个最简化的 Plugin 结构:
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// compilation.assets 里是所有即将输出的文件
console.log('即将输出的文件:', Object.keys(compilation.assets));
callback();
});
}
}
1.5 Mode(模式)
Webpack 5 要求你必须指定 mode,它决定了一系列内置优化:
| mode | 效果 |
|---|---|
development | 不压缩、启用 source map、启用模块热替换 |
production | 代码压缩(Terser)、Tree Shaking、作用域提升 |
none | 不启用任何内置优化 |
module.exports = {
mode: 'production'
};
二、Webpack 构建流程:从入口到产物
面试中经常被问到”Webpack 的打包流程是怎样的”。我们把它拆成三个大阶段:
2.1 初始化阶段
- 读取配置:合并命令行参数、配置文件、默认配置
- 创建 Compiler:Compiler 是整个构建的”总指挥”,全局唯一
- 注册插件:依次调用每个 Plugin 的
apply方法,把它们挂载到 Compiler 的钩子上
2.2 编译阶段(make)
- 从 Entry 出发:Compiler 创建 Compilation 对象(代表一次编译),从入口文件开始递归处理
- 解析模块:遇到
import/require,用对应的 Loader 转换文件内容,然后解析 AST,找出该模块的依赖 - 递归构建:对每个依赖重复步骤 5,直到所有模块都处理完毕
- 生成依赖图:所有模块和它们之间的依赖关系形成一张完整的图
2.3 输出阶段(emit)
- 生成 Chunk:根据 entry 和动态
import()等规则,把依赖图切分成一个个 Chunk - 渲染 Chunk:把每个 Chunk 中的模块”组装”成最终的代码(加上 Webpack 的运行时)
- 输出文件:将最终的 bundle 写入文件系统(dist 目录)
Entry → [Loader 转换] → [AST 解析] → [递归依赖] → 依赖图 → Chunks → Bundle → 输出
理解这个流程有什么用?它帮你定位问题。 比如编译慢,问题可能出在 Loader 转换阶段(可以用 thread-loader 并行化);产物太大,问题出在 Chunk 生成阶段(可以用 splitChunks 做代码分割)。
三、HMR(热模块替换)原理
HMR 是 Webpack 最让人”哇塞”的特性之一——改了代码,页面不刷新就能看到效果,而且组件状态还在。它是怎么做到的?
3.1 整体架构
HMR 涉及三个角色:
- Webpack Compiler:负责监听文件变化,重新编译变更的模块
- Dev Server:启动一个 HTTP 服务和一个 WebSocket 服务
- HMR Runtime:注入到浏览器端 bundle 中的一段运行时代码
3.2 工作流程
文件修改 → Compiler 重新编译 → 生成更新清单(manifest)和更新模块(update chunk)
→ Dev Server 通过 WebSocket 通知浏览器"有模块更新了"
→ 浏览器端 HMR Runtime 通过 HTTP 请求拉取更新
→ HMR Runtime 替换旧模块,执行 accept 回调
详细步骤:
- 你修改了
App.vue并保存 - Webpack 的**文件监听(watch)**机制检测到文件变化
- Compiler 只重新编译变更的模块及其受影响的依赖,生成两个文件:
- manifest(JSON):记录哪些 chunk 发生了变化
- update chunk(JS):包含更新后的模块代码
- Dev Server 通过 WebSocket 发送一条消息:
{ type: 'hash', hash: 'abc123' } - 浏览器端的 HMR Runtime 收到消息,发起 HTTP 请求拉取 manifest 和 update chunk
- HMR Runtime 用新模块替换旧模块,并执行通过
module.hot.accept注册的回调
3.3 module.hot.accept
这是 HMR 的关键 API。一个模块如果想支持热替换,需要声明自己”接受更新”:
if (module.hot) {
module.hot.accept('./App.js', () => {
// 当 App.js 更新时,执行这个回调
const NextApp = require('./App.js').default;
render(NextApp);
});
}
如果一个模块没有 accept 自身或其依赖的更新,更新会”冒泡”到父模块。 如果一路冒泡到入口模块都没人 accept,HMR 就会回退为整页刷新。
在 React 和 Vue 项目中,你通常不需要手动写 module.hot.accept——框架的 loader/plugin(如 react-refresh-webpack-plugin、vue-loader)已经帮你处理了。
四、性能优化手段
Webpack 的构建速度和产物体积是面试中的热门话题。这里列举最常用的优化手段:
4.1 提升构建速度
| 手段 | 原理 |
|---|---|
cache: { type: 'filesystem' } | 持久化缓存,二次构建只处理变更模块 |
thread-loader | 把耗时的 loader(如 babel-loader)放到 worker 线程池中并行处理 |
resolve.extensions 精简 | 减少文件后缀尝试次数 |
externals | 把 React、Vue 等大库排除,用 CDN 引入 |
noParse | 跳过对已知无依赖的大文件(如 jQuery)的解析 |
4.2 减小产物体积
| 手段 | 原理 |
|---|---|
| Tree Shaking | 基于 ESM 的静态分析,删除未使用的导出 |
| Code Splitting | splitChunks + 动态 import() 按需加载 |
| 压缩 | Terser(JS)、CssMinimizerPlugin(CSS) |
| 作用域提升(Scope Hoisting) | ModuleConcatenationPlugin,减少模块包装函数 |
| 图片压缩 | image-minimizer-webpack-plugin |
4.3 splitChunks 配置示例
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
},
common: {
minChunks: 2, // 至少被 2 个 chunk 引用
name: 'common',
priority: 5,
reuseExistingChunk: true
}
}
}
}
};
五、Rspack:Rust 驱动的 Webpack 替代方案
5.1 为什么需要 Rspack?
Webpack 的性能瓶颈主要在于:
- JavaScript 单线程:构建过程大量的 AST 解析、代码生成都在单线程上执行
- loader 串行执行:即使有
thread-loader,也只是把个别 loader 并行化 - 启动开销大:大项目冷启动可能需要几十秒甚至几分钟
Rspack(由字节跳动开源)选择用 Rust 重写 Webpack 的核心构建引擎,从底层解决性能问题。
5.2 Rspack 的核心优势
- Rust 原生性能:解析、转换、代码生成全部用 Rust 实现,比 JS 快一个数量级
- 原生并行化:充分利用多核 CPU,模块解析和代码生成可以并行执行
- 内置常用功能:SWC 转译(替代 babel-loader)、CSS 处理(替代 css-loader + mini-css-extract-plugin)等常用能力内置,减少 loader 开销
- 增量编译:更细粒度的缓存和增量策略
5.3 与 Webpack 的兼容性
Rspack 的一大卖点是配置兼容 Webpack:
// rspack.config.js —— 配置格式基本和 webpack.config.js 一致
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
path: __dirname + '/dist'
},
module: {
rules: [
{
test: /\.css$/,
type: 'css' // Rspack 内置 CSS 支持,不需要 css-loader
}
]
}
};
兼容到什么程度?
- 大部分 Webpack loader 可以直接使用(因为 loader 协议兼容)
- 常用 plugin(如 HtmlWebpackPlugin)有对应的 Rspack 内置实现或社区适配版
- 但深度依赖 Webpack 内部 API(如 Compilation 的私有属性)的 plugin 可能不兼容
5.4 迁移策略
从 Webpack 迁移到 Rspack 的推荐步骤:
- 评估兼容性:列出项目中用到的 loader 和 plugin,查阅 Rspack 兼容性文档
- 替换内置功能:把
babel-loader替换为 Rspack 内置的 SWC 转译,把css-loader+style-loader替换为 Rspack 内置 CSS 处理 - 逐步替换:先把配置文件从
webpack.config.js改为rspack.config.js,保留尽可能多的原有配置 - 处理不兼容的 Plugin:寻找 Rspack 社区的替代方案,或用 Rspack 的内置功能替代
- 验证产物一致性:对比迁移前后的构建产物,确保功能和体积没有退化
# 安装 Rspack
npm install @rspack/core @rspack/cli -D
# 运行构建
npx rspack build
常见误区
误区一:“Webpack 配置越多越好,优化就是堆插件”
很多人一上来就往配置里加各种优化插件,结果反而让构建变慢。正确的做法是先分析瓶颈(用 speed-measure-webpack-plugin 测量各阶段耗时,用 webpack-bundle-analyzer 分析产物体积),再有针对性地优化。没有度量就没有优化。
误区二:“Tree Shaking 能自动删掉所有没用的代码”
Tree Shaking 只对 ESM(import/export) 有效,对 CommonJS(require/module.exports)无效。而且如果模块有副作用(Side Effects),Webpack 不敢删——万一那个”没被引用”的代码里有全局注册逻辑呢?需要在 package.json 中配置 "sideEffects": false 来告诉 Webpack”这个包没有副作用,可以放心摇树”。
误区三:“Rspack 能完全替代 Webpack,直接切就行”
虽然 Rspack 兼容性很高,但不是 100%。一些依赖 Webpack 内部实现细节的 plugin 可能无法直接使用。而且 Rspack 的生态比 Webpack 小得多,遇到问题时可用的社区资源更少。迁移前一定要做充分的评估和测试。
误区四:“loader 和 plugin 的区别只是用法不同”
不是”用法”不同,是职责不同。Loader 是模块级别的文件转换器,只关心单个文件的内容变换。Plugin 是构建级别的流程扩展器,可以访问整个 Compilation 对象,操作所有模块、chunk 和资产。把它们混淆会导致你在错误的场景选择错误的工具。
小结
本章我们从 Webpack 的五大核心概念(Entry、Output、Loader、Plugin、Mode)出发,梳理了完整的构建流程(初始化 → 编译 → 输出),深入讲解了 HMR 的工作原理,并介绍了 Rspack 的设计思路和迁移策略。
核心要点
- Loader 做转换,Plugin 做扩展:Loader 处理单个文件,Plugin 介入整个构建流程
- 构建三阶段:初始化(读取配置、创建 Compiler)→ 编译(从 Entry 递归构建依赖图)→ 输出(生成 Chunk → 写入文件)
- HMR 三角色:Compiler(重新编译)→ Dev Server(WebSocket 通知)→ HMR Runtime(模块替换)
- Rspack 的核心价值:Rust 原生性能 + 并行化 + Webpack 配置兼容
- 优化要先度量:用工具分析瓶颈,再有针对性地优化
本章思维导图
- Webpack 核心概念
- Entry:构建的起点,生成依赖图的入口
- Output:产物的输出路径和文件名规则
- Loader:文件转换器,从右往左执行
- Plugin:流程扩展器,挂载到生命周期钩子
- Mode:development / production / none
- 构建流程
- 初始化:读取配置 → 创建 Compiler → 注册 Plugin
- 编译:Entry → Loader 转换 → AST 解析 → 递归依赖 → 依赖图
- 输出:依赖图 → Chunks → Bundle → 写入文件
- HMR 原理
- Compiler 监听文件变化并重新编译
- Dev Server 通过 WebSocket 通知浏览器
- HMR Runtime 拉取更新并替换模块
- module.hot.accept 声明"接受更新"
- 性能优化
- 构建速度:filesystem cache、thread-loader、externals
- 产物体积:Tree Shaking、Code Splitting、压缩
- Rspack
- 为什么:JS 单线程瓶颈 → Rust 原生性能
- 核心优势:Rust + 并行化 + 内置功能 + 增量编译
- 兼容性:配置兼容 Webpack,大部分 loader 可用
- 迁移策略:评估 → 替换内置 → 逐步迁移 → 验证产物
练习挑战
第一题 ⭐(基础):概念辨析
请分别说出以下几个操作应该用 Loader 还是 Plugin 实现,并简要说明原因:
- 把 SCSS 编译成 CSS
- 在打包完成后自动生成 HTML 文件
- 把 TypeScript 编译成 JavaScript
- 打包完成后把 dist 目录的旧文件清掉
点击查看答案与解析
- SCSS → CSS:Loader(
sass-loader)。这是单个文件的内容转换,属于 Loader 的职责。 - 自动生成 HTML:Plugin(
HtmlWebpackPlugin)。需要在所有模块打包完成后,根据产物信息生成一个新的 HTML 文件并注入 script 标签,这涉及对整个构建结果的操作,属于 Plugin 的职责。 - TS → JS:Loader(
ts-loader或babel-loader)。单个文件的语言转换。 - 清理 dist 目录:Plugin(
CleanWebpackPlugin,或 Webpack 5 的output.clean)。这是构建流程级别的操作,不是针对单个文件的转换。
判断标准: 如果任务是”把一种文件内容变成另一种”,用 Loader;如果任务需要”在构建的某个阶段对整体做点什么”,用 Plugin。
第二题 ⭐⭐(进阶):分析 HMR 失效原因
下面的代码使用了 Webpack HMR,但修改 utils.js 后页面总是整页刷新而不是热替换。请分析原因并提出修复方案。
// index.js
import { formatDate } from './utils.js';
console.log(formatDate(new Date()));
// 没有任何 module.hot 相关代码
点击查看答案与解析
原因: index.js 作为入口模块,没有调用 module.hot.accept 来声明自己”接受 ./utils.js 的更新”。当 utils.js 发生变化时,更新信号从 utils.js 向上冒泡到 index.js,但 index.js 也没有 accept,最终冒泡到顶层,HMR Runtime 回退为整页刷新。
修复:
import { formatDate } from './utils.js';
console.log(formatDate(new Date()));
if (module.hot) {
module.hot.accept('./utils.js', () => {
// 重新执行使用了 utils 的逻辑
const { formatDate: newFormatDate } = require('./utils.js');
console.log(newFormatDate(new Date()));
});
}
在实际的 React/Vue 项目中,框架的 HMR 插件(react-refresh-webpack-plugin、vue-loader)会自动帮你处理 accept 逻辑,所以通常不需要手写。
第三题 ⭐⭐⭐(综合):设计一个最简 Webpack Plugin
请实现一个名为 FileSizePlugin 的 Webpack Plugin,功能:在构建完成后,在控制台打印每个输出文件的文件名和大小(以 KB 为单位,保留两位小数)。
要求:
- 使用
compiler.hooks.emit钩子 - 遍历
compilation.assets - 用
source.size()获取文件大小
点击查看答案与解析
class FileSizePlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('FileSizePlugin', (compilation, callback) => {
const assets = compilation.assets;
console.log('\n--- File Sizes ---');
for (const filename in assets) {
const sizeInBytes = assets[filename].size();
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
console.log(` ${filename}: ${sizeInKB} KB`);
}
console.log('------------------\n');
callback();
});
}
}
module.exports = FileSizePlugin;
要点解析:
apply(compiler)是 Plugin 的入口方法,Webpack 在初始化时调用compiler.hooks.emit是”即将输出文件”时的钩子,此时compilation.assets里已经有了所有待输出的文件tapAsync表示异步钩子,必须调用callback()告诉 Webpack 继续执行assets[filename].size()返回字节数
这道题考察的是对 Plugin 原理的理解:Plugin 就是通过 apply 方法挂载到 Compiler 的钩子上,在合适的时机执行自定义逻辑。
自我检测
读完本章后,对照下面的清单检验自己的掌握程度:
- 能清楚说出 Webpack 五大核心概念(Entry、Output、Loader、Plugin、Mode)各自的职责
- 能解释 Loader 和 Plugin 的本质区别,并能准确判断一个任务应该用 Loader 还是 Plugin
- 能描述 Webpack 构建流程的三个阶段,以及每个阶段做了什么
- 能解释 HMR 的工作原理,包括 Compiler、Dev Server、HMR Runtime 三者的协作方式
- 能说出
module.hot.accept的作用,以及如果没有 accept 会发生什么 - 能列举至少 3 种提升构建速度的手段和 3 种减小产物体积的手段
- 能解释 Rspack 相比 Webpack 的核心优势,以及它是如何实现兼容的
- 能说出从 Webpack 迁移到 Rspack 的大致步骤和需要注意的风险点
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90