深入理解 Webpack Chunk Graph 策略

作者:@JSerFeng

前言

在当前主流的 Bundler(打包工具)中,Webpack,Rollup,以及基于 native 语言的 Rspack,esbuild,farm 等,它们的 chunk 分割策略各不相同。

这里我主要介绍我比较熟悉的 Webpack,它的 chunk 策略到底是怎样的,通过这篇文章,你可以完全理解代码中,到底怎样会产生 chunk,怎样减少 chunk 体积等。

为了简便起见,这里我们将概念做适当简化,比如 module 即为你的文件,chunk 即为一堆 module 聚合的大文件

同时 webpack 中 chunk 是没有父子关系的,但是 chunk group 有父子关系,由于 chunk group 概念牵扯到 splitChunks,这里暂且不说,我们这篇文章说的 chunk 父子关系就是 chunk group 父子关系,方便读者们理解。

Webpack 怎样执行产物

Webpack 有一套很类似 commonjs 的运行时代码,你的 module 代码都会放在一个 map 中,简化写法如下

js 复制代码
const __webpack_modules__ = {
    "./src/index.js": function(module, exports, require) { /*index.js 的代码*/ },
    "./src/foo.js": function(module, exports, require) { /*foo.js 的代码*/ },
}

在入口 chunk 中,只有从入口 module 静态 import 到的 module 才会在这个 map。 模块的执行类似于 commonjs require,不过名字叫做 webpack_require,简化写法如下

js 复制代码
const cache = {}
function __webpack_require__(moduleId) {
    if (moduleId in cache) {
        return cache[moduleId]
    }
    const module = { exports: {} }
    cache[moduleId] = module.exports;
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__)
    return module.exports
}

// 执行入口
__webpack_require__('./src/index.js')

当代码中含有动态 import 语句的时候,会去做 chunk 的加载,在加载后,继续使用 webpack_require 去执行 例如我们有一个 bar.js module 在 bar-chunk 这个 chunk 中,此时我们动态加载 bar.js,源码如下

js 复制代码
import('./src/bar.js')

会被转换成

js 复制代码
__ensure_chunk__('./bar-chunk.js')
    .then(() => __webpack_require__('./src/bar.js'))

这里的 ensure_chunk 会去加载 bar-chunk.js,一般来说浏览器端就是给 html body 插一个 script,src 放上 bar-chunk 的 url,当加载 chunk 后,该 chunk 的所有 modules 也会被加到 webpack_modules 中去,然后调用 webpack_require 执行。

因此可以发现,module 执行顺序和 chunk 加载顺序并没有关系,只需要保证执行之前对应的 chunk 已经存在即可, 并且 module 如果在多个 chunk 也没有关系,因为同一个 module 只会执行一次,可以看上面简化代码中的 cache。

Rollup 怎样执行产物

我们再看 Rollup,Rollup 几乎没有运行时,产物就是 module 的代码内容做一些转换后拼接成一个大文件:

js 复制代码
// ./src/foo.js
console.log('foo.js 中的代码')

// ./src/index.js
import('./bar-chunk.js')

其中遇到动态 import 语句后,是直接使用 esm 的 import() 语法加载(取决于你的 output format)。

Rollup 产物的代价

初看这种产物会觉得很直观,但其实有问题,在 import 的时候就会执行 module 中的代码:

  1. Module 如果重复出现在多个 chunk 中可能会执行多次,如果 module 有一些全局副作用大部分情况下都会出现错误
  2. Module 顺序如何保证?假如从一条引用链中 module 的执行顺序是 A -> B -> C,但另一条引用链中出现了 D -> C -> B,该怎样保证 B 和 C 在不同的引用链中执行顺序正确

其中第 1 点,对于重复 module,大部分情况下 Rollup 可以把重复 module 提出去,变成单独的 Chunk,是解决了问题,但是引出了新的问题,小 Chunk 可能会非常多,因为哪怕是很小的一个 module,只要出现在多个 Chunk 中都不得不将 module 移出成单独 Chunk。而这种情况在业务开发中非常常见。

举个例子,大家写 React 通常会使用 import('./Home.tsx') 来做路由的懒加载,然后常见的大家会在代码中写一些通用的工具 utils,在多个页面中共用 utils,那么 utils 就是一个重复 module,会被单独提出成 Chunk,如果你的 utils 很小,如果是 10 k,你需要为这 10 kb 而进行一次网络请求,我们说的是这一个 utils,还有非常多的地方会造成小module 在 Chunk 中复用多次的情况。

你可能会想,我将多个重复的 module 打到一个 chunk 中是不是就能解决问题呢? 并不能,你马上就会遇到 module 执行顺序的问题,接下来会提到。

对于第 2 点,现在的 Rollup 并不能做到保证 module 执行顺序的一致性,请看 playground,这里有 2 个入口,第一个引入顺序为 a b,第二个引入顺序为 b a,如果我只想执行 home 入口,仍然只会输出 a b:

解决方案也有,拆成更小的 module,例如 ab 单独拆出,那上面说的小 chunk 太多的问题就会更加严重。而且这种顺序的检测对性能可能也会有很大的影响。

Webpack 到底怎样生成 Chunk

在 Webpack module 可以重复出现,并且 Chunk 中的 module 顺序可以不用关心,那么构建 Chunk Graph 的过程就变得非常简单了。 一般情况下,不考虑各种 Worker,Module Federation,new URL 等,拆分 chunk 的情况就是 esm 的动态导入语句了,也就是 import("./path")

Chunk Graph 生成流程

构建 Chunk 图的逻辑其实很简单,

  1. 遍历当前模块的导入语句
  2. 遇到静态 import,就将引入的 module 放到当前 Chunk 中,然后继续遍历该 module 的所有导入
  3. 遇到动态 import,就新创建一个 Chunk,将引入的 module 放到新 Chunk 中,新 Chunk 变成当前 Chunk,继续遍历 module 即可

考虑如下模块图:

其中,实线代表静态的 esm import 语句产生的引入,虚线代表动态 import() 产生的引入 首先会有一个入口 chunk,由入口 module 开始,引入 a 会将 a 放入到入口 chunk,这里外层块代表 chunk,内层的每一个块代表 module。

a 发现了动态 import("./shared"),会创建一个新的 chunk,我们就叫该 chunk 为 shared-chunk-1,后面会解释为什么有个后缀 1

然后回到 index 中还有一个引入 b,将 b 加到入口 chunk

b 出发,发现动态 import('./shared'),此时由于该 import("./shared") 语句的位置,和之前的 import("./shared") 位置不同,该 import("./shared") 是从 b module 触发的,而之前的是从 a module 触发,此时不会复用 shared-chunk-1,而是会新创建 shared-chunk-2

但是这里其实可以使用 webpack 中的 magic 注释来强制复用 chunk,做法是将两处的 import('./shared') 都写成 import(/* webpackChunkName: "shared" */ './shared'),这样做的话,shared chunk 只会创建一个。

此时 Chunk 创建完毕,这个时候你会发现有两个一模一样的 chunk,shared-chunk-1 和 shared-chunk-2,早期的 webpack 中就加入了 mergeDuplicateChunk 来对 chunk 进行去重,去重后就只剩下一个 shared chunk 了。

你可能会觉得这样做并不合理,为何要创建重复 chunk 再进行去重,这不是多此一举吗,我们可以考虑一种情况,多入口的情况,将模块拓扑图变一下:

其中 indexhome 分别为两个入口,他们都共同动态引入了 shared 模块。同时 shared 模块与入口 index 都静态引入了 m 模块。

我们来分析由 index 开始,入口 chunk 中应该会有 indexm 两个 module。

然后 index 动态引入 shared,因此会产生 shared-chunk-1,并且 shared module 中引入了 mshared-chunk-1 中会含有 m,如图:

此时在看第二个 entry:homehome 自己是入口 chunk 中,并且 home module 也动态引入了 shared,因此最终 chunk 如下:

此时 removeAvailableModules 优化就派上用场了,你会发现,shared chunk 1 以及他的父 chunk 都包含了 m module,而 shared chunk 1 必定是在他父亲加载后进行加载的,那此时 m module 必定已经由父 chunk 加载过了,因此可以安全的将 mshared chunk 1 中删除了,删除后如下:

你会发现现在两个 entry 之间的 chunk 没有交集,不同入口加载的时候只加载当前所需要的 chunk,假设两个入口加载 shared 的方式是用户点击某个按钮后进行加载,那么对于 home 的首屏来说就不需要加载 m,而对于 index 来说加载 shared 的时候不需要加载 m

Rollup 以及 esbuild

如果你用过 Rollup,esbuild,他们都会尽量保证 0 重复 module,对于 import("./shared") 只会创建一个 chunk,使用 Rollup 来打包,我们会发现最终的 chunk 如下 (假设我们的 m module 是一个只包含一行 console.log(42) 小模块)

初看很好,0 重复 module,chunk 关系非常清晰。

但为什么一个非常小的 m module 需要放到一个单独的 chunk 呢?

因为重复 module 是 Rollup 等的大忌。其实现代 Webpack 默认也是生成这种形式,只是当 m 体积够大才会是上面的结构,如果 m 是个非常小的 module 的时候 webpack 就不会单独拆出 chunk

splitChunks

splitChunks 可以精确控制 module 分配到 chunk 的策略,如果我们想要实现类似 Rollup 效果的策略,只需要打开默认的 splitChunks 规则,稍作修改即可。

js 复制代码
module.exports = {
    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0 //真实项目不用管这个选项
        }
    }
}

这里 chunks: 'all' 表示对所有的 chunk 都可以进行拆分,是可以拆分,不是强制拆分。

minSize: 0 在你们的真实项目并不用开启,这里开启只是因为对于特别小的 module,webpack 并不会拆分出去,拆分反而影响加载性能,有一个默认的拆分体积阈值,这里改成 0 只是为了演示。

这样操作后,对于任意的 module,只要他同时出现在 2 个或以上 chunk 中,就会被抽离成单独的 chunk,我们看看经过 split 前的 chunk graph:

其中重复 module 有 m 和 shared,他们会被抽离出去成单独的 chunk,抽离后的关系如下:

shared chunk 1 以及 shared chunk 2 变成了空 chunk,在后面构建阶段中,webpack 会移除空 chunk,最终形成:

但 webpack 却不会出现 Rollup 那样的 module 执行顺序问题,webpack 的 chunk 只是相当于一个 module map,真正的 module 执行顺序依赖的是源码中的 import 顺序,经过任意拆分都可以使用保证正确的执行顺序。

同时由于存在 splitChunks,在配置 chunks: 'all' 之后,其实可以关闭 `mergeDuplicateModules 优化,splitChunks 的功能可以完全 cover 住 mergeDuplicateModules,而且 mergeDuplicateModules 其实挺吃性能的,算法复杂度并不低

另外 removeAvailableModules 优化同样可以关闭以提高编译性能,一方面 splitChunks 抽离同样 module,也相当于删除了多余 module。另一方面这个配置其实并没有任何作用,早期该配置是控制是否添加 webpack 的内置插件 RemoveParentModulesPlugin 的,但后来 webpack 的 code splitting 实现了一样的功能,并且性能更好好,而且 code splitting 该行为也关闭不了,在构建 chunk 的过程中就将可删除的 module 全部删除了

concatenateModules

也有人称作 scope hoisting,直译过来是连接 module,我们知道很多时候大家觉得 webpack 不够好的原因一般有3点

  1. 产物太难看了,第一次看 webpack 产物会发现充满了意义不明的注释作为缩进,以及一堆 webpack 特有的 runtime 函数,并且每个模块包在一个函数中,调用起来像是跑 cjs 一样,总感觉性能也不会好
  2. 构建性能太差了,大项目打包时间10分钟往上的也不少
  3. 配置项太细节太多(作为底层的构建工具不好说这就是缺点

这其中第一点是可以改善的。

类似 Rollup esbuild 等轻量 runtime 的打包工具,chunk 就是 module 拼接起来而已,因此看着干净,实际上 webpack 在生产环境也是这样的,对于纯 esm 模块,webpack 也会简单的将 module 拼接在一起,产物和 Rollup 等其实是一样的。而对于 cjs 模块,Rollup 需要 commonjs 插件提供少量 cjs runtime(将 cjs module 用函数包一层,require 的时候就相当于调用该函数,获取函数返回值,以及一些 cjs 和 esm 交互的 runtime),esbuild 则自带 cjs runtime

给大家看一看开启 concatenateModules 配置后,development 模式下的产物:

在结合 splitChunks 和 concatenateModules 优化后,产物基本上和 Rollup 等是一样干净的。值得注意的是,Rspack 也支持了相同的 concatenateModule 优化,参考 #5237

附录

相关推荐
蜗牛快跑2139 分钟前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy10 分钟前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪1 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇1 小时前
ES6进阶知识一
前端·ecmascript·es6
前端郭德纲2 小时前
浏览器是加载ES6模块的?
javascript·算法
JerryXZR2 小时前
JavaScript核心编程 - 原型链 作用域 与 执行上下文
开发语言·javascript·原型模式