前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Webpack 5 构建性能优化实战,持久化缓存 Tree-shaking 与分包配置

首页2023-09-17 11:40:12Front-End
Webpack构建工具前端工程化性能优化打包优化

改一行 CSS,dev server 转了十几秒才刷新;提一个 PR,CI 上的生产构建能跑掉大半杯咖啡的时间。这种项目大概率你也遇到过,而且越到项目后期越难受。Webpack 5 在这件事上给的东西不少,持久化缓存、更聪明的 Tree-shaking、更灵活的分包策略,但配置项太多,很容易开了一堆结果没开在关键路径上。

这篇按「构建慢在哪一段」的顺序把优化手段串起来,每个配置都说清它作用在哪个阶段、什么场景有效、边界在哪,最后给一份可以直接复制的生产配置和一份上线前的 checklist。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Webpack 构建的三个阶段,以及每个优化手段对应哪一段
  • 持久化缓存怎么开、各配置项管什么、失效规则是什么
  • babel-loader / ESLint / Stylelint 这一层的缓存怎么叠
  • Tree-shaking 的两步机制,以及让它失效的六种常见写法
  • SplitChunks 的 Chunk 模型、缓存组配置和长期缓存的坑
  • 并行构建的四种方案,以及它什么时候是负优化
  • HMR、SourceMap 选型和构建分析工具的正确用法
  • 一份可直接复制的生产配置 + 上线前 checklist

# 一、先搞清楚构建慢在哪一段

在改任何配置之前,得先知道 Webpack 一次构建到底干了什么。整个过程大致分三段,每一段慢的原因和药方都不一样:

        entry
          │
 ┌────────▼──────────────────────────────────────────────┐
 │ 初始化阶段                                             │
 │   读 config → 注册内置插件 → 创建 compiler             │
 │   耗时占比很小,基本不用管                              │
 └────────┬──────────────────────────────────────────────┘
          │
 ┌────────▼──────────────────────────────────────────────┐
 │ Make 阶段                                              │
 │   读文件 → Loader 转译 → acorn 生成 AST                │
 │   → 分析依赖 → 递归下去 → 产出 ModuleGraph             │
 │                                                        │
 │   慢在:babel-loader / ts-loader / eslint / sass       │
 │   药方:持久化缓存、Loader 级缓存、thread-loader、      │
 │         收窄 resolve 与 include 范围                    │
 └────────┬──────────────────────────────────────────────┘
          │
 ┌────────▼──────────────────────────────────────────────┐
 │ Seal 阶段                                              │
 │   遍历 ModuleGraph → 生成 Chunk → 代码转译 →           │
 │   产物优化(Tree-shaking)→ 写出文件                    │
 │                                                        │
 │   慢在:Terser 压缩、SplitChunks 启发式计算             │
 │   药方:TerserPlugin parallel、精简 cacheGroups         │
 └────────┬──────────────────────────────────────────────┘
          │
        dist/
@前端进阶之旅: 代码已经复制到剪贴板

先说结论,绝大多数项目的时间都花在 Make 阶段的 Loader 转译上,其次是 Seal 阶段的压缩。所以优化的第一顺位永远是「让 Loader 少干活」,持久化缓存就是干这个的;第二顺位才是「让压缩并行」。

你要是也在调构建,建议先用 speed-measure-webpack-plugin 跑一次,看清楚耗时分布再动手,不要凭感觉一顿加插件。

# 二、持久化缓存,二次构建的最大杠杆

# 2.1 一行配置开启

持久化缓存(Persistent Caching)是 Webpack 5 最有分量的新特性。它把首次构建的过程和结果数据序列化到本地文件系统,下次构建时直接把 Module、ModuleGraph、Chunk 这些对象的状态读回来,跳过解析、转译、链接这些真正花时间的操作。

社区里被反复引用的一组实测数据是这样的:一个约 360 份 JS 文件、合计 3 万行代码的中大型项目,配了 babel-loader 和 eslint-loader,不开缓存时构建耗时在 11000 毫秒到 18000 毫秒之间;开了缓存之后,第二次构建降到 500 毫秒到 800 毫秒,差了将近 50 倍。这个量级和我自己在中等规模项目上的体感是对得上的,不过具体倍数跟项目结构关系很大,别把它当成承诺。

开启只需要一行:

module.exports = {
  //...
  cache: {
    type: 'filesystem'
  },
  //...
};
@前端进阶之旅: 代码已经复制到剪贴板

type 的另一个取值是 memory,那是开发模式的默认值,进程一退就没了。要跨进程复用就必须用 filesystem。

# 2.2 配置项逐个说清

cache.cacheDirectory 是缓存文件的落盘位置,默认在 node_modules/.cache/webpack。放在这里的好处是天然被 .gitignore 覆盖;改到项目根目录下的话,记得自己加一条忽略规则。

cache.buildDependencies 是最容易漏配的一项。它声明「哪些文件变了就整个缓存作废」,通常填各种配置文件:

module.exports = {
  cache: {
    type: 'filesystem',
    buildDependencies: {
      config: [
        path.join(__dirname, 'webpack.dll_config.js'),
        path.join(__dirname, '.babelrc')
      ],
    },
  },
};
@前端进阶之旅: 代码已经复制到剪贴板

不配这个会出什么事?你改了 .babelrc 里的 preset,构建却还在用旧规则转译出来的缓存产物,然后开始怀疑人生。这种问题排查起来特别费劲,因为代码看着完全没问题。最省事的写法是 config: [__filename],把 webpack 配置文件自己加进去,再把 babel、postcss 这类配置文件补上。

cache.managedPaths 是受控目录,命中的路径会跳过新旧内容哈希和时间戳的比对,直接用缓存副本,默认值是 ['./node_modules']。这个假设成立的前提是你不会手改 node_modules 里的文件,用 patch-package 之类工具的项目要留个心眼。新版本里 snapshot.managedPaths 也控制同一类行为,具体取哪个以你用的 Webpack 版本文档为准。

cache.maxAge 是缓存失效时间,默认 5184000000 毫秒,也就是 60 天。cache.profile 打开后会输出缓存处理过程的详细日志,默认 false,排查「为什么缓存没生效」时很有用。

完整配置长这样:

const path = require('path');

module.exports = {
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
    buildDependencies: {
      config: [__filename],
    },
    managedPaths: [/^(.+?[\\/]node_modules[\\/])/],
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30天
    compression: 'gzip',
    profile: true,
  },
};
@前端进阶之旅: 代码已经复制到剪贴板

compression 值得单独说一句。开 gzip 能把缓存目录体积压下来,代价是每次读写都要多一次压缩解压。本地磁盘够用的话不开更快,CI 上要把缓存目录当 artifact 传来传去时再考虑开。

# 2.3 缓存到底缓存了什么

Webpack 的构建过程可以拆成三段。初始化阶段根据配置装配内置插件。Make 阶段从 entry 开始,读文件、调 Loader 转译、用 acorn 生成 AST、分析出依赖列表,然后对每个依赖递归重复这套流程,最终产出完整的模块依赖图 ModuleGraph。Seal 阶段遍历这张图,把每个模块做代码转译(比如 import 转成 require 调用),分析运行时依赖,合并模块代码和运行时代码生成 chunk,执行 Tree-shaking 这类产物优化,最后写出文件。

持久化缓存做的事情就是在首次构建结束后,把 Module、Chunk、ModuleGraph 这三类对象的状态序列化存下来;下次构建开始时尝试读回来并恢复。恢复成功的部分,Loader 链、AST 解析、依赖分析就全都不用再跑了。

明白了这一点就能理解它的边界。缓存加速的是 Make 阶段,Seal 阶段的压缩该跑还得跑。所以开了缓存之后你会发现开发构建快得飞起,生产构建的提升却没那么夸张,那是因为生产构建的大头在 Terser 上。

# 2.4 Loader 层再叠一层缓存

Webpack 原生缓存之外,各个 Loader 自己也有缓存能力,两层可以叠加。

babel-loader 的写法:

module.exports = {
  module: {
    rules: [{
      test: /\.m?js$/,
      loader: 'babel-loader',
      options: {
        cacheDirectory: true,
        cacheCompression: false, // 关闭压缩以提升缓存读写速度
      },
    }]
  },
};
@前端进阶之旅: 代码已经复制到剪贴板

cacheCompression: false 这一项建议一定要加。Babel 默认会 gzip 压缩缓存文件,省下来的磁盘空间远不如压缩解压花掉的时间值钱。

ESLint 和 Stylelint 走插件形式,各有各的缓存文件:

const ESLintPlugin = require('eslint-webpack-plugin');
const StylelintPlugin = require('stylelint-webpack-plugin');

module.exports = {
  plugins: [
    new ESLintPlugin({
      cache: true,
      cacheLocation: './.eslintcache',
    }),
    new StylelintPlugin({
      files: '**/*.css',
      cache: true,
      cacheLocation: './.stylelintcache',
    }),
  ],
};
@前端进阶之旅: 代码已经复制到剪贴板

顺带提一句,老项目里常见的 eslint-loader 已经废弃了,官方推荐换成上面这个 eslint-webpack-plugin。原因是 loader 形态会把 lint 塞进转译链路里串行执行,插件形态可以并行跑,构建阻塞更少。

这两层缓存都开上之后,构建性能通常还能再有三成到八成的提升,具体幅度看你的 lint 规则有多重。

# 三、Tree-shaking,把没用的代码摇下来

# 3.1 三个前提缺一不可

Tree-shaking 是基于 ES Module 规范的 Dead Code Elimination。它静态分析模块之间的导入导出关系,找出那些从来没被别人用过的导出值,然后删掉。Webpack 从 2.0 就支持了,但真正好用是在 5 之后。

要让它跑起来,三个条件必须同时满足:

fe
  • 一、先搞清楚构建慢在哪一段
  • 二、持久化缓存,二次构建的最大杠杆
    • 2.1 一行配置开启
    • 2.2 配置项逐个说清
    • 2.3 缓存到底缓存了什么
    • 2.4 Loader 层再叠一层缓存
  • 三、Tree-shaking,把没用的代码摇下来
    • 3.1 三个前提缺一不可
    • 3.2 它其实是两步
    • 3.3 六条实践,每条都对应一种失效场景
    • 3.4 别忘了 sideEffects
  • 四、SplitChunks,把包切得更聪明
    • 4.1 不分包会怎样
    • 4.2 三种 Chunk
    • 4.3 核心配置
    • 4.4 缓存组才是重点
    • 4.5 runtimeChunk 和长期缓存
  • 五、并行构建,什么时候是负优化
    • 5.1 Node 单线程带来的天花板
    • 5.2 Thread-loader 用法
    • 5.3 并行压缩
    • 5.4 HappyPack 的写法留个档
  • 六、开发体验相关的三件事
    • 6.1 HMR
    • 6.2 SourceMap 怎么选
    • 6.3 先量再改
  • 七、一份可以直接复制的生产配置
  • 八、上线前 checklist
  • 总结
  • 参考

← Charles 抓安卓 7 以上 HTTPS 接口全流程 模拟器写入系统证书实战前端文件下载完全指南 12 种方案对比与实战代码 →