Vite 是如何记录项目中所有模块的依赖关系的?

Vite 在运行过程中,会记录每个模块间的依赖关系,所有的依赖关系,最终会汇总成一个模块依赖图。利用这个模块依赖图,Vite 能够准确地进行热更新。

本篇文章,将会深度探讨 Vite 是如何对记录这些依赖关系的,以及 Vite 会如何在热更新中使用这些依赖关系。

概念约定

文件 file —— 项目中的单个文件,例如:js、ts、vue、css 等

模块 —— 不仅仅是指 JS 模块,在打包工具中,任何文件都能作为模块,例如 CSS。一个文件可能对应多个模块,例如 一个 Vue 文件实际上会编译成多个模块(Vue 可以分成 template、script、style 三部分)

模块 url —— 页面请求模块的原始 url

image-20230102230139810

模块 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 仓库,也可以直接在线运行

其文件的依赖如下:

image-20230101110456997

这个项目很简单,文件非常的少,其 ModuleNode 的关系如下:

image-20230101105924180

上图每个节点都是 ModuleNode,他们是通过 importedModules 属性连接到一起的,描述的是从顶层模块,一直往下的模块引用关系

而实际上,模块依赖图,不仅仅能从上往下查找引用的模块,还能从下往上回溯,找到当前模块被谁引用了(热更新可以从下往上找到受影响的模块并对它们执行热更新)。因为 ModuleNode 同时记录了 importerimportedModules,即记录了引用了被引用的双向关系

Vue 被依赖预构建,这样有什么好处?

Vite 默认会将所有的第三方依赖执行一遍预构建,官方文档提到的好处是:

  1. 兼容 CommonJS 和 UMD
  2. 性能

对于 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.jsApp.vue?type=styelHelloWorld.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 的属性/方法主要分为这么几类:

  1. 存储 ModuleNode:urlToModuleMapidToModuleMapfileToModulesMap
  2. 查找 ModuleNode:getModuleByUrlgetModuleByIdgetModulesByFile
  3. 创建 ModuleNode:ensureEntryFromUrlcreateFileOnlyEntry
  4. 更新 ModuleNode:updateModuleInfo
  5. 失效 ModuleNode:invalidateModuleinvalidateAll

从命名可以非常清晰的看出,每个属性、方法的作用。

个人为 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 函数的对象

image-20230103202433315

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,界面仍然会热更新

image-20230103204143744

为什么有时候修改代码可以热更新,有时候却是刷新页面?例如在 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(引入被修改模块的模块)能够自己进行热更新
          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
}

主要逻辑如下:

  1. 如果模块自身能够热更新,那就可以直接返回 false 了,即能找到热更新边界,不需要刷新页面
  2. 如果模块已经是顶层模块,没办法再往上查找,就返回 true,刷新页面
  3. 遍历所有 importer,需要所有 importer 都能找到热更新边界,才能进行热更新,否则刷新页面

从源码中,可以看出,模块通过 ModuleNode.importer 往上查找模块的。当往上能够找到热更新边界时,才能进行热更新,否则刷新页面。

总结

ModuleGraph 这个概念,其实不仅仅出现在 Vite,Webpack 和 Rollup 同样也有类似的概念,它们存储模块依赖图的数据结果是不同的,但目的也是用于记录模块间的依赖关系

在 Vite 中,ModuleGraph 只存在于 dev 模式,因为 Vite build 模式下,实际上是使用了 Rollup 进行构建,因此 Vite 无需再记录 ModuleGraph。

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

关联阅读

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容