Webpack 5配置优化指南

张玥 2026年9月19日 阅读时间 80分钟
Webpack 5 构建优化 代码分割 Tree Shaking 持久化缓存 性能调优
前端开发技巧之Webpack 5配置优化指南

“Webpack 配置”这五个字,长期占据着前端工程师恐惧排行榜的前列。它庞大、隐晦、报错信息长到需要翻三屏,一个 loader 顺序写反就能让人排查一整晚。但与此同时,它又是现代前端工程化绕不开的地基:模块解析、代码分割、Tree Shaking、持久化缓存、环境注入、产物分析——这些真正决定项目“跑得快不快、发得稳不稳、改得动不动”的能力,几乎全部由这份配置文件掌控。本篇长文以 Webpack 5 为主线,从核心概念讲起,一路走过基础配置、资源处理、开发体验、生产优化、代码分割、缓存策略、构建性能、产物分析、模块联邦、配置组织与常见坑,最后给出一份可以直接抄走的检查清单。全文配套大量真实配置片段与取舍分析,建议边读边在本地项目里改一遍——80 分钟之后,你会拥有一份属于自己的、能解释清楚每一行的 Webpack 配置。

一、2026 年了,为什么还要学 Webpack 5

每次聊到 Webpack,总有人会问:“现在不是都用 Vite / Rspack / Turbopack 了吗?”这个问题问得没错,但结论下得太早。先看几个事实:

存量项目 大量中大型企业项目的构建体系仍建立在 Webpack 之上,短期不可能整体迁移
插件生态 从 HtmlWebpackPlugin 到 Module Federation,Webpack 的插件与 loader 数量仍然最多
能力深度 细粒度的分包、缓存、模块联邦、自定义依赖图操作,Webpack 依旧是最灵活的
概念通用 Vite 的 rollupOptions、Rspack 的配置,几乎都沿用了 Webpack 的术语体系
面试高频 “webpack 构建流程”“热更新原理”“如何做分包”仍是工程岗的必问项

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 的配置文件看起来很吓人,但它其实只有五个核心概念。把这五个搞懂,剩下的都是它们的组合与细节。点击下面的每个阶段,查看它的职责。

1
entry
入口
构建的起点。Webpack 从这里开始,沿着 import / require 递归解析,最终构建出完整的依赖图。入口可以有多个,用于多页应用或分离 vendor。
2
output
输出
告诉 Webpack 把产物写到哪里、叫什么名字。filename 控制 chunk 文件名,path 控制目录,publicPath 控制运行时资源引用的前缀。文件名里的 hash 是缓存策略的关键。
3
loader
模块转换
Webpack 只认识 JavaScript 和 JSON,其他一切(CSS、图片、字体、TS、Vue 单文件)都要靠 loader 转换成模块。loader 从右向左、从下向上执行,顺序写错是最常见的错误来源。
4
plugin
插件
loader 负责“转换单个文件”,plugin 负责“干预整个构建流程”。它通过钩子(hooks)在构建的不同阶段插入逻辑,例如生成 HTML、抽取 CSS、压缩产物、分析体积。
5
mode
模式
development 会开启可读的模块 ID、较快的构建与完整的 source map;production 会自动开启 Tree Shaking、代码压缩、作用域提升等优化。不要手动设置 NODE_ENV,交给 mode。
6
resolve
解析规则
控制“怎么找到模块”:extensions 决定省略后缀时的查找顺序,alias 提供路径别名,modules 指定 node_modules 之外的查找目录。写得越精确,解析越快。
7
optimization
优化策略
Webpack 5 优化能力的集中地:splitChunks 控制分包,runtimeChunk 抽离运行时代码,minimizer 指定压缩器,usedExports 与 sideEffects 驱动 Tree Shaking。
8
devtool
source map
决定生成哪种 source map。开发环境追求“快且准”,生产环境追求“不泄露源码且能定位错误”。这是构建速度与调试体验之间最直接的取舍旋钮。

构建流程:从入口到产物

把上面的概念串起来,一次完整的构建大致经历四个阶段:

① 初始化
读取配置 → 创建 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

推荐的项目结构:

project/
├── 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 path = require('path');
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

// webpack.dev.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,
  },
});
// webpack.prod.js
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,执行顺序从右到左:

3
postcss-loader
最先执行
处理现代 CSS 语法:autoprefixer 自动补前缀、postcss-preset-env 转换新语法、cssnano 压缩。它负责“把 CSS 变成兼容性更好的 CSS”。
2
css-loader
中间执行
把 CSS 文件转成 JS 模块,解析 @import 与 url(),处理 CSS Modules 的局部作用域。它不负责把样式插入页面。
1
style-loader
最后执行
把 css-loader 产出的样式字符串,通过 <style> 标签注入到 DOM。开发环境用它图快,生产环境要换成 MiniCssExtractPlugin.loader。
★
MiniCssExtract
生产替代方案
把 CSS 抽成独立文件,通过 <link> 引入。好处是能与 JS 并行下载、可被浏览器缓存、避免 FOUC(样式闪烁)。
// 开发环境:style-loader
{
  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 通用图片处理
// 图片:小于 8KB 内联,否则输出文件
{
  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 的关键配置

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(摇树优化)的目标是删除未被使用的导出代码。但它不是自动的,必须同时满足三个条件:

条件一 代码必须是 ES Module 语法(import / export),CommonJS 的动态特性无法静态分析
条件二 必须处于 production 模式,或手动开启 optimization.usedExports
条件三 模块必须被标记为“无副作用”,即 package.json 中的 sideEffects 字段

第一条最常见的破坏方式是 babel 把 ESM 转成了 CommonJS。解决办法是在 Babel 配置里关闭模块转换:

// babel.config.js
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 TerserPlugin = require('terser-webpack-plugin');
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:

compress: {
  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
SplitChunks 把多个 chunk 共享的模块抽出来,避免重复打包

动态导入:把路由变成按需加载

// React 路由懒加载
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 引用的模块抽出来。要真正控制分包,需要显式配置:

optimization: {
  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:解决缓存失效的最后一块拼图

optimization: {
  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 和变化的主包,第三方库全部命中缓存。

过度分包
把所有第三方库拆成 20 个小 chunk。结果是首屏要发 20 多个请求,在 HTTP/1.1 下排队严重,首屏反而更慢。
合理分包
按“变更频率”分组:框架一层、工具库一层、业务公共一层。通常 3~6 个 vendor chunk 是比较舒服的区间。

八、缓存策略:让用户只下载变化的部分

前端缓存分为两层:HTTP 缓存决定浏览器要不要请求,文件名 hash决定请求到的是不是新文件。Webpack 的职责是第二层。

三种 hash 的区别

类型 计算粒度 特点
[hash] 整个 compilation 任何一个文件变化都会导致所有文件 hash 变化,已不推荐
[chunkhash] 单个 chunk 同一 chunk 内变化会互相影响,CSS 抽离时容易出问题
[contenthash] 文件内容 内容不变则 hash 不变,长期缓存的标准答案
output: {
  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 算法解决了这个问题:

optimization: {
  moduleIds: 'deterministic',
  chunkIds: 'deterministic',
},

这两个选项在 production 模式下已经是默认值,一般不需要手动配置。但在某些自定义插件的干扰下可能被覆盖,所以值得在配置里显式声明一遍,作为“防回归”的保险。

完整的缓存策略组合

// 服务端 Nginx 参考配置
# 带 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 上。

验证缓存是否真的生效

配置完成后,做一次简单验证:

  1. 构建一次,记录所有产物的文件名。
  2. 只修改一个业务组件里的文案。
  3. 再次构建,对比文件名。

理想结果:只有包含该组件的那个 chunk 与 runtime 变了,react-vendor、vendor、CSS 文件的 hash 完全不变。如果发现 vendor 也变了,说明 runtime 没有正确抽离,或者 splitChunks 配置有问题。

九、构建性能优化:把 90 秒压到 9 秒

项目变大之后,构建时间会从“可以忍受”变成“无法忍受”。优化构建性能要分两步:先量化,再优化。不要凭感觉猜瓶颈。

第一步:测量

# 输出每个 plugin 与 loader 的耗时
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 收益最大的单项优化,没有之一。开启后,二次构建只需重新编译变化的模块:

cache: {
  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 最好 最低,包一层即可
// 方案一:esbuild-loader 替换 babel-loader
{
  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 反而会更慢。

第四步:缩小处理范围

exclude 永远排除 node_modules,除非你确实需要编译某个依赖
include 用 include 精确限定源码目录,比 exclude 更高效
resolve 把 extensions 精简到实际用到的后缀,减少文件系统探测
alias 为深层路径设置别名,避免 Webpack 逐层向上查找
noParse 对体积大且无依赖的库(如某些 UMD 包)跳过解析
module: {
  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,
},

一份可参考的优化顺序

  1. 先测速,找出真正的瓶颈。
  2. 开启 cache: { type: 'filesystem' }——收益最大,成本最低。
  3. 检查 include / exclude 是否正确,避免编译 node_modules。
  4. 精简 resolve.extensions 与配置 alias。
  5. 把 Babel 换成 esbuild 或 SWC。
  6. 开发环境关闭压缩与分包。
  7. 最后才考虑 thread-loader、parallel 等多线程方案。

十、产物分析:让体积问题无处可藏

三种分析视角

treemap 矩形面积代表体积,最直观地看出“谁最大”
sunburst 环形层级视图,适合看目录嵌套关系
network 按 chunk 分组,看每个 chunk 的组成明细
stat 纯文本输出,适合 CI 中做体积门禁
// webpack-bundle-analyzer 配置
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:

// webpack.prod.js 中设置性能提示
performance: {
  hints: 'error',
  maxEntrypointSize: 512 * 1024,  // 入口文件超过 512KB 报错
  maxAssetSize: 1024 * 1024,    // 单个资源超过 1MB 报错
  assetFilter: (filename) => !/\.map$/.test(filename),
},

更精细的做法是使用 size-limit 或 bundlesize,为每个 chunk 设置独立的阈值,并在 PR 中自动评论体积变化。

首屏资源体积参考(gzip 后) 中型 Web 应用
框架 ~45KB
路由/状态 ~35KB
业务代码 ~70KB
样式 ~30KB
余量
首屏 JS 总量控制在 200KB(gzip)以内是比较健康的水平。超过 300KB 时,在中低端手机上的解析与执行时间会明显拖慢可交互时间(TTI)。注意:这只是参考值,内容型站点应更小,工具型应用可以适当放宽。

十一、Module Federation:运行时的模块共享

Module Federation(模块联邦)是 Webpack 5 最具颠覆性的特性。它解决的是一个老问题:多个独立构建、独立部署的应用,如何共享代码?

和传统方案的区别

npm 依赖
构建时共享
升级需重新发布
CDN 外链
运行时共享
但版本难管理
Module Federation
运行时共享
版本可协商
iframe
完全隔离
通信成本高

两个角色:Host 与 Remote

// 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 },
    },
  }),
],
// host 应用:消费远程模块
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 行后就会变得难以维护。标准做法是拆成三份:公共、开发、生产。前面第三节已经展示了基本形态,这里补充几个细节:

// webpack.common.js 导出函数,接收环境变量
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',
    },
  };
};

环境变量注入

// .env 文件
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 编译期字符串替换,不是运行时变量
注意 值必须 JSON.stringify,否则会被当成代码片段
安全 不要把密钥写进前端环境变量,产物里能直接搜到
Tree Shaking 配合 if (process.env.NODE_ENV === 'production') 可摇掉整个分支

关于最后一条:DefinePlugin 的替换发生在编译期,所以下面这段代码在生产构建后,if 里的整块代码都会被 Terser 删除,因为条件永远为假:

if (process.env.NODE_ENV !== 'production') {
  // 生产构建后,这段代码整个消失
  console.log('debug info');
  window.__DEVTOOLS__ = { /* ... */ };
}

多页面应用的配置

const pages = ['index', 'about', 'contact'];

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,多页应用就退化成了单页的加载量。

十三、十二个最常见的坑

坑 1 loader 顺序写反。记住:从右到左、从下到上,style-loader 永远在最前面
坑 2 忘记在 Babel 里设置 modules: false,导致 Tree Shaking 完全失效
坑 3 把 CSS 文件从 sideEffects 白名单里漏掉,样式被整包摇掉
坑 4 用 [hash] 而不是 [contenthash],改一个字就让全部缓存失效
坑 5 没有抽离 runtimeChunk,导致 vendor 的 hash 每次构建都变
坑 6 生产环境把 source-map 部署上线,源码全部泄露
坑 7 publicPath 配错,部署到子目录后所有资源 404
坑 8 mini-css-extract-plugin 与 style-loader 同时使用,CSS 被注入两次
坑 9 忘记配置 devServer.historyApiFallback,刷新子路由直接 404
坑 10 DefinePlugin 的值没有 JSON.stringify,编译出的代码语法错误
坑 11 给所有 loader 都套上 thread-loader,构建反而更慢
坑 12 升级到 Webpack 5 后 Node 内置模块报错,因为没有手动安装 polyfill

三个值得展开的坑

坑 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 isProd = process.env.NODE_ENV === 'production';
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 先打包再启动。开发服务器需要先构建依赖图,项目越大启动越慢
Vite 开发时利用浏览器原生 ESM,按需编译;生产用 Rollup 打包
Rspack 用 Rust 重写 Webpack 的核心,API 与配置基本兼容,速度快 5~10 倍
Turbopack 面向 Next.js 的增量构建引擎,目前生态相对封闭
冷启动
Vite 领先
HMR 速度
Vite / Rspack 领先
生产构建速度
Rspack 领先
生态与灵活性
Webpack 领先

什么情况继续用 Webpack

  • 项目依赖大量 Webpack 专属插件,迁移成本高于收益。
  • 需要 Module Federation 做微前端,且对共享模块的版本协商有精细要求。
  • 需要深度定制构建流程,比如自定义依赖图操作、自定义 resolver。
  • 团队对 Webpack 已经非常熟悉,切换技术栈会带来学习成本与风险。
  • 需要支持一些老旧的浏览器或特殊的构建目标。

什么情况值得考虑迁移

  • 开发体验成为团队痛点,冷启动动辄两三分钟。
  • 项目是标准的 Vue / React 应用,没有复杂的自定义构建逻辑。
  • 已经用上了 Vite 生态的插件,或者愿意承担迁移成本。
  • 想要更简洁的配置,不想再维护几百行的 webpack.config.js。

一条务实的迁移路径

如果你决定迁移,不建议一次性重写。更稳妥的路径是:

第一步 新项目直接用 Vite / Rspack,老项目保持不动
第二步 把老项目里的自定义构建逻辑抽成独立包,减少对 Webpack API 的直接依赖
第三步 尝试把 Webpack 换成 Rspack——配置兼容度最高,迁移成本最低
第四步 确认构建产物、缓存策略、错误监控都正常后,再考虑是否继续迁移到 Vite

不管用哪个工具,前面十几节讲的原理都是通用的:入口与依赖图、loader 与转换、代码分割、内容哈希与缓存、Tree Shaking、体积预算。这些概念在 Vite 里叫 build.rollupOptions,在 Rspack 里叫 optimization.splitChunks,本质是同一套东西。

所以,学 Webpack 不只是学一个工具,而是学“构建”这件事本身。工具会换,原理不会。当你能一眼看出“这个 chunk 为什么这么大”“这次改动为什么让缓存全失效”“首屏为什么多等了 800 毫秒”的时候,你掌握的就是可迁移的能力。