Vite 的前世今生:如何与 ESM/esbuild/Rollup 交织出一段爱恨情仇
最近在研究前端工程化,借着这个机会写了篇 vite 原理相关的文章~
什么是前端构建工具
作为现代化前端开发工具链中不可或缺的一环,前端构建工具的基本功能就是:让我们不再做机械重复的事情,解放我们的双手。
举个栗子:
我喜欢使用 TypeScript/ES6 去代替 Javascript,但浏览器对这些语言是不支持或者支持得不完整的,那么我需要把它编译成 Javascript(ES5),让它可以在浏览器里运行起来,那么我要如何做呢?
-
有一个
a.ts
console.log('Hello World')
-
执行编译命令
npx tsc
-
得到
a.js
(function(){ console.log('Hello World'); }).call(this);
-
执行压缩丑化命令
uglify -s a.js -o a.min.js
-
得到
a.min.js
(function(){console.log("Hello World")}).call(this);
如果我们现在需要修改一下代码,比如在 Hello World
后面加一个感叹号,那么上面那两条命令就又要再执行一遍了。
同样的,我们会用 SASS 去写 CSS,会用 React 去写整个应用,会用 Browserify 去模块化、为非覆盖式部署的资源加 MD5 戳等等。所有的一切,如果用手动来做,简直要疯了!而自动化构建工具,就是为我们完成这一套重复而机械的工作的。
在实际开发中可以发现,我们对前端构建工具主要有两个需求点:
-
所做即所现,前端作为一个直接面向用户的门户,对页面的交互功能和样式细节有较高的要求,我们需要一个开发服务器来实时更新我们对代码的更改,并可以在浏览器内访问,进而我们的页面有一个具象化的开发流程。
-
自动化打包项目的能力,在我们完成代码的编写后,部署服务器要运行命令,将代码压缩并打包,部署到指定文件夹,完成一次迭代发布。
为什么要抛弃之前的构建工具
在浏览器支持 ES 模块之前,JavaScript 并没有提供原生机制让开发者以模块化的方式进行开发。(HMR之前,每次修改代码预览的话,都要重新打包)这也正是我们对 “打包” 这个概念熟悉的原因:使用工具抓取、处理并将我们的源码模块串联成可以在浏览器中运行的文件。众多构建工具对开发服务器的实现也是基于这个流程,我们见证了诸如 webpack、Rollup 和 Parcel 等工具的变迁,它们极大地改善了前端开发者的开发体验。
webpack 开发服务器:在 webpack 项目中,我们有十个
js
文件。 在启动过程中,开发服务器会从头构建整个模块树,并打包生成浏览器可访问的一个项目。 在开发过程中,如果其中一个文件变更了,开发服务器会检测到这个文件的更改,并进行依赖图的重新构建,进行模块热替换(HMR)。
但是,当我们开始构建越来越大型的应用时,需要处理的 JavaScript 代码量也呈指数级增长。包含数千个模块的大型项目相当普遍。基于 JavaScript 开发的工具就会开始遇到性能瓶颈:通常需要很长时间(甚至是几分钟!)才能启动开发服务器;即使使用模块热替换(HMR),文件修改后的效果也需要几秒钟才能在浏览器中反映出来。
我们发现可以通过以下两个方面来改进构建工具的速度:
-
使用原生ESM而不是频繁打包来提高启动和HMR的速度,我们看到这两副图,是各种构建工具启动时间与热更新时间的对比,可以看到使用原生ESM带来的巨大加成,然而webpack使用的依旧是每次启动全部打包,速度比较落后
-
使用go,rust等编译型语言,提高工具运行速度,看这幅图,对比了go与nodejs的各种情况下的速度,等下我也会说明为什么go能比nodejs快这么多
在2020年左右,前端生态出现两个重大变化:
-
浏览器开始原生支持 ES 模块
-
越来越多 JavaScript 工具使用编译型语言(如 GO、R)编写
于是,Vite 横空出世。在短短时间内就追上了webpack star的数量。
Vite 的根基:ESM
JavaScript 最初是一种简单的语言,主要用于在 web 页面上添加一些交互效果,不需要太多的代码。但是,随着时间的推移,JavaScript 的应用范围越来越广泛,不仅用于开发复杂的 web 应用,还用于其他环境(比如 Node.js)。因此,近年来,人们开始寻找一种方法,可以把 JavaScript 代码分割成多个模块,按需加载,提高效率和可维护性。
Node.js 已经实现了这个功能,而且很多 JavaScript 库和框架也采用了模块化的思想(例如,CommonJS 和基于 AMD 的其他模块系统 如 RequireJS,以及较新的 Webpack 和 Babel(Babel是一个JS编译器,可以将新的语法转为旧的语法,以便在不支持的浏览器中运行)。
前几年,浏览器也逐渐开始支持原生的模块功能,这对于我们来说是一个好消息 :浏览器可以自己优化模块的加载过程,比使用库更高效(使用库通常需要在客户端做一些额外的处理)。(额外的处理指加载模块的机制,不用esm的话就要自己去实现细节)
ES模块有三个主要的特点:静态化、实时绑定和异步加载。
- 静态化:ES模块的导入和导出都是在代码解析阶段确定的,而不是在运行时动态改变的。这样可以让模块之间的依赖关系更清晰,也可以让编译器和工具做更多的优化和分析。
- 实时绑定:ES模块的导出值不是复制,而是引用。这意味着当一个模块更新了它的导出值,其他引用了它的模块也会立即看到变化,而不需要重新加载或重新执行。
- 异步加载:ES模块可以通过
<script type="module">
标签或者import()
函数来异步地加载其他模块,而不会阻塞主线程或者影响页面渲染。这样可以提高页面的性能和用户体验。
让我们简单地看看这个新的模块系统是怎么工作的。
原理
ES模块在浏览器中的工作流程可以分为三个步骤:
- 构建:找到、下载并解析所有的模块文件,生成模块记录。
- 实例化:在内存中为所有的导出值分配空间(但不填充具体的值),然后让导出和导入都指向这些内存空间。
- 评估:按照依赖顺序,执行每个模块的代码,填充导出值,并触发副作用。
这三个步骤是分开进行的,也就是说,浏览器不会等待一个模块完成所有的步骤才开始处理下一个模块,而是会并行地处理多个模块,提高效率。
下面我们将结合一个例子来说明ES模块工作的三个步骤:
假设有两个模块文件,main.js
和utils.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
函数)。
这一步是将模块文件从URL转换为模块记录的过程。模块记录是一个内部的数据结构,它包含了模块的元信息,比如它的导入和导出列表,以及它的代码。浏览器会根据
<script type="module">
标签或者import()
函数的参数,找到并下载相应的模块文件,然后用解析器将它们转换为模块记录。这一步不会执行模块的代码,也不会检查模块的依赖是否存在或有效。
实例化
浏览器会为每个模块的导出值分配内存空间。对于main.js
模块,它没有导出值,所以不需要分配空间。对于utils.js
模块,它有一个导出值,就是add
函数,所以浏览器会为它创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间。对于main.js
模块,它的导入列表中有一个名为add
的变量,浏览器会让它指向刚刚为utils.js
模块创建的内存空间。对于utils.js
模块,它没有导入列表,所以不需要指向任何内存空间。
这一步是为模块的导出值分配内存空间,并建立导入和导出之间的联系的过程。浏览器会遍历所有的模块记录,为每个导出值创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间,形成一个实时绑定。这一步也不会执行模块的代码,但会检查模块的依赖是否存在或有效,如果有问题,会抛出错误。
评估
浏览器会按照依赖顺序执行每个模块的代码。首先,浏览器会执行utils.js
模块的代码,定义并赋值给add
函数,并将这个值填充到之前为它分配的内存空间中。然后,浏览器会执行main.js
模块的代码,调用并打印出add(1, 2)
的结果。由于之前已经建立了实时绑定,所以当main.js
模块引用了名为add
的变量时,就相当于引用了刚刚定义好的函数。
这一步是执行模块的代码,并给导出值赋予具体的值的过程。浏览器会按照依赖顺序,从最底层的模块开始,依次执行每个模块的代码。当一个模块的代码执行完毕后,它的导出值就会被填充到内存空间中,并且其他引用了它的模块也可以看到变化。这一步也会触发模块的副作用,比如修改全局变量或者调用其他函数。
使用
让我们来看看如何在浏览器中使用 ESM:
<script type="module">
import lodash from '<https://cdn.skypack.dev/lodash>'
</script>
我们可以发现,只需要在 script
标签上面加一行 type="module"
,就可以 Import from URL 了。
由于前端跑在浏览器中,因此它也只能从 URL 中引入 Package
- 绝对路径:
https://cdn.sykpack.dev/lodash
- 相对路径:
./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 支持。
上图表示了开发服务器有无使用ESM的区别,没有使用ESM的开发服务器在启动的时候需要将所有资源进行打包,导致启动过程缓慢。而使用了ESM的开发服务器,无需打包,直接启动并发送入口文件至浏览器,浏览器自己去获取依赖模块。
Vite 与 esbuild 的关系
esbuild 是一种新型的前端构建工具,它能够快速地打包 JavaScript、CSS、TypeScript 和 JSX 等资源。它使用 Go 语言编写,利用多核并行处理和高效的算法来实现极快的速度。它还提供了一些主要特性,如模块化、tree shaking、压缩、source maps、插件等。esbuild 的目标是提供一个易于使用的现代构建工具,并开创构建工具性能的新时代。
它的构建速度是 webpack 的几十倍。为什么这么快 ?
-
js是单线程串行,esbuild是新开一个进程,然后多线程并行,充分发挥多核优势
-
go是纯机器码,肯定要比JIT快。
-
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 等高层构建工具,它不适合直接用于生产环境,而更适合作为一种偏底层的模块打包工具,需要在它的基础上二次封装,扩展出一套既兼顾性能又有完备工程化能力的工具链,例如 Snowpack, Vite, SvelteKit, Remix Run 等。
预构建
vite对esbuild的一个主要用途就是预构建阶段对依赖进行快速构建。vite将代码分为源码和依赖两部分并且分别处理。依赖便是应用使用的第三方包,一般存在于node_modules
目录中,一个较大项目的依赖及其依赖的依赖,加起来可能达到上千个包。
这些代码可能远比我们源码代码量要大,这些依赖通常是不会改变的(除非你要进行本地依赖调试)所以无论是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 打包?
虽然 esbuild
快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割和 CSS 处理方面。就目前来说,Rollup 在应用打包方面更加成熟和灵活。尽管如此,当未来这些功能稳定后,我们也不排除使用 esbuild
作为生产构建器的可能。
我们总结一下,使用Rollup,而不是webpack或者esbuild作为打包工具的原因:
- Rollup使用新的ESM,而Webpack用的是旧的CommonJS。
- Rollup 的打包文件体积很小。
- Rollup支持相对路径,webpack需要使用path模块。
- 尽管esbuild速度更快,但Vite采用了Rollup灵活的插件API和基础建设,这对Vite在生态中的成功起到了重要作用。
我们都知道,构建工具的一个很重要的功能点就是插件,既然使用了 rollup 来进行生产环境的构建,那Vite 需要保证,同一套 Vite 配置文件和源码,在开发环境和生产环境下的表现是一致的。
想要达到这个效果,只能是 Vite 在开发环境模拟 Rollup 的行为 ,在生产环境打包时,将这部分替换成 Rollup 打包。
简单来说,Vite 有自己的生态,同时也需要兼容 Rollup 的生态,实现 Rollup 的插件机制。
Rollup mini 版 - 插件容器
插件容器,是一个小的 Rollup,实现了 Rollup 的插件机制。插件容器实现的功能如下:
-
提供 Rollup 钩子的 Context 上下文对象
Rollup 插件可以调用
this.xxx
来使用一些 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 的出现过程、原理、与其他构建工具的关系做出了框架性回顾与分析,看完觉得有收获的话不妨点赞评论关注走一波~如有建议与意见请多多指教~
转载自:https://juejin.cn/post/7240740177449435191