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
模块依赖图:不是指图片,而是指计算机数据结构中的图。模块依赖图,则是描述模块间的依赖关系的图数据结构。
ModuleNode
数据结构中的图,由点和边构成。
在 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 默认会将所有的第三方依赖执行一遍预构建,官方文档提到的好处是:
- 兼容 CommonJS 和 UMD
- 性能
对于 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 文件是如何被转换并渲染到页面的?》
为什么是依赖图,而不是依赖树?
当前例子的确是一个依赖树,但有可能存在循环依赖,树是无法表示循环依赖的,因此只能用模块依赖图表示。
但我们写代码的时候,尽量不要将模块写成循环依赖,因为循环依赖会把依赖链搞得非常的乱。
当没有循环依赖时,就是一棵依赖树了,自上而下的引用链路会更加清晰明了。
ModuleGraph
从数据结构的定义上,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 的属性/方法主要分为这么几类:
- 存储 ModuleNode:
urlToModuleMap
、idToModuleMap
、fileToModulesMap
- 查找 ModuleNode:
getModuleByUrl
、getModuleById
、getModulesByFile
- 创建 ModuleNode:
ensureEntryFromUrl
、createFileOnlyEntry
- 更新 ModuleNode:
updateModuleInfo
- 失效 ModuleNode:
invalidateModule
、invalidateAll
从命名可以非常清晰的看出,每个属性、方法的作用。
个人为 ModuleGraph 对象,更贴切的应该叫 ModuleGraphOperation,因为它是一个提供对模块依赖图的操作能力的对象
不过 Vite 既然是这么写的,我们后面文章也使用 ModuleGraph,大家记得 ModuleGraph 是操作图的对象即可。
热更新
热更新的英文全称为Hot Module Replacement
,简写为 HMR。Vite 提供了一套原生 ESM 的 HMR API
我在《Vite 热更新的主要流程》文章中,详细介绍过 Vite 热更新的主要流程,感兴趣的同学可以先看看文章。
这里再稍微进行提一下几个知识点。
HMR API
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 文件,能找到热更新边界,就可以直接进行热更新
ModuleGraph 的作用
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 不能进行热更新,需要往上查找 // 将 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 }
主要逻辑如下:
- 如果模块自身能够热更新,那就可以直接返回 false 了,即能找到热更新边界,不需要刷新页面
- 如果模块已经是顶层模块,没办法再往上查找,就返回 true,刷新页面
- 遍历所有 importer,需要所有 importer 都能找到热更新边界,才能进行热更新,否则刷新页面
从源码中,可以看出,模块通过 ModuleNode.importer
往上查找模块的。当往上能够找到热更新边界时,才能进行热更新,否则刷新页面。
总结
ModuleGraph 这个概念,其实不仅仅出现在 Vite,Webpack 和 Rollup 同样也有类似的概念,它们存储模块依赖图的数据结果是不同的,但目的也是用于记录模块间的依赖关系。
在 Vite 中,ModuleGraph 只存在于 dev 模式,因为 Vite build 模式下,实际上是使用了 Rollup 进行构建,因此 Vite 无需再记录 ModuleGraph。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)