快速理解 Vite 的依赖预构建

5,475 阅读11分钟

当我们使用 Vite 进行开发时,会进行依赖预构建,即将第三方依赖进行打包,并在开发环境下使用这些打包过的第三方依赖。

那这个过程中,Vite 到底做了哪些事情呢?这就是本篇文章要讲述的内容

本文为了降低理解难度,把核心内容讲清楚,会把一些非必要的流程省略,例如缓存、用户配置对预构建过程的影响等等,都会被忽略。对这方面感兴趣的同学,可以看完文章后,自行查看 Vite 源码

预构建的发生了什么

我们直接拿一个项目来运行一下,这里我们直接使用 Vite 仓库源码的 Vue example

我们运行 vite 命令前设置 DEBUG 环境变量,这样可以打印出依赖预构建相关的构建信息:

# window 系统临时设置环境变量方式如下
set DEBUG=vite:deps && vite

运行效果如图:

image-20220903112310421

从 DEBUG 信息中可以看出:

  • 扫描到了 3 个入口 html 文件
  • 扫描到两个需要进行预构建的依赖:vuelodash,依赖扫描耗时 91.69 ms
  • 依赖打包耗时 83.92 ms

每一条 DEBUG 信息最后会有一个时间,为前后两条 DEBUG 信息相差的时间,一些行没有时间,则证明该 DEBUG 信息是多行的。不过这个时间在我们这里暂时没有太大的作用

然后访问页面,我们会看到 html 文件的 script 已经被修改:

- import { createApp, defineCustomElement } from 'vue'
+ import { createApp, defineCustomElement } from '/node_modules/.vite/deps/vue.js?v=b92a21b7'

由于 import vue 这种模块引入方式,使用的是 Nodejs 特有的模块查找算法(到 node_modules 中取查找),浏览器无法使用,因此 Vite 会将 vue 替换成一个另一个路径,当浏览器解析到这行 import 语句时,会发送一个 /node_modules/.vite/deps/vue.js?v=b92a21b7, Vite Server 会到该目录下,拿到 vue 预构建之后的产物代码。

image-20220903114644443

可以看到 node_module 下会多了一个 .vite 文件,依赖预构建的产物会放在 deps 目录下

这里阶段性的总结一下,依赖预构建做了什么:

  • 扫描入口文件
  • 扫描所有用到的依赖
  • 将多个依赖进行打包
  • 修改这些模块的引入路径

为什么要预构建

Vite 在官方文档中,给出了以下的理由:

  1. CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
  2. 性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求了!

// 在 Chrome console 运行以下代码,体验一次拉取 600+ 个请求
import('https://unpkg.com/lodash-es/lodash.js')

image-20220904152111043

600+ 的请求,单单拉取一个 lodash-es 就耗时 1200ms 了,体验极差!

依赖扫描

一个项目中,存在非常多的模块,并不是所有模块都会被预构建。只有 bare import(裸依赖)会执行依赖预构建

依赖扫描的目的,就是找出所有的这些第三方依赖,依赖扫描的结果如下:

{
  "lodash-es": "D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js",
  "vue": "D:/tencent/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js"
}

依赖扫描函数 discoverProjectDependencies 会返回一个对象:

  • key:第三方依赖的名字
  • value:模块的入口文件的本地真实路径

如果 import 的第三方依赖同时有 lodashlodash-es/merge.js,扫描结果会是怎样?

{
  "lodash-es": "D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js",
  "lodash-es/merge.js": "D:/tencent/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/merge.js",
  "vue": "D:/tencent/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js"
}

扫描结果会多了 lodash-es/merge.js 的内容,Vite 会为单独构建出一个不同的产物文件

入口扫描

如果用户没有指定入口文件,Vite 会扫描项目目录下的所有 HTML 文件**/*.html、node_modules 除外)

扫描结果如下:

[
  "D:/tencent/app/vite/playground/vue/index.html",
  "D:/tencent/app/vite/playground/vue/setup-import-template/template.html",
  "D:/tencent/app/vite/playground/vue/src-import/template.html"
]

依赖扫描的核心思路

先看一下项目中模块的依赖关系:

image-20220904103117072

从入口的 HTML 文件开始,根据模块的 import 依赖关系,可以连接成一颗模块依赖树。

要扫描出所有的 bare import,就需要遍历整个依赖树,这就涉及到了树的深度遍历

我们只需要深度遍历所有树节点,找出所有 import 语句,把 import 的模块记录下来即可

思路虽然很简单,但真正实现起来,会有几个比较困难的问题

JS 文件中,如何找到 import 语句?

这个可以用正则表达式匹配,也可以先将代码解析成 AST 抽象语法树,然后找到 Import 节点

后者更准确。

找到 import 语句后:

  • 如果 import 的模块是第三方依赖,则记录下来。如: vue

  • 如果开发者自己写的项目模块,则继续递归处理该模块。如:Main.vue,这时候应该继续处理 Main.vue

下面是一个例子:

import { createApp, defineCustomElement } from 'vue'
import Main from './Main.vue'
import CustomElement from './CustomElement.ce.vue'
  • vue 会被记录
  • ./Main.vue./CustomElement.ce.vue 将会被继续深入地处理

HTML 文件如何处理?

因为 HTML 文件内,可能存在 script 标签,这部分的代码,就可能包含 import 语句。且项目本身就是把 HTML 文件当成入口的。因此必须得处理 HTML。

由于不关心 HTML 中其他的部分,我们只需要先script 标签的内容提取出来,然后再按 JS 的处理方式处理即可

Vue 文件,也是类似的处理方式。

image-20220904111413146

CSS、PNG 等非 JS 模块如何处理?

这些文件不需要任何处理,直接跳过即可,因为这些文件不可能再引入 JS 模块

以上这几个难题,如果全部都要自己实现,是相当困难的,因此 Vite 巧妙的借助了打包工具进行处理,可以使用打包工具处理的原因如下:

  1. 如何找到 import 语句

    打包工具本身就会从入口文件开始,找到所有的模块依赖,然后进行处理。模块分析/打包流程与我们深度遍历模块树的过程完全相同

    打包工具能对每个模块进行处理,因此我们有机会在模块处理过程中,将第三方依赖记录下来

    例如:当打包工具解析到,现在正在引入的是 vue 模块,那这时候,我们就把它记录下来。

  2. HTML 文件的处理

​ 打包工具能对每个模块进行处理,在模块加载时,可以把模块处理成生成新的内容。Vue 文件的 template,就是在模块加载时,转换成 JS 的 render 函数。

​ 不过这里我们就不是生成 render 函数了,而是把 HTML、Vue 等文件,直接加载成 JS,即只保留它们 script 的部分,其他部分丢弃(依赖扫描不关心非 JS 的内容)

  1. CSS、PNG 等非 JS 模块的处理

​ 打包工具支持将模块标记为 external,就是不打包该模块了。标记之后,打包工具就不会深入分析该模块内部的依赖。

对于 CSS、PNG 这种不需要深入分析的模块,直接 external 即可

如何利用打包工具进行依赖扫描,这个我在《五千字深度解读 Vite 的依赖扫描》有深入的解析,该文章为了减少复杂度,专注于核心内容,不再深入,高阶一点的同学,可以再进行深入的了解。

打包依赖

依赖扫描已经拿到了所有需要预构建的依赖信息,那接下来直接使用 esbuild 进行打包即可。

最终会有如下的调用:

import { build } from 'esbuild'

const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: [ 'vue', 'lodash-es' ],
    bundle: true,
    format: 'esm',
    target: [
        "es2020",
        "edge88",
        "firefox78",
        "chrome87",
        "safari13"
    ],
    splitting: true,  // 该参数会自动进行代码分割
    plugins: [ /* some plugin */ ],
    // 省略其他配置
})

打包的产物如下:

  • vue.js
  • lodash-es.js

打开 lodash-es.js 文件,可以看到,所有的代码都被打包到一个文件中了

image-20220904120455337

如果打包的依赖间,存在依赖的关系/有公共的依赖,这要如何处理?

例如:

  • lodash-eslodash-es/mergelodash-es 中包含 lodash-es/merge 的代码
  • vueant-design-vueant-design-vue 中使用到了 vue 中的 API,依赖 vue

公共依赖的问题,esbuild 会自动处理

当设置了 splitting 为 true 时,在多个 entry 入口之间共享的代码,会被分成单独共享文件(chunk 文件)

因此 vueant-design-vue 的打包结果会是这样:

image-20220904130647281

打包产物 vue.js 部分代码如下:

// 从 vue 公共代码引入
import {
  reactive,
  readonly,
  ref,
 // 省略其他
} from "./chunk-KVOLGOJY.js";
export {
  reactive,
  readonly,
  ref,
 // 省略其他
};
//# sourceMappingURL=vue.js.map

打包产物 ant-design-vue.js 部分代码如下:

// 从 lodash-es 公共代码引入
import {
  cloneDeep_default,
  debounce_default,
  // 省略其他
} from "./chunk-QUQLN3RK.js";

// 从 vue 公共代码引入
import {
  provide,
  reactive,
  ref,
  // 省略其他
} from "./chunk-KVOLGOJY.js";

vuelodash-es 由于被 ant-design-vue 依赖,它们作为公共代码,被拆分到两个 chunk 文件中,而打包产物 vue.jslodash-es.js 只需要 import chunk 然后再重新导出即可

依赖路径替换

依赖打包完之后,最后就是路径替换了。

- import { createApp, defineCustomElement } from 'vue'
+ import { createApp, defineCustomElement } from '/node_modules/.vite/deps/vue.js?v=b92a21b7'

由于 import vue 这种模块引入方式,使用的是 Nodejs 特有的模块查找算法(到 node_modules 中取查找),浏览器无法使用,因此 Vite 会将 vue 替换成 /node_modules/.vite/deps/vue.js?v=b92a21b7,当浏览器解析到这行 import 语句时,会发送一个 /node_modules/.vite/deps/vue.js?v=b92a21b7 的请求。

所有请求都会在 Vite dev server 的中间件处理,而这个请求,会被 static 中间件处理:用于访问静态文件,到会到该目录下,查找文件并返回。

模块的路径是在什么时候被替换的呢?

我们知道,浏览器处理 import 时,会发送一个请求到 Vite Dev Server,然后在中间件处理后,返回模块的内容。

预构建依赖的路径,正是transform 中间件处理过程中被替换的。关于 transform 中间件的内容,我在《Vite Server 是如何处理页面资源的?》有详细的叙述。这里再总结一下:

  • 所有的类 JS 模块(包括 Vue),CSS 模块,都会在 transfrom 中间件中进行处理
  • 每个模块都会经过 resolveIdloadtransform 三个流程,这三个流程,可以通过 Vite 插件去扩展,可以在这三个过程中做一些特殊处理
    • 模块 transform 流程的作用:对代码进行转换,模块路径的替换,正是在这里被修改。

这里稍微写一下路径替换的插件伪代码:

import { parse } from 'es-module-lexer'

// 实现一个 Vite 插件,在 transform 钩子中替换
export default function myPlugin() {
  return {
    // 实现 transform 钩子,code 为当前模块的代码,需要 return 修改过后的代码
    transform(code) {
        // 用 es-module-lexer 解析出模块使用的 import 和 export,里面的信息包含 import 语句所在的行数,模块名所在的列数等信息
        // 这些信息可以用来做字符串替换
		let [imports, exports] = parseImports(source)
        // 根据 import 信息,执行路径替换
        let resCode = /* 路径替换过后的代码 */
       	return resCode
    }
  }
}

实际上这部分得逻辑,是写在 importAnalysis 插件的,但该插件过于复杂,包含了非常多的功能,因此不会展开叙述,感兴趣的同学也可以自己去查看

总结

本文介绍了 Vite 依赖预构建是什么、为什么要进行预构建,以及预构建的全流程:

  • 扫描入口文件,然后通过这些入口,扫描所有用到的依赖
  • 将多个依赖进行打包
  • 修改这些模块的引入路径

为了降低复杂度,本文去掉了部分复杂的细节,这样更便于理解。中阶的同学,其实理解到这里,已经是可以的了,如果想追求高阶的同学,可以往以下两个方向去学习:

  • 挖掘更深层次的细节,这部分的内容,有些可以参考后面的关联阅读
  • 于其他同类工具,进行横向对比

关联阅读

如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)