Vite 在运行过程中,会记录每个模块间的依赖关系,所有的依赖关系,最终会汇总成一个模块依赖图。利用这个模块依赖图,Vite 能够准确地进行热更新。
本篇文章,将会深度探讨 Vite 是如何对记录这些依赖关系的,以及 Vite 会如何在热更新中使用这些依赖关系。
文件 file —— 项目中的单个文件,例如:js、ts、vue、css 等
模块 —— 不仅仅是指 JS 模块,在打包工具中,任何文件都能作为模块,例如 CSS。一个文件可能对应多个模块,例如 一个 Vue 文件实际上会编译成多个模块(Vue 可以分成 template、script、style 三部分)
模块 url —— 页面请求模块的原始 url。
模块 id —— 模块的唯一标识。id 是通过 url 生成的,url 与 id 一一对应,url 在经过 Vite Plugin 处理后会成为 id。如果使用的 Vite 配置改变了,url 生成的 id 可能也会被改变。默认情况下,模块 id 就是【文件系统路径 + 请求的query】,例如模块 url 为:/node_modules/.vite/deps/vue.js?v=173f528e
,模块 id 为 /项目目录/node_modules/.vite/deps/vue.js?v=173f528e
模块依赖图:不是指图片,而是指计算机数据结构中的图。模块依赖图,则是描述模块间的依赖关系的图数据结构。
数据结构中的图,由点和边构成。
在 Vite 模块依赖图中,用 ModuleNode 来记录点关系和变关系:
// 有节选
export class ModuleNode {
url: string // 请求的 url
id: string | null = null // 模块 id,由【文件系统路径 + 请求的query】构成
file: string | null = null // 文件名
type: 'js' | 'css'
importers = new Set<ModuleNode>() // 引入当前模块的模块,即当前模块,被哪些模块 import
importedModules = new Set<ModuleNode>() // 已经引入的模块,即当前模块 import 的模块
acceptedHmrDeps = new Set<ModuleNode>() // 热更新相关
isSelfAccepting?: boolean // 该模块自身是否能够进行热更新
transformResult: TransformResult | null = null // 模块编译后的代码,会被存储到这里
lastHMRTimestamp = 0 // 热更新相关
lastInvalidationTimestamp = 0 // 热更新相关
}
ModuleNode 代表图的一个点(模块),里面有各种的属性,例如当前模块的文件名、代码编译结果等。
ModuleNode 的 importers 和 importedModules 记录了边的关系,即当前模块与其他模块的关系 —— 引用 or 被引用
上面的数据结构很抽象,不好理解,接下来我们就用一个简单的例子来辅助说明一下
下面是用 npm create vite
命令创建的一个 Vue Demo,代码我保存到了这个 Github 仓库,也可以直接在线运行
其文件的依赖如下:
这个项目很简单,文件非常的少,其 ModuleNode 的关系如下:
上图每个节点都是 ModuleNode,他们是通过 importedModules
属性连接到一起的,描述的是从顶层模块,一直往下的模块引用关系。
而实际上,模块依赖图,不仅仅能从上往下查找引用的模块,还能从下往上回溯,找到当前模块被谁引用了(热更新可以从下往上找到受影响的模块并对它们执行热更新)。因为 ModuleNode 同时记录了 importer
和 importedModules
,即记录了引用了被引用的双向关系
Vue 被依赖预构建,这样有什么好处?
Vite 默认会将所有的第三方依赖执行一遍预构建,官方文档提到的好处是:
对于 ModuleNode 来说,这里也是能够提升性能,试想如果没有预构建,一个 Vue 内部会有非常多的 import,就会产生非常多的 ModuleNode,另外,ModuleNode 的代码,是需要每个模块一个个地编译,这样就会有非常大的性能开销。
而预构建之后,只需要编译一次,将所有代码合成一个文件,则只会有一个 ModuleNode,省去了大量开销。
为什么 Vue 模块会有两个 ModuleNode?
在 Vite 中,Vue 文件,实际上会被编译成 JS 和 Style 两个模块,例如:
App.vue
是 JS 代码,Template(被编译成渲染函数) 和 Script 的代码会在该模块中App.vue?type=style
,是 Style 代码,Vue 文件的 style 标签的代码,会在这个模块中因此可以看到一个 Vue 模块会有两个 ModuleNode
以下是 App.vue 编译后的代码(有节选):
// 删除了修改了一些代码,更能关注核心内容,这样更好理解
// 引用的是依赖预构建后的 Vue 代码
import {defineComponent as _defineComponent} from "/node_modules/.vite/deps/vue.js?v=59dd26a1";
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";
import HelloWorld from "/src/components/HelloWorld.vue";
// 定义组件,这里其实是 script 部分
const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: "App",
setup(__props, {expose}) {
expose();
const __returned__ = {HelloWorld};
Object.defineProperty(__returned__, "__isScriptSetup", {enumerable: false, value: true});
return __returned__;
}
});
// 渲染函数,这部分是由 template 模块编译而成
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(_Fragment, null, [
_hoisted_1,
_createVNode($setup["HelloWorld"], {msg: "Vite + Vue"})
], 64);
}
// 将 render 函数设置到组件上
_sfc_main.render = _sfc_render
export default _sfc_main
上面看个大概就行,主要看那看模块的 import 关系,因此 App.vue 的 ModuleNode,实际上是引入了 vue.js
、App.vue?type=styel
、 HelloWorld.vue
这几个模块。
如果对 Vue 的转换感兴趣,可以查看这篇文章《Vue 文件是如何被转换并渲染到页面的?》
为什么是依赖图,而不是依赖树?
当前例子的确是一个依赖树,但有可能存在循环依赖,树是无法表示循环依赖的,因此只能用模块依赖图表示。
但我们写代码的时候,尽量不要将模块写成循环依赖,因为循环依赖会把依赖链搞得非常的乱。
当没有循环依赖时,就是一棵依赖树了,自上而下的引用链路会更加清晰明了。
从数据结构的定义上,ModuleNode 其实就已经可以构成模块依赖图了。
不过 Vite 在这基础上,定义了 ModuleGraph 对象,它的作用是:更方便的对图节点(ModuleNode)进行操作,它提供了查找、创建、更新、失效 ModuleNode 等能力
export class ModuleGraph {
urlToModuleMap = new Map<string, ModuleNode>()
idToModuleMap = new Map<string, ModuleNode>()
// 一个文件,可能对应多个 ModuleNode,例如 Vue 文件
fileToModulesMap = new Map<string, Set<ModuleNode>>()
// 通过 url 获取 ModuleNode
async getModuleByUrl(
rawUrl: string,
ssr?: boolean,
): Promise<ModuleNode | undefined> {
const [url] = await this.resolveUrl(rawUrl, ssr)
return this.urlToModuleMap.get(url)
}
// 通过 id 获取 ModuleNode
getModuleById(id: string): ModuleNode | undefined {
return this.idToModuleMap.get(removeTimestampQuery(id))
}
// 通过 file 获取 ModuleNode
getModulesByFile(file: string): Set<ModuleNode> | undefined {
return this.fileToModulesMap.get(file)
}
// 将 ModuleNode 设置为失效的,用于热更新时,将之前编译好的模块代码失效
invalidateModule(
mod: ModuleNode,
seen: Set<ModuleNode> = new Set(),
timestamp: number = Date.now(),
): void;
// 将所有 ModuleNode 设置为失效
invalidateAll(): void;
// 更新 ModuleNode 的依赖信息
// 函数返回值为不再 import 的依赖的 Set 集合。
// 即如果模块更新后,以前 import 的依赖,现在不再 import 了,则出现在会在返回值的 Set 集合对象中
async updateModuleInfo(
mod: ModuleNode,
importedModules: Set<string | ModuleNode>,
importedBindings: Map<string, Set<string>> | null,
acceptedModules: Set<string | ModuleNode>,
acceptedExports: Set<string> | null,
isSelfAccepting: boolean,
ssr?: boolean,
): Promise<Set<ModuleNode> | undefined>;
// 确保该 url 创建过 ModuleNode,如果没有创建,则新创建 ModuleNode
// 返回 ModuleNode
async ensureEntryFromUrl(
rawUrl: string,
ssr?: boolean,
setIsSelfAccepting = true,
): Promise<ModuleNode>;
// CSS 文件使用 @import 引入 style 文件时,这个 style 文件是直接内联到当前的 CSS 文件中的
// 由于内联到当前 CSS,因此浏览器只会请求一次当前 CSS 的模块
// 因此这些 @import 文件的 ModuleNode,没有 url,只有 file 属性
createFileOnlyEntry(file: string): ModuleNode;
}
ModuleGraph 的属性/方法主要分为这么几类:
urlToModuleMap
、idToModuleMap
、fileToModulesMap
getModuleByUrl
、getModuleById
、getModulesByFile
ensureEntryFromUrl
、createFileOnlyEntry
updateModuleInfo
invalidateModule
、invalidateAll
从命名可以非常清晰的看出,每个属性、方法的作用。
个人为 ModuleGraph 对象,更贴切的应该叫 ModuleGraphOperation,因为它是一个提供对模块依赖图的操作能力的对象
不过 Vite 既然是这么写的,我们后面文章也使用 ModuleGraph,大家记得 ModuleGraph 是操作图的对象即可。
热更新的英文全称为Hot Module Replacement
,简写为 HMR。Vite 提供了一套原生 ESM 的 HMR API
我在《Vite 热更新的主要流程》文章中,详细介绍过 Vite 热更新的主要流程,感兴趣的同学可以先看看文章。
这里再稍微进行提一下几个知识点。
HMR API 的作用是,告诉 Vite 如何进行热更新
没有使用 HMR API 的代码被修改时,由于没有告诉 Vite 如何进行热更新,Vite 只能刷新页面进行更新。需要在代码中调用 HMR API,代码才能有热更新的能力。
下面是一个例子,在线运行地址
export const render = () => {
const el = document.querySelector<HTMLDivElement>('#app')!;
el.innerHTML = `
<h1>Project: ts-file-test</h1>
<h2>File: accept.ts</h2>
<p>accept test</p>
`;
};
render();
// 如果没有下面这一段,修改代码后,整个页面会刷新
if (import.meta.hot) {
// 调用的时候,调用的是老的模块的 accept 回调
import.meta.hot.accept((mod) => {
if (mod) {
// 老的模块的 accept 回调拿到的是新的模块
console.log('mod', mod);
console.log('mod.render', mod.render);
mod.render();
}
});
}
上述代码调用了 import.meta.hot.accept
,即告诉 Vite,如果当前文件被修改了,就会调用 import.meta.hot.accept
的回调函数,即重新执行 render
函数,这样就能直接将新的内容渲染出来,不会整个刷新整个页面了。
当我们将修改该文件时(将 <p>accept test</p>
改成 <p>accept test2</p>
),之前老的模块注册的 accept 的回调就会被执行
mod 就是修改后的模块对象,在该文件中,mod 就是一个导出了 render 函数的对象
Vue 等框架,会在编译时往代码中插入热更新逻辑,因此我们即使没有写任何热更新代码,项目也能进行热更新。
不是所有模块,都有热更新逻辑,但 Vite 会一致沿着依赖链往上查找,找出最近的能够进行热更新的模块,然后执行热更新。
稍微修改一下上述例子
import { test } from './sub-module';
export const render = () => {
const el = document.querySelector<HTMLDivElement>('#app')!;
el.innerHTML = `
<h1>Project: ts-file-test</h1>
<h2>File: accept.ts</h2>
<p>accept test2</p>
<p>${test}</p>
`;
};
render();
// 如果没有下面这一段,修改代码后,整个页面会刷新
if (import.meta.hot) {
// 调用的时候,调用的是老的模块的 accept 回调
import.meta.hot.accept((mod) => {
if (mod) {
// 老的模块的 accept 回调拿到的是新的模块
console.log('mod', mod);
console.log('mod.render', mod.render);
mod.render();
}
});
}
sub-module.ts
的代码如下:
export const test = 1234;
我们修改 test = 123,界面仍然会热更新
为什么有时候修改代码可以热更新,有时候却是刷新页面?例如在 vue 项目中修改 main.ts
修改 main.ts
时,因为往上找不到可以热更新的模块了,vite 不知道如何进行热更新,因此只能刷新页面
如果其他 ts 文件,能找到热更新边界,就可以直接进行热更新
Vite 沿着依赖链往上查找最近的能够进行热更新的模块,这个过程需要用到 ModuleGraph。
我们直接来看看查找热更新边界的代码:
let needFullReload = false
// modules 为被修改的文件 file 的 ModuleNode,取值为 moduleGraph.getModulesByFile(file)
// 因为一个 file 可能对应多个 ModuleNode,因此需要循环遍历
for (const mod of modules) {
invalidate(mod, timestamp, invalidatedModules)
if (needFullReload) {
continue
}
// 这个 Set 集合,用来存储热更新边界
const boundaries = new Set()
// 计算热更新边界,然后存储到 boundaries Set 中
// mod 为当前修改的模块的 ModuleNode
const hasDeadEnd = propagateUpdate(mod, boundaries)
// 如果有 DeadEnd,例如,找不到热更新边界,就得整个刷新页面
if (hasDeadEnd) {
needFullReload = true
}
// 通过 websocket 通知 Vite 热更新 client,将页面重新刷新
if (needFullReload) {
ws.send({
type: 'full-reload',
})
return
}
}
如果 propagateUpdate
返回 true,即 hasDeadEnd
,就会刷新整个页面。
hasDeadEnd
为 true 的场景有:找不到热更新边界、存在循环依赖等
propagateUpdate
的代码如下:
function propagateUpdate(
node: ModuleNode,
boundaries: Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>, // 热更新边界,执行该函数会往里面插入 ModuleNode
currentChain: ModuleNode[] = [node],
): boolean {
// 当前模块,自身就有热更新逻辑,那就可以不用往上查找热更新边界了,直接 return false
if (node.isSelfAccepting) {
// 记录热更新边界,为当前模块
boundaries.add({
boundary: node,
acceptedVia: node,
})
return false
}
// 没有 importers, 证明当前模块已经是最顶层的模块,没办法再往上查找了
// return true,则表示是 DeadEnd,没办法进行热更新,需要刷新页面
if (!node.importers.size) {
return true
}
// 判断所有的 importer(引入被修改模块的模块)
// 看看是不是都能进行热更新,如果有其中一个不能,就得刷新页面
for (const importer of node.importers) {
// importer(引入被修改模块的模块)能够自己进行热更新
if (node.isSelfAccepting) {
// 热更新边界就是 importer
boundaries.add({
boundary: node,
acceptedVia: node,
})
// return false 表示不需要刷新页面了
return false
}
// importer 不能进行热更新,需要往上查找
// 将 importer 模块,加入到 subChain 数组
// 表示已经检查过,但是它不能进行热更新,用于判断是否为循环依赖。
const subChain = currentChain.concat(importer)
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
})
continue
}
// 如果有循环依赖,就没办法热更新了,只能重新刷新页面了
if (currentChain.includes(importer)) {
return true
}
// 递归往上传播,补全热更新边界
// propagateUpdate 为 true,则表示是 DeadEnd,没办法进行热更新,需要刷新页面
if (propagateUpdate(importer, boundaries, subChain)) {
return true
}
}
// 如果所有的 importer 都能找到热更新边界,那就不需要刷新页面了
return false
}
主要逻辑如下:
从源码中,可以看出,模块通过 ModuleNode.importer
往上查找模块的。当往上能够找到热更新边界时,才能进行热更新,否则刷新页面。
ModuleGraph 这个概念,其实不仅仅出现在 Vite,Webpack 和 Rollup 同样也有类似的概念,它们存储模块依赖图的数据结果是不同的,但目的也是用于记录模块间的依赖关系。
在 Vite 中,ModuleGraph 只存在于 dev 模式,因为 Vite build 模式下,实际上是使用了 Rollup 进行构建,因此 Vite 无需再记录 ModuleGraph。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。