Vite双引擎架构
Published in:2024-03-31 | category: 前端 工程化

这篇文章我们会将目光集中到 Vite 本身的架构上,一起聊聊它是如何站在巨人的肩膀上实现出来的。
所谓的巨人,指的就是 Vite 底层所深度使用的两个构建引擎:**Esbuild **和 Rollup

Vite 架构图

很多人对 Vite 的双引擎架构仅仅停留在开发阶段使用 Esbuild,生产环境用 Rollup 的阶段,殊不知,Vite 真正的架构远没有这么简单。

一图胜千言,这里放一张 Vite 架构图:

接下来的内容,会围绕这张架构图展开双引擎的介绍,到时候你会对这份架构图理解得更透彻。

Esbuild:性能利器

必须要承认的是,Esbuild 的确是 Vite 高性能的得力助手,在很多关键的构建阶段让 Vite 获得了相当优异的性能,如果这些阶段用传统的打包器/编译器来完成的话,开发体验要下降一大截。

那么,Esbuild 到底在 Vite 的构建体系中发挥了哪些作用?

作为 Bundler 打包工具,进行依赖预构建

首先是开发阶段的依赖预构建

一般来说,node_modules 依赖的大小动辄几百 MB 甚至上 GB ,会远超项目源代码,相信大家都深有体会。如果这些依赖直接在 Vite 中使用,会出现一系列的问题,这些问题我们在依赖预构建的小节已经详细分析过,主要是 ESM 格式的兼容性问题和海量请求的问题。
总而言之,对于第三方依赖,需要在应用启动前使用 Esbuild 进行打包并且转换为 ESM 格式

Esbuild 打包时的缺点

当然,Esbuild 作为打包工具也有一些缺点:

  • 不支持降级到 ES5 的代码。这意味着在低端浏览器代码会跑不起来。
  • 不支持 const enum 等语法。这意味着单独使用这些语法在 esbuild 中会直接抛错。
  • 不提供操作打包产物的接口。像 Rollup 中灵活处理打包产物的能力(如 renderChunk 钩子),在 Esbuild 当中完全没有。
  • 不支持自定义 Code Splitting 策略。传统的 Webpack 和 Rollup 都提供了自定义拆包策略的 API,而 Esbuild 并未提供,从而降级了拆包优化的灵活性。
  • 不支持对异步懒加载请求(路由懒加载,组件懒加载)自动优化

尽管 Esbuild 作为一个社区新兴的明星项目,有如此多的局限性,但依然不妨碍 Vite 在开发阶段使用它成功启动项目并获得极致的性能提升

而在生产环境处于稳定性考虑当然是采用功能更加丰富、生态更加成熟的 Rollup 作为依赖打包工具了。

作为 TS 和 JSX 编译工具,进行单文件编译

在依赖预构建阶段, Esbuild 作为 Bundler 的角色存在。而在 TS(X)/JS(X) 单文件编译上面,Vite 也使用 Esbuild 进行语法转译,也就是将 Esbuild 作为 Transformer 来用。

大家可以在架构图中 Vite Plugin Pipeline 部分注意到

也就是说,Esbuild 转译 TS 或者 JSX 的能力通过 Vite 插件提供,这个 Vite 插件在开发环境和生产环境都会执行,因此,我们可以得出下面这个结论:Vite 已经将 Esbuild 的 Transformer 能力用到了生产环境。

尽管如此,对于低端浏览器场景,Vite 仍然可以做到语法和 Polyfill 安全,参考后续的文章

这部分能力用来替换原先的 Babel 或者 TSC 的功能,因为无论是 Babel 还是 TSC 都有性能问题,大家对这两个工具普遍的认知都是: 慢,太慢了

所以 Element-plus 组件库打包中未使用 babel 和 tsc,直接使用 Esbuild

当 Vite 使用 Esbuild 做单文件编译之后,提升可以说相当大了,我们以一个巨大的 50 多 MB 的纯代码文件为例,来对比 Esbuild、Babel、TSC 包括 SWC 的编译性能:
文件时仅仅抹掉了类型相关的代码,暂时没有能力实现类型检查。也因此,在初始化 Vite 工程的构建脚本中,vite build 之前会先执行 tsc 命令,也就是借助 TS 官方的编译器进行类型检查。

当然,要解决类型问题,更推荐大家使用 TS 的编辑器插件。在开发阶段就能早早把问题暴露出来并解决,不至于等到项目要打包上线的时候。

Element-plus 组件库打包时,就也手动引入的 TS 官方的编译器进行类型检查,也就是 .d.ts 类型文件的生成过程

作为压缩工具,进行代码压缩

从架构图中可以看到,在生产环境中 Esbuild 压缩器通过插件的形式融入到了 Rollup 的打包流程中:

为什么 Vite 要将 Esbuild 作为生产环境下默认的压缩工具?因为压缩效率实在太高了!

传统的方式都是使用 Terser 这种 JS 开发的压缩器来实现,在 Webpack 或者 Rollup 中作为一个 Plugin 来完成代码打包后的压缩混淆的工作。
但 Terser 其实很慢,主要有 2 个原因:

  1. 压缩这项工作涉及大量 AST 操作,并且在传统的构建流程中,AST 在各个工具之间无法共享,比如 Terser 就无法与 Babel 共享同一个 AST,造成了很多重复解析的过程。(Esbuild 可以复用 AST)
  2. JS 本身属于解释性 + JIT(即时编译) 的语言,对于压缩这种 CPU 密集型的工作,其性能远远比不上 Golang 这种原生语言。

因此,Esbuild 这种从头到尾共享 AST 以及原生语言编写的 Minifier 在性能上能够甩开传统工具的好几十倍。

对比一下,以下是实际大型库(echarts)的压缩性能测试

压缩一个大小为 3.2 MB 的库,Terser 需要耗费 8798 ms,而 Esbuild 仅仅需要 361 ms,压缩效率较 Terser 提升了二三十倍。
并且产物的体积几乎没有劣化,因此 Vite 果断将其内置为默认的压缩方案。

总结

总的来说,Vite 将 Esbuild 作为自己的性能利器,将 Esbuild 各个垂直方向的能力(Bundler、Transformer、Minifier)利用的淋漓尽致,给 Vite 的高性能提供了有利的保证。

Rollup:构建基石

Rollup 在 Vite 中的重要性一点也不亚于 Esbuild,它既是 Vite 用作生产环境打包的核心工具,也直接决定了 Vite 插件机制的设计。

生产环境 Bundle

虽然 ESM 已经得到众多浏览器的原生支持,但生产环境做到完全 no-bundle 也不行,会有网络性能问题。
所以,为了在生产环境中也能取得优秀的产物性能,Vite 默认选择在生产环境中利用 Rollup 打包,并基于 Rollup 本身成熟的打包能力进行扩展和优化。

Vite 对 Rollup 的优化主要包含 3 个方面:

  1. CSS 代码分割。如果某个异步模块中引入了一些 CSS 代码,Vite 就会自动将这些 CSS 抽取出来生成单独的文件,提高线上产物的缓存复用率。
  2. 自动预加载。Vite 会自动为入口 chunk 的依赖自动生成预加载标签 ,这种适当预加载的做法会让浏览器提前下载好资源,优化页面性能。例如:
1
2
3
4
5
6
7
<head>
<!-- 省略其它内容 -->
<!-- 入口 chunk -->
<script type="module" crossorigin src="/assets/index.250e0340.js"></script>
<!-- 自动预加载入口 chunk 所依赖的 chunk-->
<link rel="modulepreload" href="/assets/vendor.293dca09.js">
</head>
  1. 异步 Chunk 加载优化。在异步引入的 Chunk 中,通常会有一些公用的模块,例如现有两个异步引入的 Chunk: A 和 B,而且两者有一个公共依赖 C,如下图。一般情况下,Rollup 打包之后,会先请求 A,然后浏览器在加载 A 的过程中才决定请求和加载 C。但 Vite 进行优化之后,请求 A 的同时会自动预加载 C,通过优化 Rollup 产物依赖加载方式节省了不必要的网络开销时间。

兼容插件机制的生态

无论是开发阶段还是生产环境,Vite 都根植于 Rollup 的插件机制和生态
如下面的架构图所示:
的思路,自己实现了一个 Plugin Container,用来模拟 Rollup 调度各个 Vite 插件的执行逻辑,而 Vite 的插件写法完全兼容 Rollup,因此在生产环境中将所有的 Vite 插件传入 Rollup 也没有问题。

但反过来,Rollup 插件却不一定能完全兼容 Vite。不过目前仍然有不少 Rollup 插件可以直接复用到 Vite 中。

Vite 与 Snowpack 的对比

Vite 属于人有我优的类型,因为类似的工具之前有 Snowpack,Vite 诞生之后补齐了作为一个 no-bundle 构建工具的 Dev Server 能力(如 HMR),确实比现有的工具能力更优。

但更重要的是,Vite 在社区生态方面比 Snowpack 更占先天优势。
Snowpack 自研了一套插件机制,类似 Rollup 的 Hook 机制,可以看出借鉴了 Rollup 的插件机制,但并不能兼容任何现有的打包工具。如果需要打包,只能调用其它打包工具的 API,自身不提供打包能力。而 Vite 的做法是从头到尾根植于的 Rollup 的生态,设计了和 Rollup 非常吻合的插件机制,而 Rollup 作为一个非常成熟的打包方案,从诞生至今已经迭代了六年多的时间,npm 年下载量达到上亿次,产物质量和稳定性都经历过大规模的验证。某种程度上说,这种根植于已有成熟工具的思路也能打消或者降低用户内心的疑虑,更有利于工具的推广和发展。

为什么选 Vite

参考:Vite

为什么生产环境仍需打包

尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。
为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking懒加载chunk 分割以获得更好的缓存)。

要确保开发服务器和生产环境构建之间的最优输出和行为一致并不容易。所以 Vite 附带了一套 构建优化构建命令,开箱即用。

为什么不用 Esbuild 打包

参考以上 Esbuild 打包时的缺点:

  • 不支持降级到 ES5 的代码。这意味着在低端浏览器代码会跑不起来。
  • 不支持 const enum 等语法。这意味着单独使用这些语法在 esbuild 中会直接抛错。
  • 不提供操作打包产物的接口。像 Rollup 中灵活处理打包产物的能力(如 renderChunk 钩子),在 Esbuild 当中完全没有。
  • 不支持自定义 Code Splitting 策略。传统的 Webpack 和 Rollup 都提供了自定义拆包策略的 API,而 Esbuild 并未提供,从而降级了拆包优化的灵活性。
  • 不支持对异步懒加载请求(路由懒加载,组件懒加载)自动优化

并且,与 Rollup 生态不兼容。

Vite 目前的插件 API 与使用 esbuild 作为打包器并不兼容。尽管 esbuild 速度更快,但 Vite 采用了 Rollup 灵活的插件 API 和基础建设,这对 Vite 在生态中的成功起到了重要作用。目前来看,我们认为 Rollup 提供了更好的性能与灵活性方面的权衡。

但是,在 v3 版本下,Vite 允许在构建阶段使用 esbuild 进行依赖优化。如果开启此项,那么它将消除 v2 版本中存在的最明显的开发与构建最终产物之间的区别。

参考:在构建阶段使用 esbuild 依赖优化

Prev:
Rollup 打包原理与插件
Next:
Vite 配置