Webpack5 进阶思考:那些官方文档没讲清楚的事

0 阅读7分钟

Webpack5 进阶思考:那些官方文档没讲清楚的事

本文不是配置手册,而是我在学习 Webpack 过程中的一些深度思考。很多问题官方文档一笔带过,但实际项目中却频繁踩坑。


1. Webpack 本身并不慢,慢的是配置不当

很多人抱怨 Webpack 打包慢,但真正慢的原因往往不是 Webpack 本身。

CRA(create-react-app)默认把所有东西打到一个 bundle 里,这才是主包 2.5MB 的原因。

我曾经以为 Webpack 打包大是理所当然的,直到深入了解 splitChunks 后才明白:正确配置和不配置的差异,可能是几十倍的体积差距。

1.1 分割前 vs 分割后

实际项目中一次优化结果:

指标优化前优化后
主包体积2.5MB300KB
首次加载2.5MB500KB
缓存命中率

所以遇到打包体积大的问题,先问自己:配置 splitChunks 了吗?

1.2 快速验证方法

打包后查看 dist/static/js 目录:

  • 没配置 splitChunks:只有一个 main.js,包含所有代码
  • 正确配置 splitChunks:有 main.jschunk-react.jschunk-libs.js 等多个文件

2. splitChunks 原理:为什么能把 node_modules 自动识别并分离?

这个问题官方文档没有详细解释,但我通过实践理解到了它的原理:

2.1 核心原理

Webpack 的 splitChunks 之所以能自动识别 node_modules 中的包并分离,依赖于三个机制:


1. 构建依赖图:Webpack 知道每个模块是"谁引入的"
2. 正则匹配模块路径:通过 cacheGroups 中的 test 正则匹配模块路径
3. 路径含 node_modules:所以把同组的打包成单独的文件

2.2 代码示例


optimization: {
  splitChunks: {
    chunks: 'all',        // 'all'=同步+异步 'async'=只分割异步 import() 'initial'=同步
    minSize: 20000,       // 模块最小 20KB 才分割
    maxSize: 244000,      // 单个 chunk 最大 244KB(超过则尝试再分割)
    cacheGroups: {
      // 默认的 vendor 组
      defaultVendors: {
        test: /[\/]node_modules[\/]/,
        priority: -10,    // 优先级,数字越大越先匹配
        reuseExistingChunk: true,  // 已经被分割过的模块不再重复分割
      },
      // 自定义组 - antd 单独打包
      antd: {
        test: /[\/]node_modules[\/](antd|@ant-design)[\/]/,
        name: 'antd',
        priority: 20,     // 比 defaultVendors 的 -10 高,先匹配
        chunks: 'all'
      }
    }
  }
}

2.3 理解 chunks 选项

选项作用适用场景
'all'同步和异步代码都分割常用,推荐
'async'只分割异步 import() 的代码路由懒加载为主
'initial'只分割同步代码不推荐,可能阻断早期加载

推荐使用 'all' ,能覆盖更多场景。

2.4 理解 priority 机制

priority: 20 > priority: -10,所以匹配时:

  1. 先检查路径是否匹配 antd 的正则(高优先级)
  2. 如果匹配,打包成 antd chunk
  3. 如果不匹配,继续检查 defaultVendors

reuseExistingChunk: true 也很关键:如果一个模块已经被单独分割过(比如 react 被分割了一次),后续不再重复分割,避免重复打包。


3. Tree-Shaking 会不会删错代码?

会! 这个问题很多人不知道。

3.1 原理与问题

Webpack 分析 import 语句时,只能看到显式的 import/export。但有些代码虽然没有 export 被使用,却有副作用:


// 自动监听页面事件,有没有显式调用都会执行
window.addEventListener('scroll', () => {...});
​
// 扩展原生对象
Array.prototype.unique = function() {...};

Webpack 以为这些代码"没被引用"就删掉了,但实际上它们运行时会产生问题。

3.2 解决方案:sideEffects

package.json 中声明哪些文件"没用但不能删":


{
  "sideEffects": [
    "*.css",
    "./src/some副作用文件.js"
  ]
}

这个配置的意思是:即使这些文件被 import 但没有被使用,也不要 tree-shake 掉它们。

3.3 CSS 一定不能被删除


"sideEffects": ["*.css"]

这是必须的,因为 CSS 文件虽然被 import,但通常没有实际被使用的导出。

3.4 如何验证 Tree-Shaking 是否生效?

mode: 'production' 下:


// 方式1:检查打包后的代码
// 如果 tree-shaking 生效,unused 的代码会被移除// 方式2:使用 webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
​
plugins: [
  new BundleAnalyzerPlugin()
]

3.5 Tree-Shaking 的前提条件

  1. 必须使用 ES6 模块语法importexport,不能用 require()
  2. 必须开启 production 模式mode: 'production'
  3. 不能使用 CommonJS 模块:因为 CommonJS 是动态的,静态分析无法确定引用关系

4. Loader 执行顺序:你可能一直理解错了

4.1 从右到左,但 pitch 是从左到右


use: ['style-loader', 'css-loader', 'less-loader']

执行顺序是:less-loader → css-loader → style-loader

为什么?

Webpack 内部用 for 倒着遍历 use 数组,最终生成的代码大概是:


styleLoader(cssLoader(lessLoader(source)))

所以 less-loader(最右)要先执行,style-loader(最左)最后执行。

4.2 pitch 的特殊行为

Loader 还有 pitch 函数,它的执行顺序是从左到右


pitch: style-loader → css-loader → less-loader
执行: less-loader   ← css-loader  ← style-loader

// loader 的 pitch 函数
function loader(content) {
  return content;
}
​
loader.pitch = function(remainingRequest, previousRequest) {
  // pitch 函数先执行
  console.log('pitch:', remainingRequest);
};
​
module.exports = loader;

这个细节很容易忽略,但理解后就能解释一些奇怪的问题。

4.3 亲身踩坑:comment-loader 放在 babel-loader 前面不生效

我写了一个 comment-loader 放在 babel-loader 前面,结果注释没有成功显示:


// 错误配置
use: ['comment-loader', 'babel-loader', 'css-loader']
​
// 正确配置
use: ['babel-loader', 'comment-loader', 'css-loader']

原因是 babel 会将 ES6 代码转换为 ES5,并且会删除或转换注释,导致 comment-loader 拿不到原始注释。

4.4 多个 rules 的执行顺序

rules 数组是从下到上执行的:


rules: [
  {
    test: /.js$/,
    loader: 'eslint-loader'  // 后定义,先执行
  },
  {
    test: /.js$/,
    loader: 'babel-loader'   // 先定义,后执行
  }
]

5. Plugin vs Loader:本质区别

这个问题很多人能答上来,但不够深入。

特性LoaderPlugin
作用对象单个文件整个打包过程
执行时机转换文件时打包的各个生命周期
返回值转换后的内容无直接返回值,通过修改 compilation.assets
配置方式module.rulesplugins 数组

5.1 Plugin 的本质

Plugin 通过钩子机制拦截 Webpack 的生命周期:


class TimeWebpackPlugin {
  apply(compiler) {
    // compiler.hooks.emit.tap 注册钩子
    // 'TimeWebpackPlugin' 是插件名称,用于调试
    compiler.hooks.emit.tap('TimeWebpackPlugin', (compilation) => {
      // emit 钩子在资源输出之前触发
      Object.keys(compilation.assets).forEach(filename => {
        const source = compilation.assets[filename].source()
        const time = new Date().toLocaleString()
        compilation.assets[filename] = {
          source: () => `/*build at: ${time}*/\n${source}`,
          size: () => source.length + time.length + 20
        }
      })
    })
  }
}
​
module.exports = TimeWebpackPlugin;

5.2 常用钩子详解

钩子时机典型用途
compile开始编译前修改编译选项
compilation编译创建完成监听模块编译
emit输出 asset 之前修改输出内容、生成分析报告
afterEmit输出 asset 之后清理操作、发送通知
done编译完成统计构建时间、输出信息

5.3 compiler vs compilation

  • compiler:包含完整的 Webpack 配置信息,代表整个编译过程
  • compilation:代表每一次编译生成的结果,包含当前的模块资源

compiler.hooks.emit.tap('Plugin', (compilation) => {
  // compilation.assets 是即将输出的文件
  // 可以读取、修改、甚至删除这些文件
});

5.4 emit 钩子何时触发?

所有模块编译完成,资源文件已生成,准备写入磁盘之前。

这是最常用的钩子,因为此时所有资源文件的内容都已经确定,但还没有真正写入磁盘,还可以修改。


6. 多进程打包:你可能正在帮倒忙

6.1 官方推荐的 thread-loader


{
  test: /.js$/,
  use: [
    {
      loader: 'thread-loader',
      options: { works: os.cpus().length }
    },
    {
      loader: 'babel-loader',
      options: { cacheDirectory: true }
    }
  ]
}

6.2 但小型项目可能更慢

我在实践中发现,多进程打包有三个问题:

  1. 进程启动开销大于编译时间:小型项目编译本身很快,但启动进程的开销反而更大
  2. Babel 缓存失效:单线程 babel 缓存后只编译一次,但多线程需要每个 worker 都重新加载
  3. 内存占用翻倍:多进程会占用更多内存

6.3 建议

项目规模编译时间建议
小型项目< 10s不使用多进程
中型项目10-30s谨慎使用
大型项目> 30s使用多进程

简单判断:如果你用 time npm run build 发现编译时间不到 30 秒,多进程可能反而更慢。


7. HMR 失效:可能是 liveReload 惹的祸

7.1 问题现象

开发时修改代码,热更新没有生效,页面直接刷新了。

7.2 原因分析


文件变化
    │
    ▼
webpack-dev-server 收到通知
    │
    ▼
liveReload 触发页面刷新 ──→ 页面重新加载,JS 上下文丢失
    │
    │ (同时)
    ▼
HMR 尝试更新模块 ──→ 但上下文已被刷新,来不及执行

HMR 和 liveReload 同时存在时会冲突。

7.3 解决方案

关闭 liveReload,只保留 HMR:


devServer: {
  hot: true,
  liveReload: false  // 关闭 liveReload
}

7.4 HMR 的完整流程


1. 监听文件变化(watchpack)
2. 通过 WebSocket 通知客户端(webpack-dev-server)
3. 客户端请求新模块(XHR)
4. 新模块替换旧模块(module.hot.accept)
5. 执行回调函数更新视图

8. Source Map:线上出问题怎么快速定位?

8.1 Source Map 实现原理

  1. 生成映射表:编译时记录每个输出字节位置与源文件、行列的对应关系,生成 .map 文件
  2. VLQ 编码压缩:映射文件使用 VLQ 编码,将海量的映射信息压缩
  3. 浏览器解析:DevTools 读取 .map 文件,当你在压缩代码断点上看到的信息实际来自源代码

8.2 不同模式的对比

模式编译速度调试质量适用环境
eval最快开发
cheap-module-source-map开发
source-map最好生产
hidden-source-map最好生产(内网)
nosources-source-map生产(安全)

8.3 线上 Source Map 的安全风险

有安全风险。source-map 会把完整源码暴露,包括变量名、注释、业务逻辑。

8.4 正确的生产环境做法

方案 A:使用 hidden-source-map + 内网部署


devtool: 'hidden-source-map'

.map 文件只部署在内部私有服务器,外网无法访问。定位时需要:

  1. 连接内网 VPN
  2. 用工具还原源码

方案 B:使用错误监控工具(推荐)


devtool: 'hidden-source-map'

上传到 Sentry 等监控系统,它们会自动解析报错并还原源码,界面直接显示错误位置。

8.5 为什么不能用 production 的 source-map?

因为 .map 文件会被部署到外网,任何人都可以下载并还原你的源代码。


9. 懒加载的适用场景

懒加载不是万能的,选择不当反而影响体验。

9.1 适合懒加载的场景

  • 路由页面:用户点击才加载
  • 弹窗/Modal 组件:触发后才加载
  • 富文本编辑器:首屏不需要
  • 图表库:按需加载
  • 用户行为触发的功能:如文件上传、导出

9.2 不适合的场景

  • 首屏核心组件:会明显感知延迟
  • 频繁切换的组件:每次切换都要重新加载

9.3 React 懒加载示例


import { lazy, Suspense } from 'react';
​
// 路由懒加载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
​
function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

9.4 Webpack 配置


// React.lazy 使用的是动态 import(),会自动被 splitChunks 处理
// 但如果想要更精确的控制:optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // 把懒加载的路由单独打包
      routes: {
        test: /[\/]pages[\/]/,
        name: 'routes',
        chunks: 'async',
        priority: 10,
      }
    }
  }
}

10. React 按需引入:一个被误解的观点

很多人说 React 需要按需引入来优化体积,但实际测试发现:

React 没有应用按需引入的原因是:按需引入会有辅助代码,并且全部导入的体积也不大,没必要增添多余的代码量。

10.1 为什么按需引入不总是值得?


// 按需引入
import { Button } from 'antd';
// 打包结果:辅助代码(约 5KB)+ Button 组件代码// 全部导入
import { Button } from 'antd';
// 打包结果:全部组件代码(约 100KB)

对于 React 这种基础库,按需引入节省的体积可能还抵不上辅助代码的开销。

10.2 什么时候按需引入是值得的?

当使用的组件占整个库的比例较小时:


// 只用了 antd 的 3 个组件(共 50 个)
// 按需引入节省约 70% 的体积// 使用了 antd 的 40 个组件
// 按需引入节省约 10% 的体积,收益不大

11. PWA 离线缓存:更新策略的思考

11.1 PWA 的优势


const WorkboxPlugin = require('workbox-webpack-plugin');
​
plugins: [
  new WorkboxPlugin.GenerateSW({
    clientsClaim: true,
    skipWaiting: true,
  }),
]

配置后,用户访问过一次,第二次访问可以完全使用缓存,实现离线可用。

11.2 缓存更新的问题

PWA 最大的问题是缓存更新

  1. 用户首次访问,网站被缓存
  2. 你发布了新版本
  3. 用户再次访问,看到的可能是旧版本

11.3 解决方案

方案 A:配置缓存策略


new WorkboxPlugin.GenerateSW({
  clientsClaim: true,
  skipWaiting: true,
  runtimeCaching: [
    {
      urlPattern: /^https://api./,
      handler: 'NetworkFirst',  // 优先网络,失败用缓存
    },
    {
      urlPattern: /.(png|jpg|css|js)$/,
      handler: 'CacheFirst',    // 缓存优先
      options: {
        cacheName: 'static-resources',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
        },
      },
    },
  ],
})

方案 B:提示用户刷新


// 检测到新版本时提示用户
if (navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    // 提示用户刷新页面
    toast.info('有新版本可用,请刷新页面');
  });
}

总结

Webpack 学习过程中,以下几点是我认为最重要的:

  1. splitChunks 配置是关键:主包体积大的问题往往是没有正确配置代码分割
  2. Tree-shaking 有副作用:不是所有代码都能被安全地 shake 掉,需要配置 sideEffects
  3. 多进程不是银弹:小型项目可能越用越慢
  4. HMR 和 liveReload 可能冲突:开发时注意区分
  5. Source Map 有安全风险:生产环境必须用 hidden-source-map
  6. 懒加载要选对场景:不是所有组件都适合懒加载
  7. PWA 更新策略要做好:否则用户可能永远看到旧版本

这些问题官方文档往往不会专门强调,但实际项目中却频繁遇到。希望这些思考能帮你少走弯路。


学习日期:2026年6月20日-6月25日基于 webpack 5 学习笔记整理