Bundler Tree Shaking 原理及差异 #28
ahabhgk
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
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Tree shaking 在如今的前端打包领域已成为极为重要且不可或缺的一部分。鉴于各个 bundler 的适用场景和侧重点存在差异,因此各个 bundler 之间的 tree shaking 实现方式也各不相同。例如,Webpack 主要用于前端应用打包,侧重于正确性,其 tree shaking 主要关注跨模块级别的优化;而 Rollup 主要用于库打包,侧重于优化效率,其 tree shaking 以 AST 节点为粒度进行优化,打包产物通常更小,但对于一些边界情况的执行正确性缺乏保障。
本文将以概述的视角简要介绍各个 bundler 的 tree shaking 原理,以及各个 bundler 的 tree shaking 之间的差异。
Tree shaking in Webpack
Webpack 的 tree shaking 分三部分:
module-level:
optimization.sideEffects
删除没有用到任何导出且没有副作用的模块import "./module";
没有用到任何导出,且无副作用,可以删除重导出模块(barrel file),本身没有任何导出被用到,且无副作用,可以删除
export-level:
optimization.providedExports
和optimization.usedExports
,删除没有用到的导出optimization.providedExports
用来分析模块有哪些导出optimization.usedExports
用来分析模块的哪些导出被用到,没用到则在代码生成时删除export
:export const a = 42
=>const a = 42
,然后通过 minimizer 删除剩余声明部分,如果变量在模块内也没被用到的话code-level:
optimization.minimize
使用 swc / terser 等 minifier 对代码进行 inline、eval 等分析,删除无用代码并压缩,尽可能减小代码体积optimization.minimizer
插件的方式与 bundler 进行集成,对 bundler 的产物进行后处理,并不属于 bundler 本身的职责范围除此之外还有很重要的一部分:静态分析,module-level 和 export-level 需要 Webpack 对代码进行静态分析,分析出模块是否有 side effects,以及模块有哪些导出、模块使用了哪些导出这些信息作为这些优化阶段的输入,所以理论上只要 Webpack 的 javascript parser 能够静态分析出这些信息,就能够做 tree shaking,这也使得 cjs、dynamic import 这些虽然是动态的,但在静态写法下,只要能静态分析出这些信息,就能够 tree shaking。但目前来说 Webpack 对 cjs 和 dynamic import 仅对很有限的场景做了静态分析,还有很多能够分析出来的场景没做,仍有一定的提升空间。
这三部分完成之后 tree shaking 已经能够工作了,但仍然会有些 cases 有问题:
导出
a
被另一个模块中没被使用的导出g
使用,导致a
不能被 tree shaking,此时需要optimization.innerGraph
对lib.js
分析出 top-level statements 之间的依赖关系,只有a
所在的 top-level statement 被使用到时,a
才会标记为被使用,否则为未被使用一些 minifier 能优化的 cases,由于 Webpack 生成的 runtime 对每个 module 包了一层函数,导致 minifier 不能优化,比如以下 case:
一般来说通过
optimization.concatenateModules
可以解决,但复杂场景下能够被 concatenate 的模块通常很少minifier 能够很好的将变量名 mangle 的更短,但这层函数导致 minifier 不能 mangle 模块导出的变量,此时需要
optimization.mangleExports
来进行 mangle其他更多优化,比如 Rspack v1.4 引入的 experiments.inlineConst
Tree shaking in esbuild
esbuild 的 tree shaking 分为以下步骤:
IsLive = true
IsLive = true
的 part,其他没被标记的 part 则会被删除掉提前切分 top-level statements 使得 esbuild 天然地解决了 innerGraph 的问题,经过切分后每个 top-level statement 都作为一个 part,以 part 粒度(part-level)进行分析优化,而不是像 webpack 一样以 module 粒度进行分析优化,这样主要有以下好处:
值得注意的是 esbuild 在早期版本还支持让这些 top-level statement 参与 code splitting,也就是我们现在说的 “module splitting”,但由于 esbuild 并没有和 webpack 的产物一样,将每个 module 包了一层函数,分离模块的加载与执行,导致难以处理好 top-level await,所以 esbuild 在后续版本放弃了对 module splitting 的支持。
目前 esbuild/docs/architecture/code-splitting 中仍然是对应支持 module splitting 的版本,code splitting 是以 part 为粒度做分割的,shared chunk 仅包含两个入口共有的 top-level statements。在 esbuild playground 中复刻了一版,可以看到 shared chunk 会包含两个入口共有的 modules,code splitting 是以 module 为粒度进行分割。
之前支持 module splitting 的结果
现在放弃支持 module splitting 的结果
另外除了 top-level await 以外,还有一个因为没有分离模块的加载与执行导致 esbuild 对于 module splitting 有额外的复杂度:ES Module 的静态 import 导的值是 read-only 的,这意味着 esbuild 进行 code splitting 时不能将模块变量的赋值操作移至与该变量声明所在的不同 chunk 中,在 esbuild 放弃 module splitting 后该问题也不再需要特殊处理。
Tree shaking in Turbopack
module splitting 其实更适合 webpack 这种能够将模块的加载与执行分离的产物,天然地保证了模块执行的正确性,也不需要对模块内变量的声明与使用有额外处理。
除此之外也能将更多 module-level 的优化应用到模块内的 top-level statements 上,比如 esbuild 所缺失或不足的一些优化:code splitting、runtime optimization、chunk splitting 等,做更进一步的优化。
那么有没有 bundler 即有类似 webpack 一样的 runtime 将模块的加载与执行分离,又支持 module splitting 呢?有的:Turbopack。
首先看 Turbopack 的产物格式如何解决不允许跨 chunk 进行变量的赋值与声明问题,使用 esbuild 中例子的打包结果:
Turbopack 使用一套和 webpack 类似的产物格式,对每个模块包一层函数,分离模块的加载与执行,与 webpack 不同的是,定义模块导出的 runtime
__turbopack_context__.s
除了提供导出的 getter 以外,还需要额外增加导出的 setter,在该模块的其他 part 对该变量进行赋值操作时,会触发该 setter 进行更新,以此来保证执行的正确性。对于 top-level await 也和 webpack 一样,使用一套 runtime 来保证包含 top-level await 的模块及其被引入的模块的执行顺序正确,比如在 data.js 第一行添加
await 1;
后打包结果如下:当然 module splitting 也有缺点,经过分割后,在产物中每个 top-level statement 都会有一层函数进行包裹,虽然解决了执行正确性的问题,但也放大了这层函数所带来的缺点:
_data_0__TURBOPACK_MODULE__.data
这种 object property 的方式来访问,降低代码执行性能(当然这点仍需在现代浏览器下进行相关 benchmark 进行验证)这两点都需要更加依赖 scope hoisting 来进行优化。
Tree shaking in Rollup
如果说 Webpack 是针对 export 语句和 module 做 tree shaking,esbuild 针对 top-level statement 做 tree shaking,那么 Rollup 会自顶向下针对所有的 statement 和部分更细粒度的 AST node 做 tree shaking,且 side effects 判定更加精细。
rollup 会对 statements,以及部分更细粒度的 AST node 做 tree shaking
Rollup 的 tree shaking 与 esbuild 比较相似,但略有不同,Rollup 的 tree shaking 过程如下:
a. 判断 AST 节点是否有 side effects
b. 有则进行 include()
c. 继续对 AST 节点的相关节点进行 side effects 判定和 include(),即 a、b 过程
Rollup 相比其他 bundler 的 tree skaking 效果往往更好原因在于:
Rollup 这种细粒度的分析中自然也覆盖了export 语句和 top-level statement 的粗粒度的分析,所以 Rollup 不仅能对跨模块的 export 语句做删除,也能覆盖一部分模块内的 DCE。接下来我们主要关注模块内的 DCE 部分,并结合几个 cases 来阐述以上两点:
Rollup 跨模块分析非 top-level 的死代码:
Rollup 的 Define 功能和其他 bundler 的实现是不太一样的,rollup-plugin-replace 只会在 transform 阶段将匹配到的 Define 节点做替换,不像 webpack、esbuild 除了在 parse 阶段做替换以外,还会去分析 dead branch,dead branch 中的代码直接跳过分析,也不会引入 dead branch 中的任何依赖到模块图中,但这种 parse 阶段的 dead branch 分析并不能跨模块。
由于 Rollup 的 tree shaking 粒度是 AST node 级别的,使得函数内部的 statement 节点也能被 tree shaking 分析到,所以 Rollup 的分析部分交给了 tree shaking 阶段,dead branch 的删除也依赖于 tree shaking。
在这个例子中,会尝试将
if (DEVELOPMENT)
中的DEVELOPMENT
变量进行 compile-time eval,结果为常量,所以能够将 else branch 作为 dead branch 删去,且不会标记file.js
中的DEVELOPMENT
变量为被使用,使得最终能够 tree shaking 掉export const DEVELOPMENT
声明以及file.js
模块。这样做的坏处在于 dead branch 中引入的依赖仍然会被加到模块图中,引入更多的模块使 Rollup 需要处理更多工作,降低性能。
好处在于能够跨模块的分析和删除 dead branch,删除更多无用代码带来更小的产物体积。其他 bundler 则需要够靠 scope hoisting 将模块合并到一个作用域内,依赖 minifier 来删除这些跨模块的 dead branch,但对于为了保证执行正确性不得不放弃 scope hoisting 的部分模块则无能为力,可能未来需要引入新的优化来处理。
Rollup 删除无用的 object properties
Rollup 在分析
console.log(obj.a.ab)
时,由于有副作用,会对该 statement 进行include()
,在include(obj.a.ab)
时,会触发相关节点的include()
,包括obj
声明节点、a:
属性节点、ab:
属性节点,AST node 级别的 tree shaking 让 Rollup 能够仅保留用到的a:
属性和ab:
属性,将其他无用的属性 tree shaking 掉,带来更小的产物体积。Rollup 根据 reassiginment 判定 side effects
在该 case 中,如果注释掉
a = {}
会发现所有代码都被 tree shaking 了,但只要去掉注释后则不会 tree shaking,这是因为 Rollup 会根据变量 a 有没有被 reassiginment 判断 a.b = 3 是否有 side effects,这体现了 Rollup 有一定根据上下文进行 side effects 分析的能力,当然相比上下文无关的 side effects 分析这带来了一定程度上的性能损耗。当然这种根据上下文的 side effects 分析也比较简易,一般仅会对特定的比较简单的场景生效,比如上述场景只会判断是否被 reassign,不会分析 reassign 是否有真正的改变,不会做过于深入细致的分析。
即使 reassign 了同一个变量,实际没有真正的改变,但仍然会判定 a.b = 3 为有 side effects
Rollup v3 仅支持 statement-level 的 tree shaking,但 v4 开始尝试更细粒度的 AST Node 的 tree shaking(上述 object properties 的 tree shaking),以及优化特定场景下对 unused 节点的追踪,比如:
使得更多场景的 tree shaking 成为可能:
Beta Was this translation helpful? Give feedback.
All reactions