从入门到能写自定义 loader 和 plugin,大概花了将近一周。这篇文章是我整个学习过程的总结,尽量把我觉得真正有用的、踩过坑的部分都写进来,而不是把官网文档重新翻译一遍。
代码示例均来自我自己写的练习项目(phase-1 到 phase-7),可以配合着看。
目录
- 为什么学 webpack
- 一、基础:它到底做什么
- 二、核心配置:五个你必须搞清楚的字段
- 三、Loader:文件怎么被处理的
- 四、Plugin:如何介入构建过程
- 五、开发体验:HMR 和多环境配置
- 六、性能优化:构建速度和产物体积
- 七、高级特性:模块联邦和打包库
- 总结:我的学习路径回顾
为什么学 webpack
说实话,现在 Vite 已经很流行了,很多人会问"还有必要学 webpack 吗"。我的答案是:对于理解前端工程化体系,webpack 仍然是绕不开的。
一方面,大量生产项目还在用 webpack,迁移成本不低。另一方面,即使你用 Vite,当遇到构建问题需要深入排查时,你会发现对打包原理的理解是通用的——Rollup、esbuild、webpack 解决的是同一类问题,只是取舍不同。
更重要的是,学 webpack 让我真正理解了:模块系统是怎么工作的、Tree Shaking 为什么需要 ES Module、Code Splitting 的 chunk 是怎么切分的、浏览器缓存和文件 hash 之间的关系。这些知识不会因为换了构建工具就过时。
一、基础:它到底做什么
webpack 的核心工作可以用一句话描述:从 entry 出发,递归分析所有 import/require 依赖,把整个依赖树打包成若干 chunk 文件,输出到 dist 目录。
src/index.js
└── import './utils/math.js'
└── import 'lodash'
webpack 会把这三个模块(加上 lodash 的所有文件)
打成一个(或多个)bundle,浏览器只需要加载这个 bundle。
三个模式的差异
最开始容易忽略的是 mode 的影响。它不只是个标签,会直接控制 webpack 内部开启哪些优化:
// development:不压缩,保留变量名,便于调试
// production:压缩混淆,Tree Shaking,所有优化全开
// none:什么优化都不做,适合研究打包原理
module.exports = {
mode: 'production',
};
我建议初学时用 none 模式看一遍产物,再切到 production 对比,会对 webpack 做了什么有很直观的感受。
最简配置
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
};
就这三个字段,已经可以处理 90% 的基础场景。其他配置都是在这之上的扩展。
二、核心配置:五个你必须搞清楚的字段
entry:从哪里开始
四种写法,我按照使用频率排序:
// 最常用:单入口
entry: './src/index.js'
// 多页应用:对象写法,每个 key 是一个独立的 chunk
entry: {
home: './src/home.js',
about: './src/about.js',
}
// 少用:把多个文件打进同一个 chunk
entry: ['./src/polyfills.js', './src/index.js']
// 高级:dependOn 共享依赖(避免重复打包)
entry: {
app: { import: './src/app.js', dependOn: 'shared' },
shared: ['lodash', 'react'],
}
对象写法是多页应用的标准做法,[name] 占位符会被替换成 key 名,生成 home.bundle.js、about.bundle.js。
output:打到哪里
重点是 hash 策略,这直接影响浏览器缓存的效果:
output: {
path: path.resolve(__dirname, 'dist'),
// [contenthash] 是最推荐的:文件内容不变则 hash 不变
// 用户浏览器可以一直缓存没有变化的文件
filename: '[name].[contenthash:8].js',
// 异步 chunk(动态 import)的命名
chunkFilename: '[name].[contenthash:8].chunk.js',
// webpack 5 内置清空,不需要 CleanWebpackPlugin 了
clean: true,
}
三种 hash 的区别经常被问到:
| hash 类型 | 变化时机 | 适用场景 |
|---|---|---|
[hash] | 任意文件变化,所有 chunk 的 hash 都变 | 几乎不用 |
[chunkhash] | 当前 chunk 内容变化时变 | 旧写法 |
[contenthash] | 当前文件内容变化时变 | 推荐,精度最高 |
module.rules:每种文件怎么处理
这是 webpack 可扩展性的核心。一个 rule 的基本结构:
{
test: /\.scss$/, // 匹配哪些文件
include: path.resolve(__dirname, 'src'), // 只处理 src 下的(性能优化)
use: [
'style-loader', // 第三步:注入 <style> 标签
'css-loader', // 第二步:处理 @import 和 url()
'sass-loader', // 第一步:把 SCSS 编译成 CSS
],
// use 数组从右到左执行,数据流:scss → css → 模块 → DOM
}
一个让我困惑了很久的问题是:loader 的执行顺序是从右到左,但为什么 thread-loader 要放在最左边(最后执行)?
thread-loader 不是普通的转换 loader,它是一个"任务调度器"。它的 normal 阶段最后执行,但此时它会接管右边所有 loader 的执行,把它们派发到 worker 进程中运行。所以"耗时的 loader 放在 thread-loader 右边"的意思是:让它们被 thread-loader 接管并放入线程池。
use: [
{ loader: 'thread-loader' }, // 最后执行,但此时接管 babel-loader 到 worker
'babel-loader', // 实际在 worker 进程里跑
]
resolve:怎么找文件
最常用的两个配置:
resolve: {
// 路径别名:消灭 ../../../ 地狱
alias: {
'@': path.resolve(__dirname, 'src'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@components': path.resolve(__dirname, 'src/components'),
},
// 省略后缀名:import './utils' 会依次尝试 .js .jsx .ts .tsx
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}
配合 IDE 的路径提示,@/utils/math 比 ../../utils/math 好维护太多了。
devtool:Source Map
调试体验的关键。不同场景的推荐值:
// 开发环境:快速重建 + 能定位到源文件
devtool: 'eval-cheap-module-source-map'
// 生产环境:独立文件,只在出错时用
devtool: 'source-map'
// CI 构建(不需要调试)
devtool: false
eval-cheap-module-source-map 这个名字看起来很长,拆开理解:eval 用 eval 执行(快)、cheap 只映射到行不映射列(更快)、module 显示源文件而非 loader 处理后的内容。
三、Loader:文件怎么被处理的
执行机制:pitch + normal 双阶段
这是理解 loader 系统最重要的概念,官网说得不够直观,我用图来表示:
配置:use: ['loader-a', 'loader-b', 'loader-c']
pitch 阶段(从左到右):
loader-a.pitch → loader-b.pitch → loader-c.pitch
normal 阶段(从右到左):
loader-c.normal → loader-b.normal → loader-a.normal
如果某个 pitch 返回了值,后续 pitch 和所有 normal 都被跳过:
loader-a.pitch → loader-b.pitch(返回值!)→ loader-a.normal
大多数时候只有 normal 阶段,pitch 是高级用法,style-loader 用 pitch 是因为它要把 CSS 注入到 <head> 里,需要提前介入。
常用 loader 组合
CSS/SCSS 处理链:
{
test: /\.scss$/,
use: [
'style-loader', // 开发环境:注入 style 标签(支持 HMR)
// MiniCssExtractPlugin.loader // 生产环境:提取独立 CSS 文件
'css-loader',
{
loader: 'postcss-loader', // 自动加 -webkit- 等前缀
options: {
postcssOptions: {
plugins: ['autoprefixer'],
},
},
},
'sass-loader',
],
}
Babel(JS 语法兼容):
{
test: /\.js$/,
include: path.resolve(__dirname, 'src'),
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-react', // 如果用 React
],
// 缓存编译结果,第二次快很多
cacheDirectory: true,
cacheCompression: false,
},
},
}
自定义 loader
写 loader 其实不难,本质是一个函数:
// loaders/remove-console-loader.js
module.exports = function(source) {
// source 是文件的原始内容(字符串)
// 返回处理后的内容
return source.replace(/console\.(log|warn|error)\(.*?\);?/g, '');
};
异步 loader 用 this.async():
module.exports = function(source) {
const callback = this.async(); // 告诉 webpack 这是异步 loader
someAsyncOperation(source).then(result => {
callback(null, result); // 第一个参数是 error,第二个是处理结果
});
};
几个做项目时真正有价值的自定义 loader 场景:
- security-audit-loader:扫描代码中的硬编码密钥、eval 使用、HTTP 明文请求,构建时作为 warning 或 error 输出,比 ESLint 更接近最终产物
- i18n-loader:构建时把
$t('key')替换成对应语言的字面量,适合语言确定、不需要运行时切换的场景(比如政府网站、特定版本构建) - style-inject-loader:给所有 SCSS 文件自动 prepend 公共变量文件,省去每个组件手动
@import
四、Plugin:如何介入构建过程
Loader 和 Plugin 的根本区别
这个问题面试经常被问,但很多回答只说了表面:
| Loader | Plugin | |
|---|---|---|
| 处理对象 | 单个文件的内容 | 整个构建过程 |
| 能做什么 | 转换文件内容(SCSS→CSS、TS→JS) | 修改输出结构、注入资源、分析依赖、改变构建行为 |
| 介入方式 | module.rules 配置,形成处理链 | 通过 Tapable 钩子,在特定时机执行 |
| 执行时机 | make 阶段(构建模块时) | 构建生命周期的任意阶段 |
一句话:loader 处理单个文件的内容转换,plugin 处理整个构建过程中的事件。
Tapable:webpack 的事件系统
webpack 内部所有的扩展点都基于 Tapable,理解它才能写出真正有用的 plugin。
五种核心钩子类型:
const { SyncHook, SyncBailHook, SyncWaterfallHook,
AsyncSeriesHook, AsyncParallelHook } = require('tapable');
// SyncHook:同步,所有监听者都执行,忽略返回值
const hook1 = new SyncHook(['arg1']);
// SyncBailHook:同步,返回非 undefined 则中断后续
// 应用:HtmlWebpackPlugin 的 alterAssetTagGroups,可以让某个 plugin 阻断后续
const hook2 = new SyncBailHook(['result']);
// SyncWaterfallHook:同步,上一个的返回值传给下一个
// 应用:处理链,每个 plugin 对结果做一步加工
const hook3 = new SyncWaterfallHook(['value']);
// AsyncSeriesHook:异步串行,一个完成才执行下一个
// 应用:emit 钩子,文件写入前的处理
const hook4 = new AsyncSeriesHook(['compilation']);
// AsyncParallelHook:异步并行,同时执行
const hook5 = new AsyncParallelHook(['compilation']);
注册和触发:
hook1.tap('MyPlugin', (arg) => { /* 同步 */ });
hook4.tapAsync('MyPlugin', (compilation, callback) => { callback(); });
hook4.tapPromise('MyPlugin', (compilation) => Promise.resolve());
hook1.call('value');
hook4.callAsync(compilation, callback);
webpack 的构建生命周期
一次完整构建,关键钩子的触发顺序:
compiler.hooks.environment → 初始化,plugin 最早介入的地方(修改 options 用这里)
compiler.hooks.entryOption → 解析完 entry 配置
compiler.hooks.beforeCompile → 编译开始前
compiler.hooks.make → 开始构建模块(从 entry 递归分析依赖)
compilation.hooks.buildModule → 处理每一个模块
compilation.hooks.finishModules→ 所有模块处理完毕
compiler.hooks.afterCompile → 编译完成(含 assets 已确定)
compiler.hooks.emit → 文件写入 dist 之前(最后修改机会)
compiler.hooks.afterEmit → 文件已写入
compiler.hooks.done → 构建完全结束(读取 stats、发通知用这里)
常用内置 Plugin
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
plugins: [
// 生成 index.html,自动注入所有 chunk 的 <script> 和 <link>
new HtmlWebpackPlugin({
template: './public/index.html',
minify: { removeComments: true, collapseWhitespace: true },
}),
// 提取 CSS 为独立文件(生产必备,才能 CSS 压缩)
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
}),
// 构建时的全局常量替换(注意必须 JSON.stringify)
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
__APP_VERSION__: JSON.stringify('1.0.0'),
}),
// 把 public/ 下的静态文件原样复制到 dist/
new CopyWebpackPlugin({
patterns: [{ from: 'public', to: '.', globOptions: { ignore: ['**/index.html'] } }],
}),
]
DefinePlugin 是做构建时字符串替换的,不是环境变量注入。__APP_VERSION__ 在代码里出现的地方,编译后会被直接替换成 "1.0.0"。必须用 JSON.stringify 是因为替换是字面量替换,JSON.stringify('1.0.0') 得到 '"1.0.0"'(带引号的字符串),替换到代码里才是合法的 JS 字符串。
自定义 Plugin:三个有意思的例子
构建质量监控(BuildStatsPlugin):
class BuildStatsPlugin {
apply(compiler) {
compiler.hooks.done.tap('BuildStatsPlugin', (stats) => {
// 遍历所有 chunk,统计体积
for (const chunk of stats.compilation.chunks) {
for (const file of chunk.files) {
const size = stats.compilation.assets[file].size();
if (size > this.options.maxSize) {
// 向 webpack 注入 warning(会在控制台显示)
stats.compilation.warnings.push(
new Error(`${file} 体积 ${size} 超过阈值`)
);
}
}
}
});
}
}
自动 CDN 外链(AutoExternalPlugin):
把手动维护 externals 和 HTML 中的 CDN script 这两件事合并成一个 plugin 自动完成:
class AutoExternalPlugin {
apply(compiler) {
// 最早的钩子,在这里修改 externals
compiler.hooks.environment.tap('AutoExternalPlugin', () => {
compiler.options.externals = { lodash: '_', dayjs: 'dayjs' };
});
// 与 HtmlWebpackPlugin 协作,在 <head> 里插入 CDN script
compiler.hooks.compilation.tap('AutoExternalPlugin', (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
'AutoExternalPlugin',
(data, callback) => {
data.headTags.unshift({ tagName: 'script', attributes: { src: CDN_URL } });
callback(null, data);
}
);
});
}
}
循环依赖检测(ModuleDependencyGraphPlugin):
循环依赖是大型项目的常见隐患:A 依赖 B、B 依赖 C、C 又依赖 A。webpack 不报错,但运行时某个模块可能拿到 undefined。
compiler.hooks.done.tap('ModuleDependencyGraphPlugin', (stats) => {
// 构建有向图 edges: Map<moduleId, Set<depId>>
const edges = new Map();
for (const mod of stats.compilation.modules) {
const deps = mod.dependencies.map(dep =>
stats.compilation.moduleGraph.getResolvedModule(dep)
).filter(Boolean);
edges.set(mod.resource, new Set(deps.map(d => d.resource)));
}
// DFS + 颜色标记法检测环
// white(0) → 未访问 grey(1) → 访问中 black(2) → 完成
// 发现 grey 节点 = 发现环
const cycles = detectCycles(edges);
if (cycles.length) {
stats.compilation.warnings.push(new Error(
`检测到循环依赖:\n${cycles.map(c => c.join(' → ')).join('\n')}`
));
}
});
五、开发体验:HMR 和多环境配置
HMR 的工作原理
Hot Module Replacement(热模块替换)是开发体验的核心。它的完整流程:
1. 文件保存
2. webpack 增量编译变更的模块(不是全量重编)
3. 生成 hot-update 补丁文件:*.hot-update.js + *.hot-update.json
4. 通过 WebSocket 通知浏览器有更新
5. 浏览器下载补丁
6. 调用旧模块的 dispose 回调(清理副作用,比如取消定时器)
7. 替换模块代码
8. 调用 module.hot.accept 回调(恢复状态、重渲染)
关键是第 8 步,需要模块自己声明如何处理更新:
// 声明"如果 counter.js 更新了,这样处理"
if (module.hot) {
module.hot.accept('./counter', () => {
// 保存当前状态
const prevCount = getCount();
// 重新初始化
cleanup();
initCounter();
// 恢复状态
setCount(prevCount);
});
}
如果没有 module.hot.accept,更新会沿依赖树向上冒泡,直到顶层仍没有 accept 处理,就降级为整页刷新。这就是为什么修改某些文件会触发整页刷新。
多环境配置分离
一个项目通常有三套配置,用 webpack-merge 合并:
config/
├── webpack.base.js # 公共:entry、HTML plugin、DefinePlugin、通用 loader
├── webpack.dev.js # 开发专属:devServer、eval source map、不 hash
└── webpack.prod.js # 生产专属:contenthash、CSS 提取、压缩、splitChunks
// webpack.dev.js
const { merge } = require('webpack-merge');
const base = require('./webpack.base');
module.exports = merge(base, {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
devServer: {
port: 3000,
hot: true,
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true },
},
historyApiFallback: true, // SPA 路由回退
},
});
merge 的关键特性:plugins、rules 等数组字段是追加(append)而不是覆盖,mode、devtool 等标量字段以后者为准。
devServer 常用配置
devServer: {
port: 3000,
hot: true,
// 解决跨域:/api/* → http://localhost:8080/api/*
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true, // 修改请求头的 Host
pathRewrite: { '^/api': '' }, // 可选:去掉 /api 前缀
},
},
// SPA 必须:所有路径回退到 index.html
historyApiFallback: true,
// 额外的静态文件目录(mock JSON、public 资源)
static: [
{ directory: path.join(__dirname, 'public') },
{ directory: path.join(__dirname, 'mock') },
],
// 编译错误时页面覆盖层
client: {
overlay: { errors: true, warnings: false },
},
}
六、性能优化:构建速度和产物体积
性能优化分两个维度,方向完全不同:构建速度(让 webpack 编译得更快)和产物体积(让 dist 里的文件更小)。
构建速度优化
持久化文件缓存(最有效):
cache: {
type: 'filesystem', // 写到磁盘,重启 webpack 仍然有效
cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
buildDependencies: {
// 配置文件变更时自动使缓存失效(很重要,别漏)
config: [__filename],
},
version: '1.0', // 需要手动失效所有缓存时,改版本号
}
第一次构建正常,第二次命中缓存后通常能快 60-80%。CI 环境可以持久化 .webpack-cache 目录,效果尤其显著。
thread-loader(多进程编译):
{
test: /\.js$/,
use: [
{
loader: 'thread-loader',
options: {
workers: require('os').cpus().length - 1,
poolTimeout: 2000, // worker 空闲多久后销毁
},
},
'babel-loader', // 在 worker 进程里运行
],
}
有几个注意点:
- thread-loader 有约 600ms 的进程启动开销,模块数少的小项目反而会变慢
- 只适合 CPU 密集型 loader(babel-loader、ts-loader),IO 密集型没有效果
- 进程间通信有序列化开销,某些返回复杂对象的 loader 可能不兼容
缩小编译范围:
{
test: /\.js$/,
include: path.resolve(__dirname, 'src'), // 只处理 src 目录
// 不加 include 的话,babel 会尝试处理 node_modules 里的所有 js
}
// noParse:跳过已知没有依赖的大文件(不会递归分析它的 import)
module: {
noParse: /jquery|lodash|moment/,
}
产物体积优化
Tree Shaking:删除未使用的导出
前提条件:
- ES Module(
import/export),CommonJSrequire()不支持 optimization.usedExports: true(production 模式自动开启)package.json中配置sideEffects字段
// package.json
{
"sideEffects": [
"./src/side-effects.js", // 这个文件有副作用,不能删
"*.css", // CSS 文件 import 后不需要返回值,但有副作用
"*.scss"
]
}
如果某个文件 import 了但没有使用任何导出,webpack 需要 sideEffects 来判断是否可以安全删除这个 import。sideEffects: false 意味着所有模块都没有副作用,Tree Shaking 最激进。
一个常见误区:Tree Shaking 删的是未使用的 export,不是未使用的代码行。如果整个模块都没有 export 被使用,且没有副作用,整个模块才会被删。
Code Splitting:按需加载
有两种方式触发 code splitting:
splitChunks配置(处理同步 import)- 动态
import()(处理异步按需加载)
动态 import 加上魔法注释:
// webpackChunkName → chunk 文件名(不加的话是数字 id,难以追踪)
// webpackPrefetch → 浏览器空闲时预拉取(<link rel="prefetch">)
// webpackPreload → 与父 chunk 并行加载(<link rel="preload">)
const { renderChart } = await import(
/* webpackChunkName: "chart" */
/* webpackPrefetch: true */
'./modules/chart'
);
Prefetch 和 Preload 的区别经常搞混:
- Prefetch:下次可能用到(空闲时拉取,低优先级)
- Preload:当前页就要用(并行拉取,高优先级,避免过度使用)
splitChunks 的合理配置:
optimization: {
// runtime 单独打包,避免它的变化影响业务 chunk 的 contenthash
runtimeChunk: 'single',
splitChunks: {
chunks: 'all', // 同步 + 异步都参与拆分
minSize: 20_000, // chunk 小于 20KB 不拆(太小的 chunk 反而增加请求数)
cacheGroups: {
vendors: {
test: /node_modules/,
name: 'vendors', // 第三方包独立打包
priority: 10,
// 第三方包变更少,独立后用户不需要因为业务代码变化而重新下载它
},
common: {
minChunks: 2, // 被至少 2 个 chunk 引用才提取成公共 chunk
priority: -10,
reuseExistingChunk: true,
},
},
},
}
压缩优化:
JS 压缩(TerserPlugin 是 webpack 5 默认的,production 模式自动用):
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
drop_console: true, // 生产环境删掉所有 console.*
drop_debugger: true,
},
format: {
comments: false, // 不保留注释(减少体积)
},
},
extractComments: false, // 不生成 *.LICENSE.txt 文件(很烦)
})
CSS 压缩(需要先用 MiniCssExtractPlugin 提取成独立文件):
new CssMinimizerPlugin({ parallel: true })
Scope Hoisting:
production 模式自动开启(optimization.concatenateModules: true)。把多个 ES Module 内联到同一作用域,消除模块包装函数:
// 打包前:每个模块有包装函数
(function(module, exports) { exports.add = n => n + 1; })
// Scope Hoisting 后:直接内联
const add = n => n + 1;
体积减少幅度不大,但消除了模块包装的运行时开销,理论上执行速度有微小提升。要求必须是 ES Module,CommonJS 不受益。
externals + CDN:
大型第三方库走 CDN,不打入 bundle:
externals: {
lodash: '_', // import _ from 'lodash' → 运行时读取 window._
react: 'React',
'react-dom': 'ReactDOM',
}
HTML 中需要手动加 CDN script(或者用 AutoExternalPlugin 自动化)。适合 lodash(~72KB gzip)、dayjs(~7KB)等变更不频繁、有可靠 CDN 的库。
缓存策略总结:
这几个配置配合使用,才能让浏览器缓存最大化发挥:
[contenthash:8] → 内容不变则文件名不变 → 浏览器永久缓存
runtimeChunk → runtime 单独打包,业务代码 hash 稳定
vendors chunk → 第三方包几乎不变,hash 长期不变
七、高级特性:模块联邦和打包库
Module Federation:微前端的核心技术
这是 webpack 5 最重磅的新特性。它解决了一个问题:多个独立部署的前端应用之间,如何在运行时共享代码,而不是各自重复打包。
传统方式:
应用 A 打包了 react + lodash + 自己的代码
应用 B 打包了 react + lodash + 自己的代码
用户访问 A 后再访问 B,需要重新下载 react 和 lodash
Module Federation:
应用 A 提供(exposes)部分组件
应用 B 消费(remotes)应用 A 的组件
react 和 lodash 只需要加载一次(shared singleton)
配置结构:
// Remote(提供方)
new ModuleFederationPlugin({
name: 'remote',
filename: 'remoteEntry.js', // 入口清单文件
exposes: {
'./Button': './src/components/Button',
'./utils': './src/utils/math',
},
shared: {
lodash: { singleton: true }, // 强制单例
},
})
// Host(消费方)
new ModuleFederationPlugin({
name: 'host',
remotes: {
// 'remote' 是别名,对应代码里 import('remote/Button') 的前缀
remote: 'remote@http://localhost:3011/remoteEntry.js',
},
shared: {
lodash: { singleton: true },
},
})
Host 的入口必须异步:
// index.js — 必须这样写
import('./bootstrap'); // 不能直接写业务代码
// bootstrap.js — 实际的业务代码
import { createButton } from 'remote/Button';
原因是 webpack 需要先完成"模块协商"——获取 remoteEntry.js,对比双方 shared 的版本,确定最终加载哪个实例——然后才能执行业务代码。同步加载的话协商还没完成就开始执行,remote 模块会是 undefined。
singleton: true 对 React 和 Vue 是必须的:如果 Host 和 Remote 各自加载了一份 React,会有两个 React 实例,很多功能会报错(比如 hooks 的上下文无法跨越两个 React 实例)。
require.context:自动化注册
这个功能平时很少被提到,但在 Vue 项目里用处很大:
// 替代手动 import 每个组件
const ctx = require.context('./components', false, /\.js$/);
ctx.keys().forEach(key => {
const name = key.replace(/^\.\/(.*)\.js$/, '$1'); // './Button.js' → 'Button'
Vue.component(name, ctx(key).default);
});
同理可以用于:自动聚合 Vuex modules、自动注册路由、自动加载 i18n 语言包。新增一个文件就自动注册,不需要改任何其他文件。
Asset Modules:统一资源处理
webpack 5 用四种内置类型统一了之前 file-loader/url-loader/raw-loader 的工作:
// 选型思路:
// 大文件(图片/字体)→ asset/resource(输出独立文件,HTTP 缓存)
// 小图标(< 8KB) → asset/inline(base64 内联,减少请求数)
// 文本/模板/shader → asset/source(原始文本字符串)
// 不确定大小 → asset(按 maxSize 阈值自动选择)
{
test: /\.(png|jpg|gif)$/,
type: 'asset',
parser: {
dataUrlCondition: { maxSize: 8 * 1024 },
},
generator: {
filename: 'images/[name].[hash:8][ext]',
},
}
打包为库
开发工具库时,output.library 配置 UMD 格式,让用户可以用任何模块系统引用:
output: {
filename: 'my-utils.min.js',
library: {
name: 'MyUtils', // 浏览器全局变量名:window.MyUtils
type: 'umd', // CommonJS + AMD + 全局变量 三合一
export: 'default',
umdNamedDefine: true,
},
globalObject: 'globalThis', // 兼容 Node.js 和浏览器
}
外部化 peer dependencies(不要把 React 打进组件库里):
externals: {
react: {
commonjs: 'react',
commonjs2: 'react',
amd: 'React',
root: 'React',
},
}
webpack 4 → 5 迁移
主要变化的对应关系:
| webpack 4 | webpack 5 | 说明 |
|---|---|---|
file-loader | type: 'asset/resource' | 内置,少一个依赖 |
url-loader | type: 'asset/inline' 或 'asset' | 内置 |
raw-loader | type: 'asset/source' | 内置 |
CleanWebpackPlugin | output.clean: true | 内置 |
cache-loader | cache: { type: 'filesystem' } | 内置,效果更好 |
HashedModuleIdsPlugin | 默认行为 | 默认就是 deterministic |
| Node polyfill 自动注入 | 需要 resolve.fallback | 避免无谓的体积膨胀 |
迁移时最容易踩的坑:Node core polyfill 报错。webpack 4 会自动为 path/crypto/stream 等注入 polyfill,webpack 5 不再这样做。遇到报错时,要么安装 path-browserify 等 polyfill 包并在 resolve.fallback 里配置,要么确认代码是否真的需要这些 Node API。
总结:我的学习路径回顾
回头看这一周,走了一些弯路,也有一些节省时间的地方,记录下来给后来者参考。
先理解的,后来都受益:
- Loader 的 pitch + normal 双阶段:理解了这个之后,看任何 loader 的文档都更容易搞清楚它在链路里的位置
- Tapable 钩子类型:写 plugin 之前先把 tapable-demo.js 跑一遍,比直接看源码效率高很多
- contenthash 和 runtimeChunk:这两个配合使用才能让缓存策略真正生效,单独配一个效果减半
浪费时间的地方:
- 一开始花了很多时间研究 devtool 的各种选项,其实就记两个:开发用
eval-cheap-module-source-map,生产用source-map或false - 企业级 loader 里的
api-version-loader和env-inject-loader实际项目里几乎不会用这种模式,偏学术性,可以跳过 - Module Federation 需要两个 devServer 同时运行才能看到效果,建议先把 Remote 和 Host 各自构建成静态文件,用
npx serve dist启动来测试,比 devServer 更稳定
真正值得深挖的:
- 循环依赖检测的 DFS 实现(ModuleDependencyGraphPlugin),既复习了图算法,也理解了 webpack 内部怎么遍历模块
- splitChunks 的 cacheGroups 配置,直到自己手动调过 priority 和 minChunks 才真正理解每个字段的作用
- Tree Shaking 的 sideEffects 字段,这个很容易配错——漏掉 CSS 文件会导致样式消失
webpack 本身确实复杂,但复杂在于它的可扩展性和通用性。当你需要在构建过程中做任何自定义事情的时候,总能找到对应的钩子或者 loader 扩展点。这种设计思路本身就值得学习。