构建系统与前端打包工具 #22
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
-
最近在调研 Rspack 的 incremental 实现,很多其他编译器实现增量构建的资料中都有提到一篇论文:Build Systems à la Carte: Theory and Practice,所以抽空学习了下发现挺有意思的,和 bundler 也有一些相关性。本文会简单介绍这篇论文的内容,并尝试从 build system 的角度来概括 bundlers。
Build system
Build system 指的是自动化执行一系列可重复任务的软件系统,常见的有 Make、Shake、Bazel,他们以源文件作为输入,根据任务描述文件(比如:makefile)执行任务,构建出可执行文件。
还有一些并不常见的,Excel 以单元格作为输入,根据指定单元格的公式作为任务并执行任务,构建出这个单元格的结果;UI frameworks 以 props 作为输入,根据 Components 作为任务并执行,构建出新的 UI。
由此我们可以看出一些通用的概念:
这些概念是很通用的,在各个 build system 中的实现也比较相似,并不是造成不同 build systems 的主要原因,各个 build systems 不同的主要原因其实是对于以下两点所选取的策略不同导致的:
这两点分别对应两个比较重要的概念:Rebuilder 和 Scheduler,不同 build systems 可以看做使用了不同 Rebuilder 和不同 Scheduler 的组合。
Scheduler
持有 Rebuilder,进行一次新的 Build,决定了以怎样的顺序执行 Tasks。
Rebuilder
持有 Task,对 Task 进行重新执行,决定了 Task 是否需要重新执行,是使用缓存还是重新执行的结果。
Build systems
build systems 可以看做使用了不同 Rebuilder 和不同 Scheduler 的组合。
先介绍几个常见的特性:
Make
make = topological modTimeRebuilder
Make 使用 makefile 来描述任务,这些任务间的依赖关系明确,属于静态依赖,也不支持循环依赖,所以 Make 使用了 topological scheduler 以拓扑顺序执行任务。
Make 的 Info 构建信息其实就是文件系统本身,文件系统会有文件修改时间,Make 通过文件修改时间来判断任务是否需要重新执行,如果文件的修改时间早于其依赖文件的修改时间,则说明该任务需要重新执行,Make 将文件修改时间当做 dirty bit,属于 dirty bit rebuilder 的一种。
当然很多情况下文件修改时间是不可信的,比如有些程序会更新文件修改时间,但文件实际内容并不会修改,这就导致任务没必要的重新执行。
Make 通过 modTimeRebuilder 实现 Minimality,跳过不需要执行的任务,但也因为 modTimeRebuilder 导致它没有实现 Early cutoff,因为任务重新执行后输出的新的文件,尽管内容没变,文件修改时间也是改变的,导致不能提前中断,从这里也可以看出,没有实现 Early cutoff 的执行的任务一定不是最少的,所以 Minimality 往往是相对的。
Excel
excel = restarting dirtyBitRebuilder
Excel 通过单元格中的公式来描述任务,有些公式会有静态的依赖关系,但有些是动态的,所以使用了 restarting scheduler 来执行任务。值得注意的是,Excel 会记录最终的执行顺序供下次构建参考,以减少 restarting 的开销。
Excel 使用 dirty bit rebuilder,对于用户修改的单元格标记为 dirty,并重新执行依赖该单元格的任务,对于导致动态依赖的公式,Excel 会在每次构建时都标记为 dirty,确保每次都对其进行更新,来保证正确性,通过损耗一些性能来保证其正确性。
Excel 对于静态依赖是 Minimality 的,但对于动态依赖并没有实现 Minimality。
Bazel
bazel = restarting ctRebuilder
Bazel 也使用了 restarting scheduler 来执行任务,Bazel 也有一套优化机制来避免 restarting 的开销。
Bazel 使用 ctRebuilder 支持了云缓存和远程执行任务。
Shake
shake = suspending vtRebuilder
Shake 使用 vtRebuilder,在任务执行时追踪任务的依赖,并记录下来,在下次执行时,如果依赖没发生改变,则跳过执行,并且如果当前任务没被执行,则依赖当前任务的任务由于依赖没发生改变,也不需要执行,以此实现 Minimality 和 Early cutoff。
Shake 由于是任务执行时追踪依赖,并不需要提前静态定义,所以也支持 Dynamic dependencies。
Cloud Shake
cloudShake = suspending ctRebuilder
cloud shake 在 shake 的基础上支持了云缓存,区别在于将 Rebuilder 从 vtRebuilder 换成了 ctRebuilder。
Buck2
buck2 = suspending ctRebuilder
buck2 的核心开发者之一是 shake 的作者,也是 Build Systems à la Carte: Theory and Practice 这篇论文的作者之一。
Buck2 与 cloud shake 类似,buck2 支持 dynamic dependencies,实现了 minimality 和 early cutoff,除此之外还支持云缓存,并且一等公民的支持了远程执行任务。
buck2 也实现了自己的 incremental computation engine:DICE
Bundlers
Bundler 其实可以理解为 build system + 一部分 task descriptor,build system 其实对任务具体做什么并不关心,任务具体做什么由用户通过任务描述文件提供,build system 只管执行任务。早期 gulp、grunt 这种 task runner 其实更接近 build system,开发者使用这些 task runner 来手动编排文件的处理逻辑,以 task runner 作为 build system;同样的 turborepo 不关心任务逻辑,只执行任务,也声称自己是 build system。
Bundler 本身描述了一部分的任务逻辑,比如怎样构建模块、怎样拆分 chunk、怎样进行优化等,然后由用户的配置和插件提供剩余部分,组合成完整的 task descriptor。
Bundler 和 Build system 的任务也是有些不同的:
另外以 build system 中定义的 Build 为准的话,Bundler 的 Build 其实分为两种:
这两种 Build 也导致了两种不同的 Info,即 memory cache 和 persistent cache,这两种 Info 不仅能分开使用,也能针对场景进行混合使用。
Webpack/Parcel/Rollup/esbuild
passBasedBundler = foreach ctRebuilder
在传统的 pass-based bundler 中,每个 pass 的任务执行顺序(Scheduler)和是否执行(Rebuilder)都是不同的,每个 pass 依据这个阶段的任务逻辑,使用适合这个阶段的任务执行顺序和是否执行策略,比如在 webpack 中:
在 pass-based bundler 中,cache 为 bundler 实现了 Minimality,但由于各个 pass 之间的任务互不感知,pass 之间的任务不能实现 early cutoff,导致仍然存在过量任务需要进行 cache 验证。这往往也是 pass-based bundler 慢的原因:没有实现 Early cutoff 导致不够 Minimality。
Turbopack
turbopack = suspending ctRebuilder
不同于传统的 pass-based bundler,turbopack 并没有强调从头到尾的一个个编译阶段(pass),而是更接近于 query-based,定义任务,通过 query 获取任务结果,尤其是在 Dev 环境下,比如编译一个以 html 为入口的 web 页面,turbopack 的逻辑是:
传统 pass-based bundler 的逻辑是:
相比于 pass-based bundler,turbopack 只会关注获取 query 结果所需要执行的这一部分任务,其他无关任务不会执行,尤其 Dev 环境下不会有完整的 ModuleGraph 和 ChunkGraph。在 Production 环境下还是会通过一些方式来聚合成完整的图,以对完整 ModuleGraph / ChunkGraph 进行全局优化。
Turbopack 底层的 incremental computation engine:turbo tasks 就是驱动 turbopack 的 build system,task、scheduler、rebuilder 等 build system 的概念都有在 turbo tasks 中实现,上层 turbopack 相当于在 turbo tasks 的基础上对 bundler 的具体任务进行描述。这样看其实 incremental computation engine 本身就是一种 build system,同样基于 incremental computation engine:DICE 的 buck2 也类似,DICE 已经覆盖了 build system 中的核心功能,buck2 在其基础上实现将用户描述的任务作为 DICE 的任务进行执行。
Turbopack 整体统一基于 turbo tasks,使用 suspending + ctRebuilder 的组合,实现整体的 Minimality 和 Early cutoff。
Vite
vite = suspending vtRebuilder
虽然 Vite 本身并不会 Bundle,但 Vite 在 dev 时还是会对任务不断进行执行,符合 build system 的定义,Vite 并不会对多个模块进行打包,而是对单个模块进行编译,所以 Vite 的任务逻辑其实很简单,就是编译模块。Vite 是在浏览器对模块进行请求时才去编译模块,浏览器没命中缓存才会发起请求,发起请求的顺序就是模块 import 的顺序,也是由浏览器决定的,所以可以看出 Vite 利用浏览器 ESM 模块系统作为自己的一部分 build system,属于 suspending + vtRebuilder 的组合。
利用浏览器 ESM 模块系统虽然会让本身的实现简单很多,但浏览器 ESM 模块系统本身并不是以 build system 为目标来实现的,相比真正的 build system 会带来很多限制,比如:
Rspack
incrementalRspack = foreach dirtyBitAndCtRebuilder
Rspack 本身也属于 pass-based bundler,但为了将 HMR 的性能从 O(project) 优化到 O(change),Rspack 引入了 affected-based incremental。简单来说 affected-based incremental 会收集各个阶段的变更,后续阶段会根据收集到的变更计算出可能被影响的任务,从而只重新执行这些被影响的任务,减少任务的执行数量。
从 build system 的角度来讲,affected-based incremental 其实就是在 pass-based bundler 原有的 build system 基础上,引入新的 Rebuilder,让各个阶段之间的任务能够通过收集到的变更相互感知,以此能够对后续阶段的任务做 Early cutoff,通过添加 Early cutoff 这一特性来让 Rspack 更加 Minimality。这种方式更接近 self-adjusting computation:
根据变更找到被影响的输入,作为 dirty 的输入重新执行对应任务,这种实现相比于 incremental computation 不那么智能,但却是一种相对简单且有效的方式。
总结
很多 bundlers 都声称过自己是 next-generation bundler,但从底层 build systems 任务执行角度来看大部分都基本没有区别,缺少 build systems 中很多已经存在很久的优秀特性,这些优秀特性很多都可以吸纳进 bundler 中:
Beta Was this translation helpful? Give feedback.
All reactions