vite 官网中对自己的介绍是 “下一代前端开发与构建工具”。即 vite 的定位是前端开发构建工具,并且强调了自己是全新的、下一代的。和“下一代”相对应的是“上一代”,也就是当前最流行的构建工具如 webpack、rollup。那么“上一代”构建工具存在什么问题,我们为什么又需要构建工具?下面就从前端构建工具发展、vite 极速构建原理和 vite 插件编写三方面来进行介绍。

前端构建工具简介

在“前端开发”这一工种诞生以前,负责网页 UI 界面开发的程序员/设计师,还叫做 UI 开发。虽没有亲身经历,但是可以想象当时主要的工作是画一些 HTML 页面,JavaScript 只是用来处理一些简单交互逻辑。随着 CPU 和浏览器性能的不断提升,越来越复杂的业务逻辑放在了浏览器端。

相应地,前端代码也变得更加复杂,如何去管理和组织是越来越复杂的业务逻辑成为前端工程师需要解决的问题,答案就是模块化。为了解决模块化,社区提出了一系列模块化规范:AMD、CMD、commonjs、UMD,最后 Ecma 官方在 ES6 中推出了 ES Modules。与此同时,也诞生了很多构建工具比如 grunt、gulp、webpack、rollup。

grunt 和 gulp 作为早期的自动化构建工具,主要的工作还是跑一些自动化任务,比如图片、代码压缩,文件添加哈希等等。而 webpack 则是从解决 JS 模块化问题,再利用其强大的插件机制来处理资源依赖管理的前端工程化解决方案,同时社区也给予了大力的支持。由此 webpack 成为最主流的前端构建工具。

既然 webpack 已经如此全能,为什么尤雨溪还会投入极大的精力和热情去搞 vite 呢?直接原因是—— webpack 太慢了。尤雨溪曾表示自己开发 vite 的主要原因是自己在开发 vuepress 时,稍微改动一个文件就得等上很久才能看到效果。那么为什么 webpack 那么慢,而 vite 又是如何提速的呢?

Vite 极速构建原理

直观感受

在深入构建原理之前,我们先从直观上来感受一下 vite 的极速构建。

从 github 上找一个 vue3 的 demo,https://github.com/vincentzyc/vue3-demo。这个项目本来是基于 vue-cli 做的构建,稍加改造使得它也可以基于 vite 进行构建。使用两种构建方式来进行跑一下开发构建:

项目启动:

  • vite

揭秘 Vite 极速构建 - 图1

  • vue-cli

揭秘 Vite 极速构建 - 图2

热更新:

  • vite 几乎瞬间完成

揭秘 Vite 极速构建 - 图3

  • vue-cli

揭秘 Vite 极速构建 - 图4

从直观上来看,不论在项目启动还是热更新中,也就是 dev 场景下 vite 都比 vue-cli 表现地更加高效。在 dev 场景下,vue-cli 的核心则是 webpack + webpack-dev-server。vite 比上一代构建工具 webpack 表现地更为极致。

vite 官方文档其实已经对两者的差异做了解释:

我们在开发一个项目时,都会执行类似 npm run serve 或者 npm run dev 的指令,来启动 dev-server,对于传统的 dev-server 比如 webpack,都是先抓取整个应用构建出包,才提供服务。而 vite 则利用了浏览器原生加载 ESM 模块的特性,先启动服务,当需要请求某个文件时,再对文件处理并返回。事实上也实现了动态加载

  • webpack

揭秘 Vite 极速构建 - 图5

  • vite

揭秘 Vite 极速构建 - 图6

从入口开始

以我们的 demo 为例,vite 启动的 dev-server 的地址为 http://localhost:3000/#/ ,打开 devtools 的 network 面板,可以看到有个 script 标签会请求我们的入口文件——main.ts。这里的 script 标签的 type 是 ‘module’,表示采用 ESM 的规范去加载。

揭秘 Vite 极速构建 - 图7

继续看 network 面板,浏览器继续请求了 main.ts 文件。震惊!浏览器能直接执行 ts 文件了?

揭秘 Vite 极速构建 - 图8

其实不然,查一下这个请求的 content-type 就可以发现是 ‘application/javascript’,这样的话浏览器就会把 main.ts 当作 javascript 来处理了。并且,我们查看返回的 main.ts 也可以发现其中的内容也是编译后的 js 代码。

同理,main.ts 依赖的 app.vue 文件也是同样的套路。content-type是’application/javascript’,文件内容是编译后的 js 代码。

揭秘 Vite 极速构建 - 图9

揭秘 Vite 极速构建 - 图10

到这里,我们就能基本知晓 vite 在 dev 模式下运行的大概流程。先启动一个 dev-server,返回 index.html,而 html 中的 script 标签都是 ESM 规范的,因此会按照 ESM 规范加载代码。当浏览器需要某个文件时,dev-server 会对该文件执行一定的处理,再返回给浏览器。因为 dev-server 是立即启动,而且需要的文件是按需加载,而首屏需要的代码通常并没有很多。所以在 dev 模式下,vite 会比 webpack 快很多。

基于 ESM的 Dev Server 和 HMR

模块路径解析

根据上文的分析,我们了解到使用 vite 调试时,基于 ESM 的规范加载代码,请求到某个文件直接处理后返回。这就会造成一个问题。业务源码中通常会有 import { createApp } from vue这类代码,这里原意是去加载 node_modules 下 vue 模块。但是基于 ESM 规范实际上加载的是 http://localhost:3000/vue,实际上是无法正确加载依赖。

因此,vite 的 dev-server 会去对这类的裸模块进行路径重写。以 vue 模块为例,看下如何解析和重写。浏览器请求某个文件(如 main.ts),先会经过服务器的处理,会发现它依赖一个裸模块 vue,ESM 没法直接加载裸模块。这个时候 vite dev-server 就重写依赖路径 vue->/node_modules/.vite/vue.js?v=xxxx (vite 会把所有依赖的裸模块缓存到 /node_modules/.vite目录下)

  • main.ts 源码

揭秘 Vite 极速构建 - 图11

  • network 面板

揭秘 Vite 极速构建 - 图12

那么,vite 是如何把 vue 映射到 /node_modules/.vite/vue.js 的呢?这里其实是预构建做的事情了。

预构建

在实际开发中,可能我们依赖的一个模块可能没有 ESM bundle,但是 vite dev-server 需要的是 ESM 的模块。所以必须将 commonjs/UMD 的文件提前处理,转化成 ESM bundle。

此外,还是以 vue 模块为例,vue 的 ESM bundle 也依赖了 @vue/runtime-dom模块,而 @vue/runtime-dom 又依赖了 @vue/runtime-core @vue/shared 等模块。这样当请求 vue.js 的时候,会根据依赖关系发出更多次请求。对于 vue 这种模块还好,也就依赖几个子模块。而 lodash-es这种包会有几百个子模块,当代码中出现 import { debounce } from 'lodash-es' 会发出几百个 http 请求,这样肯定会影响页面的加载,并且大部分的请求都是无用的。

因此,vite 在会在服务启动之前,对上面两种情况的依赖模块进行预构建。具体的做法是从入口(index.html)出发,利用 <font style="color:rgb(44, 62, 80);">es-module-lexer</font>分析依赖关系。然后以这些依赖作为预构建的入口,进行打包构建。同时为了提升打包效率,采用了 ESbuild 进行构建。如下图可以看到,打包 vue 的 ESM bundle 仅耗时 58ms。

揭秘 Vite 极速构建 - 图13

这里说的其实是 vite 自动扫描依赖层次,来进行的预构建。此外,vite 也支持通过用户配置,预构建指定的依赖模块。

HMR(热替换)

前文说明了 vite 是如何启动一个 ESM 的 dev-server,以及 vite 所做的一些优化。当文件发生修改时,vite HMR 的表现还会好于 webpack 吗?原因又是什么呢。

处理 HMR 都需要监听文件系统的变更,再对变更后的代码重新加载。这一点 vite 和 webpack 的思路是一致的,vite 通过 chokidar 来监听文件系统的变更,只用对发生变更的模块重新加载即可。而 webpack 还要经历一次打包构建。所以 HMR 场景下,vite 表现也要好于 webpack。

接下来看下 vite 实现 HMR 的思路。

首先,vite 中定义了两个类 ModuleGraph 和 ModuleNode ,用这两个类来记录整个应用的模块依赖图。

揭秘 Vite 极速构建 - 图14揭秘 Vite 极速构建 - 图15

可以构建出如下的模块依赖图:

揭秘 Vite 极速构建 - 图16

而在 vite dev-server 对浏览器请求的模块进行 transform 的时候,会有对模块进行划分。具体方式是在给到浏览器的响应中添加一段代码。这段代码标识了模块的边界,可以这么理解:依赖的变更到此为止,只需要在边界模块重新执行依赖,完成 HMR。

  1. import.meta.hot.accept(({ }) => {
  2. // 接收到更新后的操作
  3. })

揭秘 Vite 极速构建 - 图17

例如下面的 HellowWorld.vue 就是 boundary,而其依赖的 css 则不是。

揭秘 Vite 极速构建 - 图18

当依赖更新时,根据之前准备好的模块依赖图找到 boundaries,再由 boundaries 重新加载变更的模块即可。

揭秘 Vite 极速构建 - 图19

揭秘 Vite 极速构建 - 图20

当然还有种情况时,一直向上查找都没找到 boundary,这时候就直接重新加载整个应用。这也和 webpack 的表现一致。

揭秘 Vite 极速构建 - 图21

Vite 插件开发

基于 rollup 的插件机制

vite 的插件机制是对 rollup 插件的扩展,因此编写 vite 插件和 rollup 插件基本结构一样,也就是返回一个带有一系列钩子的对象。vite 在 rollup 基础上扩展了自己特定的钩子。

插件示例:

  1. export default function myPlugin() {
  2. const virtualFileId = '@my-virtual-file'
  3. return {
  4. name: 'my-plugin', // 必须的,将会显示在 warning 和 error 中
  5. resolveId(id) {
  6. if (id === virtualFileId) {
  7. return virtualFileId
  8. }
  9. },
  10. load(id) {
  11. if (id === virtualFileId) {
  12. return `export const msg = "from virtual file"`
  13. }
  14. }
  15. }
  16. }
  • 通用钩子:
    • dev-server 启动时(只执行一次)
      • options 读取配置选项时,这个钩子里可以对构建选项进行修改
      • buildStart 开始构建启动时
    • 浏览器发送模块请求时
      • resolveId 自定义确认函数,通常用来定位第三方依赖
      • load 自定义加载函数,可以返回自定义的内容
      • transform 自定义转换函数
    • dev-server 关闭时(只执行一次)
      • buildEnd
      • closeBundle
  • vite 定制钩子:
    • config 解析 vite 配置前调用
    • configResolved 解析 vite 配置后调用
    • configureServer 配置 dev-server 钩子,为 dev-server 添加自定义的中间件
    • transformIndexHtml 转换入口文件 index.html 的钩子函数,参数是 index.html 的内容字符串和转换上下文
    • handleHotupdate 执行自定义的热更新处理,在这个钩子中可以通过ws向客户端发送自定义事件

DEMO:mockServerPlugin

mock-server 通常用来模拟后台服务器接收前端的请求,返回一些 mock 的数据。这样即使后台还没有准备好的情况下,前端也可以模拟整个业务流程。主要也是在 dev 场景下使用,因此可以考虑以插件的形式提供给 vite。

首先,我们要使用某个插件,需要先在 vite.config.js/ts 中引用:

揭秘 Vite 极速构建 - 图22

由于 mock-server 插件是要提供 web 接口的能力。参考上面的钩子,我们会选择在 configureServer 配置 dev-server 的时候,增加 mock-server 的能力,即添加自定义的中间件。

因为我们希望当一个请求来的时候,优先匹配 mock-server 的路由进行处理返回,因此我们会采用 configureServer 默认的时机,也就是在安装内置中间件前安装 mock-server 中间件。

最后,我们看下这个插件的核心代码:

  • 返回一个插件函数,通过函数调用返回一个 vite 插件
  • 可以通过选项参数配置自定义的 mock 文件
  • 定义中间件,如果匹配到指定的 mock api 的路由,则返回配置的 mock 数据;如果没匹配,交给下一个中间件进行处理
  • 使用自定义的中间件

揭秘 Vite 极速构建 - 图23

这样我们的 dev-server 就有了 mock-server 的能力

揭秘 Vite 极速构建 - 图24揭秘 Vite 极速构建 - 图25

一些思考

  • 到现在都没提到的 vite 在生产环境下的构建,其实是用 rollup 构建 bundle,这也是对现实的一种妥协😂
  • vite 解决的问题是 dev 模式下快速启动,按需加载。借助了浏览器原生对 ESM 的支持、esbuild 的构建效率,也学习了 snowpack 依赖预构建的思想
  • vite 和 webpack 的关系,一切以提升个人/团队生产力为依归。对个人而言,会关注 vite 发展,在合适的时机用合适的工具

参考文章