当我们使用 Vite 进行开发时,会进行依赖预构建,即将第三方依赖进行打包,并在开发环境下使用这些打包过的第三方依赖。
那这个过程中,Vite 到底做了哪些事情呢?这就是本篇文章要讲述的内容
本文为了降低理解难度,把核心内容讲清楚,会把一些非必要的流程省略,例如缓存、用户配置对预构建过程的影响等等,都会被忽略。对这方面感兴趣的同学,可以看完文章后,自行查看 Vite 源码
预构建的发生了什么
我们直接拿一个项目来运行一下,这里我们直接使用 Vite 仓库源码的 Vue example
我们运行 vite 命令前设置 DEBUG 环境变量,这样可以打印出依赖预构建相关的构建信息:
# window 系统临时设置环境变量方式如下 set DEBUG=vite:deps && vite
运行效果如图:
从 DEBUG 信息中可以看出:
- 扫描到了 3 个入口 html 文件
- 扫描到两个需要进行预构建的依赖:
vue
和lodash
,依赖扫描耗时 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 预构建之后的产物代码。
可以看到 node_module 下会多了一个 .vite
文件,依赖预构建的产物会放在 deps
目录下
这里阶段性的总结一下,依赖预构建做了什么:
- 扫描入口文件
- 扫描所有用到的依赖
- 将多个依赖进行打包
- 修改这些模块的引入路径
为什么要预构建
Vite 在官方文档中,给出了以下的理由:
- CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
- 性能: 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')
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 的第三方依赖同时有
lodash
和lodash-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" ]
依赖扫描的核心思路
先看一下项目中模块的依赖关系:
从入口的 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 文件,也是类似的处理方式。
CSS、PNG 等非 JS 模块如何处理?
这些文件不需要任何处理,直接跳过即可,因为这些文件不可能再引入 JS 模块
以上这几个难题,如果全部都要自己实现,是相当困难的,因此 Vite 巧妙的借助了打包工具进行处理,可以使用打包工具处理的原因如下:
- 如何找到 import 语句
打包工具本身就会从入口文件开始,找到所有的模块依赖,然后进行处理。模块分析/打包流程与我们深度遍历模块树的过程完全相同。
打包工具能对每个模块进行处理,因此我们有机会在模块处理过程中,将第三方依赖记录下来。
例如:当打包工具解析到,现在正在引入的是 vue 模块,那这时候,我们就把它记录下来。 - HTML 文件的处理
打包工具能对每个模块进行处理,在模块加载时,可以把模块处理成生成新的内容。Vue 文件的 template,就是在模块加载时,转换成 JS 的 render 函数。
不过这里我们就不是生成 render 函数了,而是把 HTML、Vue 等文件,直接加载成 JS,即只保留它们 script 的部分,其他部分丢弃(依赖扫描不关心非 JS 的内容)
- 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
文件,可以看到,所有的代码都被打包到一个文件中了
如果打包的依赖间,存在依赖的关系/有公共的依赖,这要如何处理?
例如:
lodash-es
和lodash-es/merge
,lodash-es
中包含lodash-es/merge
的代码vue
和ant-design-vue
,ant-design-vue
中使用到了vue
中的 API,依赖vue
公共依赖的问题,esbuild 会自动处理。
当设置了 splitting
为 true 时,在多个 entry 入口之间共享的代码,会被分成单独共享文件(chunk 文件)
因此 vue
和 ant-design-vue
的打包结果会是这样:
打包产物 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";
vue
和 lodash-es
由于被 ant-design-vue
依赖,它们作为公共代码,被拆分到两个 chunk 文件中,而打包产物 vue.js
和 lodash-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
中间件中进行处理 - 每个模块都会经过
resolveId
、load
、transform
三个流程,这三个流程,可以通过 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 依赖预构建是什么、为什么要进行预构建,以及预构建的全流程:
- 扫描入口文件,然后通过这些入口,扫描所有用到的依赖
- 将多个依赖进行打包
- 修改这些模块的引入路径
为了降低复杂度,本文去掉了部分复杂的细节,这样更便于理解。中阶的同学,其实理解到这里,已经是可以的了,如果想追求高阶的同学,可以往以下两个方向去学习:
- 挖掘更深层次的细节,这部分的内容,有些可以参考后面的关联阅读
- 于其他同类工具,进行横向对比
关联阅读
- 《五千字深度解读 Vite 的依赖扫描》
- 《五千字剖析 vite 是如何对配置文件进行解析的》
- 《Vite 是如何兼容 Rollup 插件生态的》
- 《Vite 热更新的主要流程》
- 《五千字剖析 vite 是如何对配置文件进行解析的》
- 《手把手教你手写一个 Vite Server(一)》
- 《如何调试 vite 源码?》
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)