Webpack Chunk Graph 策略 #16
JSerFeng
started this conversation in
Deep Dive CN
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Webpack ChunkGraph 策略
现在主流的 Bundler 中 Webpack,Rollup,以及基于 native 语言的 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 中,简化写法如下
在入口 chunk 中,只有从入口 module 静态 import 到的 module 才会在这个 map。
模块的执行类似于 commonjs require,不过名字叫做 webpack_require,简化写法如下
当代码中含有动态 import 语句的时候,会去做 chunk 的加载,在加载后,继续使用 webpack_require 去执行
例如我们有一个 bar.js module 在 bar-chunk 这个 chunk 中,此时我们动态加载 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 的代码内容做一些转换后拼接成一个大文件
其中遇到动态 import 语句后,是直接使用 esm 的 import() 语法加载(取决于你的 output format)
Rollup 产物的代价
初看这种产物会觉得很直观,但其实有问题,在 import 的时候就会执行 module 中的代码
其中第 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,例如
a
和b
单独拆出,那上面说的小 chunk 太多的问题就会更加严重。而且这种顺序的检测对性能可能也会有很大的影响。Webpack 到底怎样生成 Chunk
在 Webpack module 可以重复出现,并且 Chunk 中的 module 顺序可以不用关心,那么构建 Chunk Graph 的过程就变得非常简单了。
一般情况下,不考虑各种 Worker,Module Federation,new URL 等,拆分 chunk 的情况就是 esm 的动态导入语句了,也就是
import("./path")
。Chunk Graph 生成流程
构建 Chunk 图的逻辑其实很简单,
考虑如下模块图
其中实线代表静态的 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 再进行去重,这不是多此一举吗,我们可以考虑一种情况,多入口的情况,将模块拓扑图变一下
其中
index
和home
分别为两个入口,他们都共同动态引入了shared
模块。同时shared
模块与入口 index 都静态引入了m
模块。我们来分析由
index
开始,入口 chunk 中应该会有index
和m
两个 module。然后
index
动态引入shared
,因此会产生shared-chunk-1
,并且shared
module 中引入了m
,shared-chunk-1
中会含有m
,如图此时在看第二个 entry:
home
,home
自己是入口 chunk 中,并且home
module 也动态引入了shared
,因此最终 chunk 如下此时 removeAvailableModules 优化就派上用场了,你会发现,
shared
chunk 1 以及他的父 chunk 都包含了m
module,而shared
chunk 1 必定是在他父亲加载后进行加载的,那此时m
module 必定已经由父 chunk 加载过了,因此可以安全的将m
从shared 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 就不会单独拆出 chunksplitChunks
splitChunks 可以精确控制 module 分配到 chunk 的策略,如果我们想要实现类似 Rollup 效果的策略,只需要打开默认的 splitChunks 规则,稍作修改即可。
这里
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点
这其中第一点是可以改善的。
类似 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 优化也即将在后续版本中推出,敬请期待
结束
画图工具 https://www.doodleboard.pro/app/RlAuShKAn0R2
Beta Was this translation helpful? Give feedback.
All reactions