webpack 的辉煌与瓶颈
webpack 的辉煌与瓶颈
写在前面
上一篇讲了前端构建工具的整体演进,这一篇聚焦 webpack——它曾经是事实标准,现在依然是存量最大的项目在用。理解 webpack 的核心架构,既是为了用好它,也是为了后面理解 Rspack / Vite 的设计思路。
一、webpack 的核心架构
1.1 一句话理解
webpack 把一切资源当作模块,从入口出发建立依赖图,然后把模块打包成静态资源。
1.2 四大核心概念
| 概念 | 作用 |
|---|---|
| Entry | 入口,依赖图的起点 |
| Output | 输出,告诉 webpack 打包到哪里 |
| Loader | 转换器,把 webpack 不识别的文件转成 JS 模块 |
| Plugin | 插件,在 webpack 生命周期的各个阶段注入逻辑 |
1.3 一个完整的 webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
clean: true,
publicPath: '/',
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
},
{
test: /\.(png|jpg|gif|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024,
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
}),
],
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
},
},
},
runtimeChunk: 'single',
},
cache: {
type: 'filesystem',
},
mode: 'production',
};
二、Loader 机制
2.1 Loader 是什么
Loader 是函数,接收源文件内容,返回转换后的内容。
// 最简单的 loader
module.exports = function(source) {
return source.replace(/foo/g, 'bar');
};
webpack 默认只认识 JS 和 JSON,其他文件(CSS、图片、字体、TS、Vue SFC)都需要 loader 转换。
2.2 Loader 链
多个 loader 串联,顺序很重要——从右往左执行(或从下往上):
// 顺序:postcss-loader → css-loader → style-loader
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'],
}
处理流程:
.css 文件 → postcss-loader(加浏览器前缀)
→ css-loader(把 CSS 转成 JS 模块)
→ style-loader(把 CSS 注入到 <style>)
2.3 常用 Loader
| Loader | 用途 |
|---|---|
babel-loader |
ES6+ → ES5,JSX → JS |
ts-loader |
TypeScript → JS |
css-loader |
CSS → JS 模块 |
style-loader |
注入 CSS 到 DOM |
MiniCssExtractPlugin.loader |
提取 CSS 到文件 |
postcss-loader |
PostCSS(autoprefixer 等) |
sass-loader |
Sass → CSS |
less-loader |
Less → CSS |
file-loader / asset/resource |
文件输出 |
url-loader / asset/inline |
转 base64 |
vue-loader |
Vue SFC |
svgr-loader |
SVG → React 组件 |
2.4 写一个自定义 Loader
// markdown-loader.js
const marked = require('marked');
module.exports = function(source) {
const callback = this.async();
// 异步处理
marked(source, (err, result) => {
if (err) return callback(err);
// 把 markdown 转成 JS 模块,导出 HTML 字符串
callback(null, `module.exports = ${JSON.stringify(result)};`);
});
};
module.exports.raw = false; // 输入是字符串,不是 Buffer
// webpack.config.js
{
test: /\.md$/,
use: './markdown-loader.js',
}
// 代码里
import readme from './README.md';
console.log(readme); // HTML 字符串
三、Plugin 机制
3.1 Plugin 是什么
Plugin 是 webpack 生命周期的钩子函数,可以做任何 loader 做不了的事情。
// 一个最简单的 plugin
class MyPlugin {
apply(compiler) {
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('Build complete!');
});
}
}
module.exports = MyPlugin;
3.2 生命周期
webpack 的编译流程:
初始化参数 → 创建 compiler → 加载 plugin
↓
开始编译 → 从 entry 找到依赖模块
↓
编译模块(用 loader 转换)
↓
完成模块编译
↓
输出资源(根据 dependency graph 合并 chunk)
↓
写入文件
3.3 核心 hook(Tapable)
webpack 用 Tapable 管理 hook:
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
compilation.hooks.optimize.tap('MyPlugin', () => {
console.log('Optimizing chunks');
});
});
| Hook | 阶段 |
|---|---|
compiler.hooks.beforeRun |
启动前 |
compiler.hooks.compile |
编译前 |
compiler.hooks.compilation |
compilation 创建 |
compilation.hooks.buildModule |
模块编译 |
compilation.hooks.optimize |
优化 |
compilation.hooks.processAssets |
处理资源(webpack5) |
compiler.hooks.emit |
输出(webpack4) |
compiler.hooks.done |
完成 |
3.4 实战:写一个 build 时间统计 plugin
// build-time-plugin.js
class BuildTimePlugin {
apply(compiler) {
const startTime = Date.now();
compiler.hooks.done.tap('BuildTimePlugin', (stats) => {
const time = Date.now() - startTime;
const hash = stats.hash;
const warnings = stats.compilation.warnings.length;
const errors = stats.compilation.errors.length;
console.log(`Build completed in ${time}ms`);
console.log(`Hash: ${hash}`);
console.log(`Warnings: ${warnings}, Errors: ${errors}`);
// 把构建时间写到文件
const fs = require('fs');
const path = require('path');
fs.writeFileSync(
path.join(compiler.options.output.path, 'build-info.json'),
JSON.stringify({ time, hash, warnings, errors }, null, 2)
);
});
}
}
module.exports = BuildTimePlugin;
四、模块联邦(Module Federation)
4.1 解决的问题
微前端 / 多应用共享代码。
// 应用 A (Host)
const RemoteButton = React.lazy(() => import('remoteApp/Button'));
// 应用 B (Remote)
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
});
应用 A 在运行时去应用 B 加载 Button 组件,实现跨应用共享代码。
4.2 实战:共享 UI 组件库
// 远程模块(组件库)
new ModuleFederationPlugin({
name: 'uiLibrary',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./Modal': './src/components/Modal',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
// 主应用
new ModuleFederationPlugin({
name: 'mainApp',
remotes: {
uiLibrary: 'uiLibrary@https://cdn.company.com/ui/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
},
});
五、splitChunks 与代码分割
5.1 为什么需要代码分割
如果不分割,所有代码打成一个 bundle.js:
bundle.js 5.2 MB
首屏加载慢,用户等了 30 秒白屏。
分割后:
main.chunk.js 200 KB ← 首屏需要
vendor.chunk.js 1.5 MB ← 第三方库(React、Lodash)
home.chunk.js 300 KB ← 首页路由
admin.chunk.js 1.2 MB ← 后台路由(用户不进就不加载)
5.2 webpack 默认行为
optimization: {
splitChunks: {
chunks: 'all', // async + initial 都分割
minSize: 20000, // 最小 20KB 才分割
minChunks: 1, // 至少被 1 个 chunk 引用
maxAsyncRequests: 30,
maxInitialRequests: 30,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
}
5.3 实战:精细化分包
splitChunks: {
cacheGroups: {
// React 全家桶 - 一个 chunk
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: 'react-vendor',
priority: 30,
},
// 工具库
utils: {
test: /[\\/]node_modules[\\/](lodash|date-fns|dayjs|axios)[\\/]/,
name: 'utils',
priority: 20,
},
// UI 库
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'ui-vendor',
priority: 25,
},
// 其他第三方
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
// 公共业务代码
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
效果:首屏 chunk 从 5MB 降到 200KB,首屏加载从 30 秒降到 2 秒。
5.4 动态导入
// 路由级别代码分割
const Home = lazy(() => import('./pages/Home'));
const Admin = lazy(() => import('./pages/Admin'));
const Settings = lazy(() => import('./pages/Settings'));
// 组件级别
const HeavyChart = lazy(() => import('./components/HeavyChart'));
// 条件加载
if (user.isAdmin) {
const { AdminPanel } = await import('./AdminPanel');
}
webpack 自动把 import() 拆成独立的 chunk。
六、Tree Shaking
6.1 原理
Tree Shaking 是消除死代码(Dead Code Elimination)。
前提:代码必须是 ES Module(静态结构)。
// 静态 ES Module - 可以 tree shake
import { add } from './math'; // 静态分析知道只用了 add
// CommonJS - 不能 tree shake
const { add } = require('./math'); // 运行时才知道用了什么
6.2 sideEffects 标记
在 package.json 里声明模块无副作用:
{
"name": "my-lib",
"sideEffects": false
}
sideEffects: false 告诉 webpack:这个包的所有代码都是纯粹的(没有 import './style.css' 之类的副作用),可以安全 tree shake。
6.3 实战:从 lodash 改成 lodash-es
// NG - 不能 tree shake
import _ from 'lodash';
_.debounce(fn, 100);
// OK - 能 tree shake
import { debounce } from 'lodash-es';
debounce(fn, 100);
lodash-es 用 ES Module 写,webpack 知道只用了 debounce,其他函数全删掉。
打包结果:
lodash: 70KB
lodash-es(按需): 2KB
6.4 webpack 的副作用分析
module: {
rules: [
{
test: /\.js$/,
sideEffects: false, // 标记整个目录的 JS 都无副作用
},
],
},
七、实战:大型 SPA 启动 90s → 5s
7.1 问题描述
公司内部一个老项目(运营管理系统):
- 冷启动:90 秒
- HMR:45 秒
- node_modules:3.5GB
- 构建产物:5.2MB(未压缩)
7.2 第一步:升级 webpack 版本
webpack 4 → webpack 5。
webpack 5 的改进:
- 持久化缓存(默认开启 filesystem cache)
- 更好的 Tree Shaking
- Module Federation 内置
- 资源模块(asset modules,替代 file-loader/url-loader)
冷启动:90 秒 → 50 秒。
7.3 第二步:优化 Loader
// 之前
{
test: /\.tsx?$/,
use: 'ts-loader',
}
// 之后 - 用 transpileOnly
{
test: /\.tsx?$/,
use: {
loader: 'swc-loader', // 比 ts-loader 快 5 倍
options: {
// swc 配置
jsc: {
parser: { syntax: 'typescript', tsx: true },
target: 'es2020',
},
},
},
}
冷启动:50 秒 → 25 秒。
7.4 第三步:优化 resolve
resolve: {
// 减少文件搜索
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
extensions: ['.tsx', '.ts', '.js', '.jsx'], // 不要太多
alias: {
'@': path.resolve(__dirname, 'src'),
// 避免大模块的深层查找
'lodash': 'lodash-es',
},
},
冷启动:25 秒 → 20 秒。
7.5 第四步:分包 + Tree Shaking
(前面讲过,这里应用)
main.chunk.js 800KB
react-vendor.js 450KB
ui-vendor.js 900KB
utils.js 200KB
admin.js 1.5MB
chart.js 900KB
common.js 400KB
7.6 第五步:缓存
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename], // 配置文件改了才清缓存
},
},
// babel-loader 缓存
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false, // 不压缩缓存,加快读取
},
}
二次冷启动:5 秒(从 20 秒降到 5 秒)。
7.7 第六步:thread-loader
{
test: /\.tsx?$/,
use: [
{
loader: 'thread-loader',
options: { workers: 4 }, // 4 个 worker 并行
},
{
loader: 'swc-loader',
// ...
},
],
}
把耗时的 loader 任务并行化。
冷启动:5 秒 → 3.5 秒。
7.8 最终效果
| 阶段 | 冷启动 | HMR | 产物 |
|---|---|---|---|
| 原始 | 90s | 45s | 5.2MB |
| webpack 5 | 50s | 25s | 4.8MB |
| swc-loader | 25s | 12s | 4.5MB |
| resolve 优化 | 20s | 10s | 4.5MB |
| 持久化缓存 | 5s | 3s | 4.5MB |
| thread-loader | 3.5s | 2s | 4.5MB |
90 秒压到 3.5 秒,提升 25 倍——全是配置和缓存优化。
八、webpack 的局限
8.1 JS 实现的天花板
webpack 是 JavaScript 实现,Node.js 单线程。CPU 密集任务无法突破。
8.2 内存占用大
处理大项目时,webpack worker 占用 2-4GB 内存。
8.3 配置复杂
新手配置 webpack 像在背天书。
8.4 生态老化
很多老插件不维护,新工具也不出 webpack 版本。
这些是 Rspack / Vite 解决的核心问题。
九、本篇小结
| 主题 | 关键点 |
|---|---|
| Loader | 文件转换,从右往左 |
| Plugin | 生命周期 hook |
| splitChunks | 拆分代码,首屏最小 |
| Tree Shaking | ES Module + sideEffects 标记 |
| Module Federation | 跨应用共享代码 |
| 持久化缓存 | filesystem cache 必备 |
| thread-loader | 多核并行 |
下一篇讲 esbuild 原理与速度革命——为什么 Go 写编译器这么快。