Vite 的前世今生:如何与 ESM/esbuild/Rollup 交织出一段爱恨情仇

4,006 阅读14分钟

最近在研究前端工程化,借着这个机会写了篇 vite 原理相关的文章~

什么是前端构建工具

作为现代化前端开发工具链中不可或缺的一环,前端构建工具的基本功能就是:让我们不再做机械重复的事情,解放我们的双手。

image.png

举个栗子:

我喜欢使用 TypeScript/ES6 去代替 Javascript,但浏览器对这些语言是不支持或者支持得不完整的,那么我需要把它编译成 Javascript(ES5),让它可以在浏览器里运行起来,那么我要如何做呢?

  1. 有一个 a.ts

    console.log('Hello World')
    
  2. 执行编译命令

    npx tsc
    
  3. 得到 a.js

    (function(){
      console.log('Hello World');
    }).call(this);
    
  4. 执行压缩丑化命令

    uglify -s a.js -o a.min.js
    
    
  5. 得到 a.min.js

    (function(){console.log("Hello World")}).call(this);
    

image.png

如果我们现在需要修改一下代码,比如在 Hello World 后面加一个感叹号,那么上面那两条命令就又要再执行一遍了。

同样的,我们会用 SASS 去写 CSS,会用 React 去写整个应用,会用 Browserify 去模块化、为非覆盖式部署的资源加 MD5 戳等等。所有的一切,如果用手动来做,简直要疯了!而自动化构建工具,就是为我们完成这一套重复而机械的工作的。

在实际开发中可以发现,我们对前端构建工具主要有两个需求点:

  • 所做即所现,前端作为一个直接面向用户的门户,对页面的交互功能和样式细节有较高的要求,我们需要一个开发服务器来实时更新我们对代码的更改,并可以在浏览器内访问,进而我们的页面有一个具象化的开发流程。

  • 自动化打包项目的能力,在我们完成代码的编写后,部署服务器要运行命令,将代码压缩并打包,部署到指定文件夹,完成一次迭代发布。

为什么要抛弃之前的构建工具

在浏览器支持 ES 模块之前,JavaScript 并没有提供原生机制让开发者以模块化的方式进行开发。(HMR之前,每次修改代码预览的话,都要重新打包)这也正是我们对 “打包” 这个概念熟悉的原因:使用工具抓取、处理并将我们的源码模块串联成可以在浏览器中运行的文件。众多构建工具对开发服务器的实现也是基于这个流程,我们见证了诸如 webpackRollup 和 Parcel 等工具的变迁,它们极大地改善了前端开发者的开发体验。

image.png

webpack 开发服务器:在 webpack 项目中,我们有十个 js 文件。 在启动过程中,开发服务器会从头构建整个模块树,并打包生成浏览器可访问的一个项目。 在开发过程中,如果其中一个文件变更了,开发服务器会检测到这个文件的更改,并进行依赖图的重新构建,进行模块热替换(HMR)。

但是,当我们开始构建越来越大型的应用时,需要处理的 JavaScript 代码量也呈指数级增长。包含数千个模块的大型项目相当普遍。基于 JavaScript 开发的工具就会开始遇到性能瓶颈:通常需要很长时间(甚至是几分钟!)才能启动开发服务器;即使使用模块热替换(HMR),文件修改后的效果也需要几秒钟才能在浏览器中反映出来。

我们发现可以通过以下两个方面来改进构建工具的速度:

  • 使用原生ESM而不是频繁打包来提高启动和HMR的速度,我们看到这两副图,是各种构建工具启动时间与热更新时间的对比,可以看到使用原生ESM带来的巨大加成,然而webpack使用的依旧是每次启动全部打包,速度比较落后

    启动时间对比热更新时间对比

  • 使用go,rust等编译型语言,提高工具运行速度,看这幅图,对比了go与nodejs的各种情况下的速度,等下我也会说明为什么go能比nodejs快这么多

image.png

在2020年左右,前端生态出现两个重大变化:

  • 浏览器开始原生支持 ES 模块

  • 越来越多 JavaScript 工具使用编译型语言(如 GO、R)编写

于是,Vite 横空出世。在短短时间内就追上了webpack star的数量。

image.png

Vite 的根基:ESM

image.png

JavaScript 最初是一种简单的语言,主要用于在 web 页面上添加一些交互效果,不需要太多的代码。但是,随着时间的推移,JavaScript 的应用范围越来越广泛,不仅用于开发复杂的 web 应用,还用于其他环境(比如 Node.js)。因此,近年来,人们开始寻找一种方法,可以把 JavaScript 代码分割成多个模块,按需加载,提高效率和可维护性。

Node.js 已经实现了这个功能,而且很多 JavaScript 库和框架也采用了模块化的思想(例如,CommonJS 和基于 AMD 的其他模块系统 如 RequireJS,以及较新的 Webpack 和 Babel(Babel是一个JS编译器,可以将新的语法转为旧的语法,以便在不支持的浏览器中运行)。

前几年,浏览器也逐渐开始支持原生的模块功能,这对于我们来说是一个好消息 :浏览器可以自己优化模块的加载过程,比使用库更高效(使用库通常需要在客户端做一些额外的处理)。(额外的处理指加载模块的机制,不用esm的话就要自己去实现细节)

image.png

ES模块有三个主要的特点:静态化、实时绑定和异步加载。

  • 静态化:ES模块的导入和导出都是在代码解析阶段确定的,而不是在运行时动态改变的。这样可以让模块之间的依赖关系更清晰,也可以让编译器和工具做更多的优化和分析。
  • 实时绑定:ES模块的导出值不是复制,而是引用。这意味着当一个模块更新了它的导出值,其他引用了它的模块也会立即看到变化,而不需要重新加载或重新执行。
  • 异步加载:ES模块可以通过<script type="module">标签或者import()函数来异步地加载其他模块,而不会阻塞主线程或者影响页面渲染。这样可以提高页面的性能和用户体验。

让我们简单地看看这个新的模块系统是怎么工作的。

原理

ES模块在浏览器中的工作流程可以分为三个步骤:

  • 构建:找到、下载并解析所有的模块文件,生成模块记录。
  • 实例化:在内存中为所有的导出值分配空间(但不填充具体的值),然后让导出和导入都指向这些内存空间。
  • 评估:按照依赖顺序,执行每个模块的代码,填充导出值,并触发副作用。

image.png

这三个步骤是分开进行的,也就是说,浏览器不会等待一个模块完成所有的步骤才开始处理下一个模块,而是会并行地处理多个模块,提高效率。

下面我们将结合一个例子来说明ES模块工作的三个步骤:

假设有两个模块文件,main.jsutils.js,它们的内容分别是:

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

// utils.js
export function add(a, b) {
    return a + b;
}

如果在一个HTML文件中,用<script type="module" src="main.js">标签来加载main.js模块,那么浏览器会进行以下三个步骤:

构建

浏览器会下载main.js文件,并将它解析为一个模块记录,记录它的导入列表(./utils.js)和导出列表(空)。然后,浏览器会根据导入列表,下载并解析utils.js文件,记录它的导入列表(空)和导出列表( add函数)。

image.png

这一步是将模块文件从URL转换为模块记录的过程。模块记录是一个内部的数据结构,它包含了模块的元信息,比如它的导入和导出列表,以及它的代码。浏览器会根据<script type="module">标签或者import()函数的参数,找到并下载相应的模块文件,然后用解析器将它们转换为模块记录。这一步不会执行模块的代码,也不会检查模块的依赖是否存在或有效。

实例化

浏览器会为每个模块的导出值分配内存空间。对于main.js模块,它没有导出值,所以不需要分配空间。对于utils.js模块,它有一个导出值,就是add函数,所以浏览器会为它创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间。对于main.js模块,它的导入列表中有一个名为add的变量,浏览器会让它指向刚刚为utils.js模块创建的内存空间。对于utils.js模块,它没有导入列表,所以不需要指向任何内存空间。

image.png

这一步是为模块的导出值分配内存空间,并建立导入和导出之间的联系的过程。浏览器会遍历所有的模块记录,为每个导出值创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间,形成一个实时绑定。这一步也不会执行模块的代码,但会检查模块的依赖是否存在或有效,如果有问题,会抛出错误。

评估

浏览器会按照依赖顺序执行每个模块的代码。首先,浏览器会执行utils.js模块的代码,定义并赋值给add函数,并将这个值填充到之前为它分配的内存空间中。然后,浏览器会执行main.js模块的代码,调用并打印出add(1, 2)的结果。由于之前已经建立了实时绑定,所以当main.js模块引用了名为add的变量时,就相当于引用了刚刚定义好的函数。

image.png

这一步是执行模块的代码,并给导出值赋予具体的值的过程。浏览器会按照依赖顺序,从最底层的模块开始,依次执行每个模块的代码。当一个模块的代码执行完毕后,它的导出值就会被填充到内存空间中,并且其他引用了它的模块也可以看到变化。这一步也会触发模块的副作用,比如修改全局变量或者调用其他函数。

使用

让我们来看看如何在浏览器中使用 ESM:

<script type="module">
  import lodash from '<https://cdn.skypack.dev/lodash>'
</script>

我们可以发现,只需要在 script 标签上面加一行 type="module",就可以 Import from URL 了。

由于前端跑在浏览器中,因此它也只能从 URL 中引入 Package

  1. 绝对路径: https://cdn.sykpack.dev/lodash
  2. 相对路径: ./lib.js

现在打开浏览器控制台,把以下代码粘贴在控制台中。由于 http import 的引入,你发现你调试 lodash 此列工具库更加方便了。

> lodash = await import('<https://cdn.skypack.dev/lodash>')

> lodash.get({ a: 3 }, 'a')

当然我们马上就发现,这样 import 太麻烦了,每次都需要输入完全的 URL。ESM 贴心的给我们提供了一个 importmap 的机制,使得裸导入(bare import specifiers)可正常工作:

<script type="importmap">
{
  "imports": {
    "lodash": "<https://cdn.skypack.dev/lodash>",
		"lodash/": "<https://cdn.skypack.dev/lodash/>",
    "ms": "<https://cdn.skypack.dev/ms>"
  }
}
</script>

<script type="module">
  import lodash from 'lodash'
  import get from 'lodash/get.js'
  import("ms").then(_ => ...)
</script>

在Vite中的应用

  • 在开发模式下,Vite 通过 ESM 来直接在浏览器中加载源代码,无需打包。这样,Vite 只需要按需转换和服务源代码,而不需要处理整个模块图。这使得 Vite 的启动速度和热更新速度非常快。
  • 在生产模式下,Vite 通过 Rollup 来打包源代码为 ESM 格式的静态资源,以便浏览器高效地加载和缓存。Vite 还支持多种构建目标,可以兼容不同版本的 ESM 支持。

image.png

上图表示了开发服务器有无使用ESM的区别,没有使用ESM的开发服务器在启动的时候需要将所有资源进行打包,导致启动过程缓慢。而使用了ESM的开发服务器,无需打包,直接启动并发送入口文件至浏览器,浏览器自己去获取依赖模块。

Vite 与 esbuild 的关系

esbuild 是一种新型的前端构建工具,它能够快速地打包 JavaScript、CSS、TypeScript 和 JSX 等资源。它使用 Go 语言编写,利用多核并行处理和高效的算法来实现极快的速度。它还提供了一些主要特性,如模块化、tree shaking、压缩、source maps、插件等。esbuild 的目标是提供一个易于使用的现代构建工具,并开创构建工具性能的新时代。

image.png

它的构建速度是 webpack 的几十倍。为什么这么快 ?

  1. js是单线程串行,esbuild是新开一个进程,然后多线程并行,充分发挥多核优势

  2. go是纯机器码,肯定要比JIT快。

  3. Esbuild 选择重写包括 js、ts、jsx、css 等语言在内的转译工具,所以它更能保证源代码在编译步骤之间的结构一致性,比如在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:

    • Webpack 读入源码,此时为字符串形式
    • Babel 解析源码,转换为 AST 形式
    • Babel 将源码 AST 转换为低版本 AST
    • Babel 将低版本 AST generate 为低版本源码,字符串形式
    • Webpack 解析低版本源码
    • Webpack 将多个模块打包成最终产物

    源码需要经历 string => AST => AST => string => AST => string ,在字符串与 AST 之间反复横跳。而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。

虽然 Esbuild 这么牛,但其也有些问题:

  • Code splitting ,Css content type 问题较多
  • ESbuild 没有提供 AST 的操作能力-----------不能兼容一些低版本浏览器(ESbuild 只能将代码转成 es6)

后果就是,Esbuild 当下与未来都不能替代 Webpack 等高层构建工具,它不适合直接用于生产环境,而更适合作为一种偏底层的模块打包工具,需要在它的基础上二次封装,扩展出一套既兼顾性能又有完备工程化能力的工具链,例如 SnowpackViteSvelteKitRemix Run 等。

预构建

vite对esbuild的一个主要用途就是预构建阶段对依赖进行快速构建。vite将代码分为源码依赖两部分并且分别处理。依赖便是应用使用的第三方包,一般存在于node_modules 目录中,一个较大项目的依赖及其依赖的依赖,加起来可能达到上千个包。

image.png

这些代码可能远比我们源码代码量要大,这些依赖通常是不会改变的(除非你要进行本地依赖调试)所以无论是webpack或者vite在启动时都会编译后将其缓存下来。

但是webpack和vite的预构建依旧存在区别,**vite会使用esbuild进行依赖编译和转换(commonjs包转为esm)**而webpack则是使用acorn或者tsc进行编译,而esbuild是使用Go语言写的,其速度比使用js编写的acorn速度要快得多。

读者可能要问了,在开发环境你 vite 启动速度确实薄纱 webpack,但生产环境的打包呢?

能不能优化 webpack 构建速度,优化到接近vite的速度呢?

webpack 作为老牌的构建器,拥有各种花里胡哨的的插件、配置来变相规避其短板,比如:

  • 指定固定路径
  • 使用 esbuild-loader 加快打包速度
  • HappyPack多进程loader
  • ParallelUglifyPlugin多进程压缩
  • DllPlugin减少依赖编译

但是不管webpack装了啥插件,其打包速度还是和vite里面的rollup比。下面我们来看看 Rollup。

Vite 与 Rollup 的关系

我们知道,Vite 开发时,用的是 esbuild 进行构建,而在生产环境,则是使用 Rollup 进行打包。

为什么生产环境仍需要打包?为什么不用 esbuild 打包?

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

虽然 esbuild 快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割和 CSS 处理方面。就目前来说,Rollup 在应用打包方面更加成熟和灵活。尽管如此,当未来这些功能稳定后,我们也不排除使用 esbuild 作为生产构建器的可能。

我们总结一下,使用Rollup,而不是webpack或者esbuild作为打包工具的原因:

  1. Rollup使用新的ESM,而Webpack用的是旧的CommonJS。
  2. Rollup 的打包文件体积很小。
  3. Rollup支持相对路径,webpack需要使用path模块。
  4. 尽管esbuild速度更快,但Vite采用了Rollup灵活的插件API和基础建设,这对Vite在生态中的成功起到了重要作用。

我们都知道,构建工具的一个很重要的功能点就是插件,既然使用了 rollup 来进行生产环境的构建,那Vite 需要保证,同一套 Vite 配置文件和源码,在开发环境和生产环境下的表现是一致的

想要达到这个效果,只能是 Vite 在开发环境模拟 Rollup 的行为 ,在生产环境打包时,将这部分替换成 Rollup 打包。

简单来说,Vite 有自己的生态,同时也需要兼容 Rollup 的生态,实现 Rollup 的插件机制。

image.png

Rollup mini 版 - 插件容器

插件容器,是一个小的 Rollup,实现了 Rollup 的插件机制。插件容器实现的功能如下:

  • 提供 Rollup 钩子的 Context 上下文对象

    Rollup 插件可以调用 this.xxx 来使用一些 Rollup 提供的实用工具函数,插件容器需要实现这个上下文对象。

  • 对钩子的返回值进行相应处理

  • 实现钩子的类型

具体的可以参考 Rollup 文档 ,这里不多赘述。

Vite 使用插件容器,对 Rollup 插件的工具函数调用进行了兼容,Vite 在构建对应的阶段,会执行 Rollup 对应的钩子

上面说了这么多,Rollup 打包到底有没有 Webpack 快?

Rollup的初衷是希望开发者去写esm,而不是cjs。因为esm是javascript的新标准,是未来,有很多优点,高版本浏览器也支持。

所以,Rollup 官方放弃了对cjs的支持,仅作为插件兼容选项。

相当于,webpack自己实现polyfill支持模块语法,rollup是利用高版本浏览器原生支持esm。rollup本身也就不会去做polyfill,也会让打包体积小很多。

所以,Rollup 比 Webpack 快,虽然快的有限,但依旧是质变,因为有舍肯定有得。

题外话:HMR

Vite 提供了一套原生 ESM 的 HMR API(怎么和浏览器通信)。在 React 中,使用官方的 vite-plugin-react 插件即可享受到 React Fast Refresh(我需要更新什么东西) + 原生 HWR 的快速刷新功能。

React Fast Refresh 是 React 官方为 React Native 开发的模块热替换(HMR)方案,由于其核心实现与平台无关,所以也适用于 Web。

Fast Refresh 有以下几个优势:

  • 它可以让你在修改 React 组件的时候,快速地看到变化,而不会丢失组件的状态。
  • 它可以捕获代码中的错误,并在浏览器中显示一个错误覆盖层,帮助你快速定位和解决问题。
  • 它可以自动判断哪些文件需要更新,哪些文件需要重新运行,从而提高开发效率。

Fast Refresh 也有以下一些局限性:

  • 它不支持类组件(只支持函数组件和 Hooks)。
  • 它不支持在入口文件中直接修改组件,这会导致整个应用重新加载。
  • 它不支持在 webpack 的 externals 配置项中引入 react-refresh,这会导致它失效。

vite-plugin-react-swc 是 vite 官方推出的另一款插件,可以在开发时使用 SWC 来替换 Babel,从而提高 Vite 开发服务器的速度。 它还可以在构建时使用 SWC 和 esbuild 来编译 React 代码,并启用自动 JSX 运行时。

所以说我们在开发的时候,使用 vite 的 SWC + esbuild 的 HWR 解决方案,可以大大提高我们代码的热更新速度。

总结

本文对 vite 的出现过程、原理、与其他构建工具的关系做出了框架性回顾与分析,看完觉得有收获的话不妨点赞评论关注走一波~如有建议与意见请多多指教~