Appearance
第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 的心脏,那么模块图就是它的神经系统——感知变化、传递信号、协调响应。它在运行时回答了以下几个关键问题:
- 一个模块被谁导入了? 当模块 A 发生变更时,需要沿着反向依赖链找到所有直接和间接依赖 A 的模块,以便决定热更新的范围和策略。
- 一个模块导入了谁? 当模块的代码被重新转换后,需要比较新旧导入列表,清理不再被引用的依赖模块的缓存,同时为新增的依赖建立关联。
- 模块的转换结果是否仍然有效? 通过精细的失效标记(软失效和硬失效)来决定是直接复用缓存、仅替换时间戳、还是完整重新转换。
- 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.vue;id 是插件解析后的完整标识,如 /Users/me/project/src/App.vue?vue&type=style;file 是真实的磁盘路径,如 /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() 接受自身更新。acceptedHmrExports 与 importedBindings 配合使用,实现了部分接受(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' | undefinedtransformResult 存储模块的转换结果缓存。当浏览器请求一个已缓存的模块时,可以直接返回而无需重新转换。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
}