“Webpack 配置”这五个字,长期占据着前端工程师恐惧排行榜的前列。它庞大、隐晦、报错信息长到需要翻三屏,一个 loader 顺序写反就能让人排查一整晚。但与此同时,它又是现代前端工程化绕不开的地基:模块解析、代码分割、Tree Shaking、持久化缓存、环境注入、产物分析——这些真正决定项目“跑得快不快、发得稳不稳、改得动不动”的能力,几乎全部由这份配置文件掌控。本篇长文以 Webpack 5 为主线,从核心概念讲起,一路走过基础配置、资源处理、开发体验、生产优化、代码分割、缓存策略、构建性能、产物分析、模块联邦、配置组织与常见坑,最后给出一份可以直接抄走的检查清单。全文配套大量真实配置片段与取舍分析,建议边读边在本地项目里改一遍——80 分钟之后,你会拥有一份属于自己的、能解释清楚每一行的 Webpack 配置。
一、2026 年了,为什么还要学 Webpack 5
每次聊到 Webpack,总有人会问:“现在不是都用 Vite / Rspack / Turbopack 了吗?”这个问题问得没错,但结论下得太早。先看几个事实:
Webpack 5 相比 4 到底变了什么
Webpack 5 不是一次小版本迭代,它把很多过去需要插件才能实现的能力内置了进来,同时对性能做了系统性改造:
- 持久化缓存:
cache: { type: 'filesystem' }让二次构建从分钟级降到秒级,过去要靠hard-source-webpack-plugin打补丁。 - 资源模块(Asset Modules):
file-loader、url-loader、raw-loader全部内置为asset/resource、asset/inline、asset/source、asset,少装三个依赖。 - 更好的 Tree Shaking:支持嵌套模块的
sideEffects分析,export *也能被正确摇掉。 - 模块联邦(Module Federation):让多个独立构建的应用在运行时共享模块,微前端的原生方案。
- 长期缓存默认开启:内置
deterministic模块 ID 与 chunk ID 算法,内容不变则 hash 不变。 - Node.js 生态 polyfill 移除:不再自动注入
path、crypto等 Node 内置模块,浏览器包更小,但也带来了迁移成本。 - 构建性能优化:更快的依赖解析、更聪明的增量构建、支持多线程与原生语言重写的 loader。
本文的阅读方式
文章按“概念 → 基础 → 开发 → 生产 → 调优 → 进阶”的顺序组织。每一节都包含可直接粘贴的配置片段,以及“为什么这么写”的解释。如果你时间有限,可以按下面的路径跳读:
二、五个核心概念:把配置文件拆开看
Webpack 的配置文件看起来很吓人,但它其实只有五个核心概念。把这五个搞懂,剩下的都是它们的组合与细节。点击下面的每个阶段,查看它的职责。
import / require 递归解析,最终构建出完整的依赖图。入口可以有多个,用于多页应用或分离 vendor。filename 控制 chunk 文件名,path 控制目录,publicPath 控制运行时资源引用的前缀。文件名里的 hash 是缓存策略的关键。development 会开启可读的模块 ID、较快的构建与完整的 source map;production 会自动开启 Tree Shaking、代码压缩、作用域提升等优化。不要手动设置 NODE_ENV,交给 mode。extensions 决定省略后缀时的查找顺序,alias 提供路径别名,modules 指定 node_modules 之外的查找目录。写得越精确,解析越快。splitChunks 控制分包,runtimeChunk 抽离运行时代码,minimizer 指定压缩器,usedExports 与 sideEffects 驱动 Tree Shaking。构建流程:从入口到产物
把上面的概念串起来,一次完整的构建大致经历四个阶段:
读取配置 → 创建 Compiler → 挂载 Plugin → 确定 Entry
② 编译(make)
Entry → 解析依赖 → 对每个模块应用 Loader 链 → 生成 Module
→ 递归处理依赖 → 构建出完整依赖图
③ 优化(seal)
Tree Shaking → 作用域提升(Scope Hoisting)
→ SplitChunks 分包 → 生成 Chunk → 计算 hash
④ 输出(emit)
应用 Plugin 的产物处理钩子 → 压缩 → 写文件到磁盘
loader 与 plugin 的分工
这两者最容易混淆。一句话区分:loader 面向文件,plugin 面向流程。
| 维度 | loader | plugin |
|---|---|---|
| 作用对象 | 单个模块文件 | 整个构建过程 |
| 工作方式 | 把源码转成另一种形式 | 在钩子中执行副作用操作 |
| 配置位置 | module.rules |
plugins 数组 |
| 典型代表 | babel-loader、css-loader |
HtmlWebpackPlugin、DefinePlugin |
| 执行顺序 | 从右到左、从下到上 | 由钩子触发时机决定 |
举个例子:babel-loader 把 ES2020 语法转成 ES5,这是“转换单个文件”,属于 loader;而 HtmlWebpackPlugin 要在所有产物生成后,把 script 标签自动注入 HTML,这需要“知道全局有哪些产物”,属于 plugin。
三、从零开始:一份最小可用的配置
先忘掉那些动辄三百行的“最佳实践”,我们从最小的配置开始,逐个补齐。
安装与目录约定
npm i -D webpack webpack-cli webpack-dev-server
# 常用配套
npm i -D html-webpack-plugin css-loader style-loader
npm i -D mini-css-extract-plugin css-minimizer-webpack-plugin
npm i -D terser-webpack-plugin webpack-merge dotenv
推荐的项目结构:
├── config/
│ ├── webpack.common.js
│ ├── webpack.dev.js
│ └── webpack.prod.js
├── public/
│ └── favicon.ico
├── src/
│ ├── index.js
│ ├── App.jsx
│ └── styles/
├── package.json
└── .env
webpack.common.js:公共配置
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
main: './src/index.js',
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[hash:8][ext]',
publicPath: '/',
clean: true,
},
resolve: {
extensions: ['.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, '../src'),
},
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
favicon: './public/favicon.ico',
inject: 'body',
}),
],
};
webpack.dev.js 与 webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
devServer: {
port: 3000,
hot: true,
open: true,
},
});
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
optimization: {
minimize: true,
usedExports: true,
sideEffects: true,
},
});
package.json 脚本
"scripts": {
"dev": "webpack serve --config config/webpack.dev.js",
"build": "webpack --config config/webpack.prod.js",
"build:stats": "webpack --config config/webpack.prod.js --json > stats.json",
"analyze": "webpack-bundle-analyzer dist/stats.json"
},
"sideEffects": ["*.css", "*.less"]
}
注意最后那个顶层的 sideEffects 字段:它告诉 Webpack“除了 CSS 之外,其他文件都没有副作用,可以放心地摇掉未使用的导出”。这个字段放在 package.json 里,比写在 module.rules 的 sideEffects 选项里覆盖面更广,也是 Tree Shaking 生效的前提之一。
package.json 里省略 sideEffects 字段。此时 Webpack 会保守地认为所有模块都可能有副作用,import './polyfill' 这类语句不敢删除,Tree Shaking 效果大打折扣。
"sideEffects": false,再把确实有副作用的文件(CSS、polyfill)单独列成数组排除。
四、样式与静态资源:loader 链的正确写法
CSS 处理的三段式
CSS 从源文件到浏览器,需要经过三个环节,对应三个 loader,执行顺序从右到左:
autoprefixer 自动补前缀、postcss-preset-env 转换新语法、cssnano 压缩。它负责“把 CSS 变成兼容性更好的 CSS”。@import 与 url(),处理 CSS Modules 的局部作用域。它不负责把样式插入页面。<style> 标签注入到 DOM。开发环境用它图快,生产环境要换成 MiniCssExtractPlugin.loader。<link> 引入。好处是能与 JS 并行下载、可被浏览器缓存、避免 FOUC(样式闪烁)。{
test: /\.css$/i,
use: ['style-loader', 'css-loader', 'postcss-loader'],
}
// 生产环境:抽离为独立文件
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
}
// CSS Modules:只对 .module.css 生效
{
test: /\.module\.css$/i,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
},
},
},
],
}
预处理器的接入
Less / Sass / Stylus 只需要在 css-loader 之后再挂一个 loader(因为它是先执行的):
test: /\.scss$/i,
use: [
'style-loader',
'css-loader',
'postcss-loader',
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: [path.resolve(__dirname, '../src/styles')],
},
additionalData: '@use "@/styles/variables" as *;',
},
},
],
}
additionalData 是非常实用的选项:它会在每个 SCSS 文件顶部自动注入变量定义,避免在每个文件里重复 @import。注意它不会真正产生重复代码,只是编译期的文本拼接。
资源模块:告别 file-loader
Webpack 5 内置了四种资源类型,覆盖了过去三个 loader 的全部场景:
| 类型 | 行为 | 替代 | 适用场景 |
|---|---|---|---|
asset/resource |
输出为独立文件,返回 URL | file-loader |
字体、大图、视频 |
asset/inline |
转成 base64 内联 | url-loader |
小图标、小 SVG |
asset/source |
导出文件源码字符串 | raw-loader |
着色器的 GLSL、模板文本 |
asset |
自动在 inline 与 resource 间切换 | url-loader + limit |
通用图片处理 |
{
test: /\.(png|jpe?g|gif|webp|avif)$/i,
type: 'asset',
parser: {
dataUrlCondition: { maxSize: 8 * 1024 },
},
generator: {
filename: 'images/[name].[hash:6][ext]',
},
},
// SVG:作为独立文件,方便后续用 CSS 控制颜色
{
test: /\.svg$/i,
type: 'asset/resource',
generator: {
filename: 'icons/[name].[hash:6][ext]',
},
},
// 字体
{
test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[hash:6][ext]',
},
}
关于 8KB 这个阈值
maxSize 是资源内联的关键参数,但它没有标准答案。内联的好处是省掉一次 HTTP 请求,坏处是:
- base64 编码后体积会膨胀约 33%。
- 内联的图片会进入 JS bundle,无法被浏览器单独缓存,图片一改,整个 JS 的 hash 就变了。
- 会阻塞 JS 的解析执行时间。
因此经验值是:图标类小图内联(4~8KB),内容图片一律外链。如果你的项目有大量小图标,更推荐使用 SVG Sprite 或字体图标,而不是依赖 base64 内联。
五、开发体验:devServer、HMR 与 Source Map
devServer 的关键配置
port: 3000,
host: '0.0.0.0', // 允许局域网访问
hot: true, // 开启 HMR
open: true,
compress: true, // 开启 gzip 压缩
historyApiFallback: true, // SPA 路由回退
client: {
overlay: {
errors: true,
warnings: false,
},
},
static: {
directory: path.resolve(__dirname, '../public'),
publicPath: '/public',
},
proxy: [
{
context: ['/api'],
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
],
},
几个容易忽略的点:
historyApiFallback只在用了 HTML5 History 路由(React Router 的 browserHistory、Vue Router 的 history 模式)时才需要。没有它,刷新子路由会 404。client.overlay建议把warnings设为false,否则一个未使用变量的警告就会糊满整个屏幕,反而看不见真正的错误。proxy的context在新版本中已被devServer.proxy的数组语法推荐取代,写起来更清晰。
HMR 到底是怎么工作的
热更新(Hot Module Replacement)是 Webpack 开发体验的核心。它的完整链路是:
webpack-dev-server 的 watch 检测到文件修改
② 增量编译
只重新编译受影响的模块,生成新的 chunk 与 hash
③ 推送更新
通过 WebSocket 把 manifest 与更新后的 chunk 推给浏览器
④ 客户端应用
HMR Runtime 根据 manifest 找到需要替换的模块
→ 调用 module.hot.accept 注册的回调
→ 替换模块引用,不刷新页面
关键点在于 module.hot.accept。如果某个模块没有注册接受回调,更新就会一路冒泡到入口,最终触发整页刷新。这就是为什么“改了 CSS 能热更新,改了 JS 却整页刷新”——CSS 由 style-loader 帮你注册了 HMR 处理,而业务 JS 需要框架层支持(React Refresh、Vue Loader 都已内置)。
Source Map 的选择
devtool 有二十多种取值,但真正需要记住的只有几个:
| 取值 | 构建速度 | 质量 | 推荐场景 |
|---|---|---|---|
eval |
最快 | 差 | 不推荐,行号会错乱 |
eval-cheap-module-source-map |
快 | 中 | 开发环境首选 |
eval-source-map |
中 | 好 | 需要精确定位时 |
source-map |
慢 | 最好 | 生产环境(独立 .map 文件) |
hidden-source-map |
慢 | 最好 | 生成 map 但不引用,只上传给错误监控平台 |
nosources-source-map |
慢 | 中 | 有堆栈行号但无源码,兼顾安全 |
命名规则其实很好记:
- eval:把源码包在
eval()里,速度最快但行号不准。 - cheap:不生成列映射,只到行级别,体积和速度都更好。
- module:把 loader 处理前的源码也映射进来,能调试原始 TS / JSX。
- inline:把 map 以 base64 内联进 JS,不产生额外文件。
- hidden:生成 map 文件,但不在 JS 末尾添加
sourceMappingURL注释。
devtool: 'source-map' 并把 .map 文件部署到公网。任何人都能下载你的完整源码,包括注释与内部接口路径。
hidden-source-map 生成 map,构建后把 .map 上传到 Sentry 等监控平台,再从产物目录删除,或配置服务器拒绝 .map 的公开访问。
六、生产优化(上):Tree Shaking 与压缩
Tree Shaking 生效的三个前提
Tree Shaking(摇树优化)的目标是删除未被使用的导出代码。但它不是自动的,必须同时满足三个条件:
import / export),CommonJS 的动态特性无法静态分析
production 模式,或手动开启 optimization.usedExports
package.json 中的 sideEffects 字段
第一条最常见的破坏方式是 babel 把 ESM 转成了 CommonJS。解决办法是在 Babel 配置里关闭模块转换:
module.exports = {
presets: [
['@babel/preset-env', {
modules: false, // 关键!保留 ESM 语法
useBuiltIns: 'usage',
corejs: 3,
}],
],
};
如果不写 modules: false,Babel 会把 import 编译成 require,Webpack 拿到的是已经转换过的 CommonJS 代码,Tree Shaking 直接失效。
副作用标记的正确写法
{ "sideEffects": false }
// 情况二:只有部分文件有副作用
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfill.js",
"./src/global-setup.js"
]
}
// 情况三:单文件内局部标记(函数级)
export function pureHelper() { /* ... */ }
/* webpackIgnore 与 pure 注释主要用于库作者 */
一个非常隐蔽的坑:CSS 文件必须保留在 sideEffects 数组里。因为 import './style.css' 这行代码本身没有导入任何变量,如果被标记为无副作用,Webpack 会认为这行代码可以安全删除,结果就是——样式没了。
压缩器配置
Webpack 5 在 production 模式下默认使用 terser-webpack-plugin 压缩 JS,但它不会自动压缩 CSS。CSS 压缩需要单独配置:
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true, // 多进程并行压缩
extractComments: false, // 不抽离 license 注释
terserOptions: {
compress: {
drop_console: true, // 移除 console
drop_debugger: true,
pure_funcs: ['console.log'],
},
format: {
comments: false,
},
},
}),
new CssMinimizerPlugin({
parallel: true,
}),
],
},
关于 drop_console:它确实能去掉生产环境的日志,但也会连同 console.error 一起删掉,导致线上问题难以定位。更温和的做法是只删除 console.log 与 console.debug:
pure_funcs: ['console.log', 'console.debug', 'console.info'],
},
Scope Hoisting:作用域提升
默认情况下,Webpack 会把每个模块包裹在一个函数里,这层包裹本身就是体积与性能开销。生产模式下 optimization.concatenateModules 会自动开启,把能合并的模块合并到同一个作用域中:
var moduleA = (function() {
exports.value = 1;
});
// 提升后:直接内联
var value = 1;
它通常能带来 10%~20% 的体积减少与一定的运行时提速。副作用是:模块边界消失后,函数名可能变得难以辨认。如果调试困难,可以在开发环境关闭它(开发环境本来也是关的)。
七、生产优化(下):代码分割与 SplitChunks
代码分割是前端性能优化中收益最大的一环。它的目标很朴素:让首屏只加载首屏需要的代码。
三种分割方式
entry,天然产生多个 bundle,适合多页应用
import() 语法,Webpack 遇到它就自动创建一个独立 chunk
动态导入:把路由变成按需加载
const Dashboard = React.lazy(() =>
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);
// 条件加载:只有用户点开弹窗才下载
button.addEventListener('click', async () => {
const { openEditor } = await import(
/* webpackPrefetch: true */
'./editor'
);
openEditor();
});
三个魔法注释非常有用:
webpackChunkName:给 chunk 起个可读的名字,否则产物会是一堆数字 ID。webpackPrefetch:浏览器空闲时预取,用户点击时几乎瞬间打开。适合“下一步极可能用到”的资源。webpackPreload:与父 chunk 并行加载,优先级高于 prefetch。适合“当前页面马上就要用”的资源,用多了会抢带宽。
SplitChunks:分包策略的核心
默认配置下的 SplitChunks 只做了一件很保守的事:把 node_modules 里被多个 chunk 引用的模块抽出来。要真正控制分包,需要显式配置:
splitChunks: {
chunks: 'all', // 同步 + 异步都参与分割
minSize: 20 * 1024, // 超过 20KB 才分割
maxSize: 244 * 1024, // 尝试拆到 244KB 以下
minChunks: 1,
maxAsyncRequests: 6,
maxInitialRequests: 4,
cacheGroups: {
// React 核心:体积大且几乎不变,单独长缓存
react: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'react-vendor',
priority: 30,
enforce: true,
},
// 其他第三方库
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 10,
reuseExistingChunk: true,
},
// 业务公共代码:被两个以上 chunk 引用就抽出来
common: {
name: 'common',
minChunks: 2,
minSize: 10 * 1024,
priority: 5,
reuseExistingChunk: true,
},
},
},
},
每个参数的取舍
| 参数 | 作用 | 调大 / 调小的后果 |
|---|---|---|
chunks |
哪些 chunk 参与分割 | 'all' 收益最大;'async'(默认)只处理动态导入 |
minSize |
小于此体积不分割 | 调小会产生大量碎文件与请求;调大则合并过度 |
maxSize |
尝试把 chunk 拆到此值以下 | 它不是硬约束,优先级低于 minSize |
maxInitialRequests |
入口 chunk 最多并行请求数 | 调大能更细粒度拆分,但 HTTP/1.1 下会排队 |
priority |
cacheGroup 优先级 | 数值大的先匹配,用于让 react 优先于 vendor |
reuseExistingChunk |
复用已存在的 chunk | 开启可避免重复打包,几乎无脑开 |
runtimeChunk:解决缓存失效的最后一块拼图
runtimeChunk: 'single',
},
Webpack 的运行时代码(负责模块加载、chunk 映射的那段逻辑)默认会被打进主 bundle。这带来的问题是:任何一次业务代码改动,都会让主 bundle 的 hash 变化,即使里面大部分是第三方库。
把 runtime 单独抽成一个约 1~2KB 的小文件后:
runtime.[hash].js:每次构建都变,但体积很小。react-vendor.[hash].js:只有升级 React 才变。vendor.[hash].js:只有升级依赖才变。main.[hash].js:每次业务改动都变。
配合 CDN 的强缓存策略,用户大多数时候只需要重新下载那个几 KB 的 runtime 和变化的主包,第三方库全部命中缓存。
八、缓存策略:让用户只下载变化的部分
前端缓存分为两层:HTTP 缓存决定浏览器要不要请求,文件名 hash决定请求到的是不是新文件。Webpack 的职责是第二层。
三种 hash 的区别
| 类型 | 计算粒度 | 特点 |
|---|---|---|
[hash] |
整个 compilation | 任何一个文件变化都会导致所有文件 hash 变化,已不推荐 |
[chunkhash] |
单个 chunk | 同一 chunk 内变化会互相影响,CSS 抽离时容易出问题 |
[contenthash] |
文件内容 | 内容不变则 hash 不变,长期缓存的标准答案 |
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
},
模块 ID 与 chunk ID 的稳定性
Webpack 4 时代有一个经典问题:新增一个模块,会导致所有模块的 ID 重新排列,进而让所有 chunk 的 hash 变化,缓存全部失效。Webpack 5 通过 deterministic 算法解决了这个问题:
moduleIds: 'deterministic',
chunkIds: 'deterministic',
},
这两个选项在 production 模式下已经是默认值,一般不需要手动配置。但在某些自定义插件的干扰下可能被覆盖,所以值得在配置里显式声明一遍,作为“防回归”的保险。
完整的缓存策略组合
# 带 hash 的静态资源:永久强缓存
location ~* \.(js|css|woff2|png|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML:绝不缓存,保证能拿到最新的资源引用
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
# source map:拒绝外部访问
location ~* \.map$ {
deny all;
}
注意 immutable 这个指令:它告诉浏览器“这个文件在一年内绝对不会变,用户手动刷新时也不要发请求验证”。它只对带 contenthash 的文件安全,千万不要用在 HTML 上。
验证缓存是否真的生效
配置完成后,做一次简单验证:
- 构建一次,记录所有产物的文件名。
- 只修改一个业务组件里的文案。
- 再次构建,对比文件名。
理想结果:只有包含该组件的那个 chunk 与 runtime 变了,react-vendor、vendor、CSS 文件的 hash 完全不变。如果发现 vendor 也变了,说明 runtime 没有正确抽离,或者 splitChunks 配置有问题。
九、构建性能优化:把 90 秒压到 9 秒
项目变大之后,构建时间会从“可以忍受”变成“无法忍受”。优化构建性能要分两步:先量化,再优化。不要凭感觉猜瓶颈。
第一步:测量
webpack --config config/webpack.prod.js --profile --json > stats.json
# 使用 speed-measure-webpack-plugin 自动打印
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);
输出的结果会告诉你:Babel 编译花了 40 秒、Terser 压缩花了 25 秒、CSS 处理花了 8 秒……然后你才知道该优化谁。
第二步:持久化缓存
这是 Webpack 5 收益最大的单项优化,没有之一。开启后,二次构建只需重新编译变化的模块:
type: 'filesystem',
buildDependencies: {
config: [__filename], // 配置文件变化时缓存失效
},
cacheDirectory: path.resolve(__dirname, '../node_modules/.cache/webpack'),
name: 'prod', // 多环境用不同缓存目录,避免互相污染
maxAge: 7 * 24 * 60 * 60 * 1000,
},
实测数据(中型项目,约 2000 个模块):首次构建 95 秒,开启文件系统缓存后二次构建 12 秒,改动单个文件后的增量构建 3 秒。
第三步:用更快的编译器替换 Babel
babel-loader 是纯 JS 实现的,在大型项目里经常是最大的瓶颈。用原生语言重写的替代品可以带来 5~20 倍的提升:
| 方案 | 速度 | 兼容性 | 迁移成本 |
|---|---|---|---|
babel-loader |
基准 | 最好 | — |
esbuild-loader |
10~20x | 较好 | 低,替换 loader 即可 |
swc-loader |
10~20x | 好 | 低,需要 .swcrc |
thread-loader |
2~4x | 最好 | 最低,包一层即可 |
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'esbuild-loader',
options: {
loader: 'tsx',
target: 'es2015',
},
},
},
// 方案二:thread-loader 多进程(放在其他 loader 之前)
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: 'thread-loader',
options: { workers: 4 },
},
'babel-loader',
],
},
关于 thread-loader:它把后续的 loader 放到 worker 线程池里执行。但进程启动本身有开销(约 600ms/worker),所以它只对耗时的 loader 有意义。给 css-loader 加 thread-loader 反而会更慢。
第四步:缩小处理范围
node_modules,除非你确实需要编译某个依赖
include 精确限定源码目录,比 exclude 更高效
extensions 精简到实际用到的后缀,减少文件系统探测
noParse: /lodash|moment/,
rules: [
{
test: /\.[jt]sx?$/,
include: path.resolve(__dirname, '../src'),
use: ['babel-loader'],
},
],
},
resolve: {
extensions: ['.js', '.jsx'], // 不写 .ts/.vue 等用不到的后缀
modules: [path.resolve(__dirname, '../src'), 'node_modules'],
alias: {
'@': path.resolve(__dirname, '../src'),
'react': path.resolve(__dirname, '../node_modules/react'),
},
symlinks: false, // 不使用 symlink 时关闭可提速
},
第五步:减少不必要的优化
有些优化本身就非常耗时,如果开发环境不需要,就应该关掉:
optimization: {
minimize: false, // 不压缩
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false, // 分包在开发环境收益低
runtimeChunk: false,
},
一份可参考的优化顺序
- 先测速,找出真正的瓶颈。
- 开启
cache: { type: 'filesystem' }——收益最大,成本最低。 - 检查
include/exclude是否正确,避免编译 node_modules。 - 精简
resolve.extensions与配置alias。 - 把 Babel 换成 esbuild 或 SWC。
- 开发环境关闭压缩与分包。
- 最后才考虑
thread-loader、parallel等多线程方案。
十、产物分析:让体积问题无处可藏
三种分析视角
const BundleAnalyzerPlugin = require(
'webpack-bundle-analyzer'
).BundleAnalyzerPlugin;
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'report.html',
openAnalyzer: false,
gzipSize: true,
defaultSizes: 'gzip',
excludeAssets: /\.map$/,
}),
],
注意 defaultSizes: 'gzip' 这个选项。默认视图展示的是未压缩体积,但实际传输的是 gzip / brotli 压缩后的体积。两者比例可能相差 3~5 倍,看未压缩数据容易误判优先级。
常见体积问题的定位思路
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| vendor 体积巨大 | 整包引入了 lodash / moment | 改为按需引入或换成 dayjs / lodash-es |
| 同一个库出现两次 | 依赖锁定了不同版本,或有 CJS/ESM 双份 | 用 resolve.alias 强制指向单一版本 |
| 图标字体过大 | 引入了完整的图标集,但只用了几个 | 换成 SVG 按需导入 |
| 首屏 chunk 过大 | 路由没有懒加载 | 用 React.lazy / defineAsyncComponent |
| 多语言包全量打入 | 把语言文件全 import 了 |
按语言动态导入 |
| Polyfill 体积大 | core-js 全量引入 |
改用 useBuiltIns: 'usage' |
性能预算与 CI 门禁
只在本地看报告是不够的。把体积约束写进 CI,才能防止下一个 PR 悄悄塞进 300KB:
performance: {
hints: 'error',
maxEntrypointSize: 512 * 1024, // 入口文件超过 512KB 报错
maxAssetSize: 1024 * 1024, // 单个资源超过 1MB 报错
assetFilter: (filename) => !/\.map$/.test(filename),
},
更精细的做法是使用 size-limit 或 bundlesize,为每个 chunk 设置独立的阈值,并在 PR 中自动评论体积变化。
十一、Module Federation:运行时的模块共享
Module Federation(模块联邦)是 Webpack 5 最具颠覆性的特性。它解决的是一个老问题:多个独立构建、独立部署的应用,如何共享代码?
和传统方案的区别
升级需重新发布
但版本难管理
版本可协商
通信成本高
两个角色:Host 与 Remote
const { ModuleFederationPlugin } = require(
'webpack'.container
);
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./utils': './src/utils/index',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true },
},
}),
],
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
// 名称: 远程入口地址
remoteApp: 'remoteApp@https://cdn.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
// 使用远程组件(配合 React.lazy)
const RemoteButton = React.lazy(
() => import('remoteApp/Button')
);
shared 配置的三个关键选项
singleton: true:强制所有应用共用同一个实例。对 React、Vue 这类有全局状态的库必须开启,否则会出现“两个 React 实例”导致的 hooks 报错。requiredVersion:声明可接受的版本范围。如果 host 提供的版本不满足,Webpack 会单独加载一份,避免 API 不兼容。eager: true:把共享模块打进初始 chunk,而不是异步加载。会让初始体积变大,但能避免“共享模块异步加载导致的水合延迟”。
适合与不适合的场景
Module Federation 的调试成本不低:版本冲突、共享模块加载顺序、远程应用不可用时的降级,都是需要提前设计的问题。引入前请先确认团队是否真的需要“运行时共享”这个能力。
十二、配置组织与环境变量
用 webpack-merge 拆分配置
单文件配置超过 200 行后就会变得难以维护。标准做法是拆成三份:公共、开发、生产。前面第三节已经展示了基本形态,这里补充几个细节:
module.exports = (env, argv) => {
const isProd = argv.mode === 'production';
return {
entry: './src/index.js',
output: {
filename: isProd
? 'js/[name].[contenthash:8].js'
: 'js/[name].js',
},
};
};
环境变量注入
API_BASE_URL=https://api.example.com
APP_VERSION=2.4.1
SENTRY_DSN=https://xxx@sentry.io/1
// webpack.common.js
const dotenv = require('dotenv');
const { DefinePlugin } = require('webpack');
const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
const envVars = dotenv.config({ path: envFile }).parsed || {};
const stringified = Object.keys(envVars).reduce((acc, key) => {
acc[`process.env.${key}`] = JSON.stringify(envVars[key]);
return acc;
}, {});
plugins: [
new DefinePlugin(stringified),
],
关于最后一条:DefinePlugin 的替换发生在编译期,所以下面这段代码在生产构建后,if 里的整块代码都会被 Terser 删除,因为条件永远为假:
// 生产构建后,这段代码整个消失
console.log('debug info');
window.__DEVTOOLS__ = { /* ... */ };
}
多页面应用的配置
entry: pages.reduce((acc, page) => {
acc[page] = `./src/pages/${page}/index.js`;
return acc;
}, {}),
plugins: [
...pages.map((page) => new HtmlWebpackPlugin({
template: `./public/${page}.html`,
filename: `${page}.html`,
chunks: [page, 'vendor', 'common'],
minify: { collapseWhitespace: true },
})),
],
注意 chunks 字段:它决定这个 HTML 只引入哪些 chunk。如果不写,所有页面的 HTML 都会注入全部 JS,多页应用就退化成了单页的加载量。
十三、十二个最常见的坑
style-loader 永远在最前面
modules: false,导致 Tree Shaking 完全失效
sideEffects 白名单里漏掉,样式被整包摇掉
[hash] 而不是 [contenthash],改一个字就让全部缓存失效
runtimeChunk,导致 vendor 的 hash 每次构建都变
source-map 部署上线,源码全部泄露
publicPath 配错,部署到子目录后所有资源 404
mini-css-extract-plugin 与 style-loader 同时使用,CSS 被注入两次
devServer.historyApiFallback,刷新子路由直接 404
DefinePlugin 的值没有 JSON.stringify,编译出的代码语法错误
thread-loader,构建反而更慢
三个值得展开的坑
坑 7:publicPath 的三种写法
publicPath 决定运行时从哪里加载 chunk 和静态资源。它有三个常用取值:
publicPath: '/',
// 部署在子目录 https://example.com/my-app/
publicPath: '/my-app/',
// 使用 CDN
publicPath: 'https://cdn.example.com/assets/',
// 自动推断(相对 HTML 的位置)
publicPath: 'auto',
'auto' 是 Webpack 5 新增的取值,它会根据当前脚本的 URL 自动推断路径,非常适合“不知道会部署到哪里”的场景。但它对使用了 import() 的动态 chunk 有一定要求,部署前务必在真实环境验证一遍。
坑 12:Node 核心模块的迁移
Webpack 5 不再自动为 Node 核心模块注入 polyfill。如果你依赖了 crypto、path、buffer 等模块,会看到类似 Module not found: Can't resolve 'crypto' 的报错。
npm i -D crypto-browserify stream-browserify buffer
resolve: {
fallback: {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
buffer: require.resolve('buffer/'),
},
},
// 方案二:如果确实不需要,直接置空
fallback: {
fs: false,
path: false,
},
更好的思路是找到浏览器原生的替代方案:crypto 可以用 Web Crypto API,path 通常可以用简单的字符串处理代替,Buffer 可以用 Uint8Array。引入 polyfill 只是把问题从构建时推到了运行时。
坑 8:CSS 重复注入
如果同时在 module.rules 里保留了 style-loader,又加了 MiniCssExtractPlugin.loader,样式会被处理两遍。正确做法是根据环境动态选择:
const styleLoader = isProd
? MiniCssExtractPlugin.loader
: 'style-loader';
use: [styleLoader, 'css-loader', 'postcss-loader'],
十四、配置检查清单
把全文结论浓缩成一份可以对照逐项打勾的清单。每次新建项目或接手项目时过一遍,能挡掉绝大多数问题。
- 基础:
mode显式指定,不依赖NODE_ENV推断。 - 基础:
output.clean: true,避免旧产物残留。 - 基础:
resolve.extensions只写实际用到的后缀。 - 基础:配置了
resolve.alias,消除../../../式路径。 - Loader:所有 JS/TS 规则都有
include或exclude。 - Loader:Babel 配置了
modules: false。 - Loader:资源模块用
asset/asset/resource,不再依赖 file-loader。 - Loader:CSS 在生产环境通过
MiniCssExtractPlugin抽离。 - 优化:
package.json中声明了sideEffects,并把 CSS 排除在外。 - 优化:
optimization.usedExports与sideEffects均为true。 - 优化:配置了
splitChunks.cacheGroups,把框架与业务代码分开。 - 优化:
runtimeChunk: 'single',避免缓存连带失效。 - 优化:
moduleIds与chunkIds为deterministic。 - 优化:CSS 也配置了压缩器(
CssMinimizerPlugin)。 - 缓存:文件名使用
[contenthash],不用[hash]。 - 缓存:
cache: { type: 'filesystem' }已开启。 - 缓存:CDN 对带 hash 的资源设置
immutable,对 HTML 设置no-cache。 - 性能:开发环境
devtool使用eval-cheap-module-source-map。 - 性能:生产环境使用
hidden-source-map,并拒绝.map公网访问。 - 性能:路由级组件使用动态
import(),并配置了webpackChunkName。 - 性能:设置了
performance.maxEntrypointSize作为体积门禁。 - 工程:配置拆分为 common / dev / prod,通过
webpack-merge合并。 - 工程:环境变量通过
DefinePlugin注入,且值经过JSON.stringify。 - 工程:
devServer.historyApiFallback已按路由模式配置。 - 工程:
publicPath与真实部署路径一致,并在预发环境验证过。
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
devtool: 'hidden-source-map',
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
publicPath: '/',
clean: true,
},
cache: {
type: 'filesystem',
buildDependencies: { config: [__filename] },
},
optimization: {
usedExports: true,
sideEffects: true,
moduleIds: 'deterministic',
chunkIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20 * 1024,
maxAsyncRequests: 6,
maxInitialRequests: 4,
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react-vendor',
priority: 30,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 10,
reuseExistingChunk: true,
},
},
},
minimizer: [
new TerserPlugin({ parallel: true, extractComments: false }),
new CssMinimizerPlugin(),
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].chunk.css',
}),
],
performance: {
hints: 'warning',
maxEntrypointSize: 512 * 1024,
maxAssetSize: 1024 * 1024,
},
});
Webpack 的配置之所以让人望而生畏,是因为它把构建过程中的每一个决策都暴露给了你。这既是负担,也是自由:你可以精确控制什么被打包、什么被拆开、什么被缓存、什么被丢弃。当你能解释清楚配置文件里的每一行时,你就不再是被构建工具支配的人,而是真正掌控了从源码到产物这条完整链路的人。
十五、Webpack 5 与 Vite / Rspack 的取舍
最后聊聊选型。这不是一个“谁替代谁”的问题,而是一个“场景匹配”的问题。
构建原理的根本差异
什么情况继续用 Webpack
- 项目依赖大量 Webpack 专属插件,迁移成本高于收益。
- 需要 Module Federation 做微前端,且对共享模块的版本协商有精细要求。
- 需要深度定制构建流程,比如自定义依赖图操作、自定义 resolver。
- 团队对 Webpack 已经非常熟悉,切换技术栈会带来学习成本与风险。
- 需要支持一些老旧的浏览器或特殊的构建目标。
什么情况值得考虑迁移
- 开发体验成为团队痛点,冷启动动辄两三分钟。
- 项目是标准的 Vue / React 应用,没有复杂的自定义构建逻辑。
- 已经用上了 Vite 生态的插件,或者愿意承担迁移成本。
- 想要更简洁的配置,不想再维护几百行的
webpack.config.js。
一条务实的迁移路径
如果你决定迁移,不建议一次性重写。更稳妥的路径是:
不管用哪个工具,前面十几节讲的原理都是通用的:入口与依赖图、loader 与转换、代码分割、内容哈希与缓存、Tree Shaking、体积预算。这些概念在 Vite 里叫 build.rollupOptions,在 Rspack 里叫 optimization.splitChunks,本质是同一套东西。
所以,学 Webpack 不只是学一个工具,而是学“构建”这件事本身。工具会换,原理不会。当你能一眼看出“这个 chunk 为什么这么大”“这次改动为什么让缓存全失效”“首屏为什么多等了 800 毫秒”的时候,你掌握的就是可迁移的能力。