Skip to content

第6章 模块图与依赖追踪

"在一个由数千个模块构成的前端应用中,理解模块之间的关系比理解单个模块的实现更为重要。模块图就是这种关系的数据化表达。"

本章要点

  • EnvironmentModuleNode 是模块图的基本单元:每个节点承载 url、id、file、type、transformResult 等关键字段,同时通过 importers 和 importedModules 双向链接形成有向图
  • EnvironmentModuleGraph 通过四张 Map 实现高效查找:urlToModuleMap、idToModuleMap、etagToModuleMap、fileToModulesMap 分别支持按公开路径、解析 ID、ETag、文件路径四种方式定位模块
  • 软失效与硬失效的精妙区分:软失效仅需替换导入时间戳而无需重新转换代码,硬失效则要求完整地重新加载和转换模块,这一设计大幅减少了 HMR 链中不必要的重复编译
  • 双向依赖图的维护是增量式的:updateModuleInfo 方法在每次模块转换完成后增量更新导入关系,清理不再使用的依赖,添加新的依赖
  • ModuleGraph 兼容层桥接新旧 API:mixedModuleGraph.ts 中的 ModuleNode 和 ModuleGraph 通过代理模式将基于环境的新模块图 API 包装为向后兼容的统一接口

6.1 为什么需要模块图

在传统的打包工具(如 Webpack 和 Rollup)中,依赖关系在构建时一次性解析完成,生成一个静态的依赖图。整个项目的所有模块在构建阶段就被扫描和分析,最终产出的是一个不再变化的依赖拓扑结构。但 Vite 的开发服务器采用了截然不同的策略——按需编译模式。只有当浏览器实际请求某个模块时,这个模块才会被解析和转换。这意味着依赖关系是动态增长的,模块图随着用户在浏览器中的导航和交互而不断扩展。更重要的是,每次文件变更都可能改变依赖关系——一个新增的 import 语句会在图中创建新的边,一个删除的 import 语句会使某些依赖变为孤立节点。

模块图(Module Graph)就是 Vite 用来追踪这种动态依赖关系的核心数据结构。如果说开发服务器是 Vite 的心脏,那么模块图就是它的神经系统——感知变化、传递信号、协调响应。它在运行时回答了以下几个关键问题:

  1. 一个模块被谁导入了? 当模块 A 发生变更时,需要沿着反向依赖链找到所有直接和间接依赖 A 的模块,以便决定热更新的范围和策略。
  2. 一个模块导入了谁? 当模块的代码被重新转换后,需要比较新旧导入列表,清理不再被引用的依赖模块的缓存,同时为新增的依赖建立关联。
  3. 模块的转换结果是否仍然有效? 通过精细的失效标记(软失效和硬失效)来决定是直接复用缓存、仅替换时间戳、还是完整重新转换。
  4. HMR 更新应该在哪里停止传播? 通过 acceptedHmrDeps 和 isSelfAccepting 等字段来确定热更新的边界,避免更新波及整个应用。

理解模块图的设计,是理解 Vite 开发时性能优化和热更新机制的基础。接下来我们从模块图的最小单元开始,自底向上地剖析整个数据结构。

6.2 EnvironmentModuleNode:模块图的基本单元

每一个被 Vite 处理过的模块,在内存中都对应着一个 EnvironmentModuleNode 实例。这个类定义在 src/node/server/moduleGraph.ts 文件中,只有不到一百行代码,却承载了模块在开发阶段的全部生命周期信息——从身份标识到依赖关系,从转换缓存到失效状态,从热更新接受声明到时间戳管理。让我们逐一解析它的关键字段,理解每个字段在系统中扮演的角色。

6.2.1 身份标识字段

typescript
export class EnvironmentModuleNode {
  environment: string
  url: string           // 公开的 URL 路径,以 / 开头
  id: string | null     // 解析后的文件系统路径 + 查询参数
  file: string | null   // 清理后的文件系统路径(不含查询参数)
  type: 'js' | 'css' | 'asset'
}

这三层标识的设计是经过深思熟虑的。url 是浏览器看到的路径,如 /src/App.vueid 是插件解析后的完整标识,如 /Users/me/project/src/App.vue?vue&type=stylefile 是真实的磁盘路径,如 /Users/me/project/src/App.vue。同一个文件可以对应多个不同查询参数的模块(例如 Vue 单文件组件的 template、script、style 块分别是不同的模块),因此 fileToModulesMap 是一对多的映射。

environment 字段标识该模块节点属于哪个运行环境(例如 'client''ssr')。这是 Vite 6 引入环境 API 后的设计——同一个文件在不同环境中可能有完全不同的转换结果和依赖关系。

type 字段的判定逻辑非常简洁:

typescript
constructor(url: string, environment: string, setIsSelfAccepting = true) {
  this.environment = environment
  this.url = url
  this.type = isDirectCSSRequest(url) ? 'css' : 'js'
  if (setIsSelfAccepting) {
    this.isSelfAccepting = false
  }
}

注意这里只区分了 'css''js' 两种类型,并没有更细粒度的分类(如 TypeScript、Vue、JSX 等)。这是因为模块类型在这个层面上只需要区分处理策略——CSS 类型的模块可以自接受更新(通过替换样式表实现),JS 类型的模块则需要通过 import.meta.hot API 声明接受能力。'asset' 类型仅由 createFileOnlyEntry 方法手动设置,用于那些不直接通过 URL 请求但需要参与 HMR 的文件(如被 CSS @import 引入的子样式表文件)。

另一个值得注意的细节是 setIsSelfAccepting 参数。默认情况下,新创建的模块节点的 isSelfAccepting 被设置为 false。但在某些场景下(如 Issue #7870 描述的情况),模块的自接受状态需要延迟设置——先创建节点,等转换完成后再根据代码分析的结果确定是否自接受。此时传入 false 可以使 isSelfAccepting 保持 undefined 状态,表示"尚未确定",这在后续的传播算法中有特殊的处理逻辑。

6.2.2 依赖关系字段

typescript
importers: Set<EnvironmentModuleNode> = new Set()
importedModules: Set<EnvironmentModuleNode> = new Set()
acceptedHmrDeps: Set<EnvironmentModuleNode> = new Set()
acceptedHmrExports: Set<string> | null = null
importedBindings: Map<string, Set<string>> | null = null
isSelfAccepting?: boolean
staticImportedUrls?: Set<string>

这些字段构成了一个双向有向图。importers 记录"谁导入了我",importedModules 记录"我导入了谁"。这种双向设计的核心价值在于 HMR 场景——当一个模块变更时,通过 importers 可以向上追溯所有受影响的模块;当需要清理孤立依赖时,通过 importedModules 可以向下检查。

acceptedHmrDeps 记录的是通过 import.meta.hot.accept('./dep.js', callback) 显式声明接受的依赖模块。isSelfAccepting 表示模块通过 import.meta.hot.accept() 接受自身更新。acceptedHmrExportsimportedBindings 配合使用,实现了部分接受(partial accept)的能力——如果一个模块导出了 10 个函数,但改动只影响了其中 2 个,而这 2 个恰好被声明为可接受的,则不需要完整重载。

staticImportedUrls 是一个内部字段,用于区分静态导入和其他类型的导入(如 glob 导入或文件监听依赖)。这一区分在失效传播中至关重要——只有静态导入的模块变更可以触发软失效,其他类型的变更必须触发硬失效。

6.2.3 缓存与失效字段

typescript
transformResult: TransformResult | null = null
lastHMRTimestamp = 0
lastHMRInvalidationReceived = false
lastInvalidationTimestamp = 0
invalidationState: TransformResult | 'HARD_INVALIDATED' | undefined

transformResult 存储模块的转换结果缓存。当浏览器请求一个已缓存的模块时,可以直接返回而无需重新转换。lastHMRTimestamp 用于在 HMR 更新时为模块 URL 附加时间戳查询参数,强制浏览器重新请求。

invalidationState 是失效策略的核心字段,它的三种取值(undefined、旧的 TransformResult'HARD_INVALIDATED')分别对应模块的三种生命状态:有效、软失效、硬失效。我们将在 6.5 节详细讨论这一精妙的设计。

lastHMRInvalidationReceived 标志用于处理多客户端场景下的去重问题。当多个浏览器标签页连接到同一个开发服务器时,每个标签页都可能发送 import.meta.hot.invalidate() 请求。这个标志确保同一模块的同一次失效只被处理一次,避免重复触发更新。

6.3 EnvironmentModuleGraph:四张 Map 的索引体系

EnvironmentModuleGraph 是模块图的容器,通过四张 Map 提供了多维度的模块查找能力:

typescript
export class EnvironmentModuleGraph {
  environment: string
  urlToModuleMap: Map<string, EnvironmentModuleNode> = new Map()
  idToModuleMap: Map<string, EnvironmentModuleNode> = new Map()
  etagToModuleMap: Map<string, EnvironmentModuleNode> = new Map()
  fileToModulesMap: Map<string, Set<EnvironmentModuleNode>> = new Map()
  _unresolvedUrlToModuleMap: Map<
    string,
    EnvironmentModuleNode | Promise<EnvironmentModuleNode>
  > = new Map()
}

每张 Map 服务于不同的查找场景:

  • urlToModuleMap:当浏览器发起模块请求时,通过公开 URL 查找模块。
  • idToModuleMap:当文件系统路径已知时(如文件变更通知),通过解析后的 ID 查找。
  • etagToModuleMap:仅客户端环境使用,支持 HTTP 304 条件请求机制。
  • fileToModulesMap:当磁盘上某个文件发生变更时,查找该文件关联的所有模块(一个文件可能对应多个模块)。

还有一张内部的 _unresolvedUrlToModuleMap,它缓存了从原始 URL(可能没有扩展名、可能包含时间戳)到模块节点的映射。这个缓存的巧妙之处在于,它可以暂存一个 Promise<EnvironmentModuleNode>,即当多个请求并发解析同一个 URL 时,第二个请求可以直接等待第一个请求的解析 Promise,避免重复解析。

6.3.1 模块的创建流程

ensureEntryFromUrl 是创建或获取模块节点的核心方法:

typescript
async _ensureEntryFromUrl(
  rawUrl: string,
  setIsSelfAccepting = true,
  resolved?: PartialResolvedId,
): Promise<EnvironmentModuleNode> {
  rawUrl = removeImportQuery(removeTimestampQuery(rawUrl))
  let mod = this._getUnresolvedUrlToModule(rawUrl)
  if (mod) {
    return mod
  }
  const modPromise = (async () => {
    const [url, resolvedId, meta] = await this._resolveUrl(rawUrl, resolved)
    mod = this.idToModuleMap.get(resolvedId)
    if (!mod) {
      mod = new EnvironmentModuleNode(url, this.environment, setIsSelfAccepting)
      if (meta) mod.meta = meta
      this.urlToModuleMap.set(url, mod)
      mod.id = resolvedId
      this.idToModuleMap.set(resolvedId, mod)
      const file = (mod.file = cleanUrl(resolvedId))
      let fileMappedModules = this.fileToModulesMap.get(file)
      if (!fileMappedModules) {
        fileMappedModules = new Set()
        this.fileToModulesMap.set(file, fileMappedModules)
      }
      fileMappedModules.add(mod)
    } else if (!this.urlToModuleMap.has(url)) {
      this.urlToModuleMap.set(url, mod)
    }
    this._setUnresolvedUrlToModule(rawUrl, mod)
    return mod
  })()

  this._setUnresolvedUrlToModule(rawUrl, modPromise)
  return modPromise
}

基于 VitePress 构建