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
}这段代码体现了多个精巧的设计:
- URL 清洗:首先移除
?import和?t=xxx查询参数,确保缓存命中率。 - 快速路径:优先从
_unresolvedUrlToModuleMap查找,避免昂贵的解析操作。 - 并发安全:在异步解析开始前,立即将 Promise 存入缓存,防止并发重复创建。
- ID 去重:通过
idToModuleMap检查是否已有相同 ID 的模块(多个 URL 可能解析到同一文件)。 - 多重注册:一个模块被同时注册到 urlToModuleMap、idToModuleMap 和 fileToModulesMap 三张 Map 中。
6.3.2 URL 解析策略
_resolveUrl 方法调用插件链的 resolveId 钩子来解析模块路径,并且有一个重要的补充逻辑——如果解析后的 ID 带有文件扩展名,而原始 URL 没有,则自动补上扩展名:
typescript
async _resolveUrl(
url: string,
alreadyResolved?: PartialResolvedId,
): Promise<ResolvedUrl> {
const resolved = alreadyResolved ?? (await this._resolveId(url))
const resolvedId = resolved?.id || url
if (url !== resolvedId && !url.includes('\0') && !url.startsWith(`virtual:`)) {
const ext = extname(cleanUrl(resolvedId))
if (ext) {
const pathname = cleanUrl(url)
if (!pathname.endsWith(ext)) {
url = pathname + ext + url.slice(pathname.length)
}
}
}
return [url, resolvedId, resolved?.meta]
}这一逻辑确保了 /src/utils 和 /src/utils.ts 映射到同一个模块节点,避免了因扩展名缺失导致的重复模块创建。虚拟模块(以 \0 开头或 virtual: 前缀)被排除在外,因为它们没有真实的文件系统路径,补充扩展名对它们没有意义。
这个看似简单的扩展名补充逻辑解决了一个在 ESM 开发中频繁出现的实际问题。在 TypeScript 项目中,开发者通常写 import { foo } from './utils' 而不带扩展名,但磁盘上的文件是 utils.ts。如果不做补充,/src/utils 和 /src/utils.ts 会被当作两个不同的模块创建两个节点,导致依赖关系的割裂——当 utils.ts 文件变更时,通过 idToModuleMap 找到的节点和通过 urlToModuleMap 找到的节点不一致,热更新将无法正确传播。
6.4 双向依赖图的增量维护
模块图的依赖关系不是一次性构建的,而是随着每个模块被请求和转换而逐步填充。updateModuleInfo 方法是这个增量维护过程的核心:
typescript
async updateModuleInfo(
mod: EnvironmentModuleNode,
importedModules: Set<string | EnvironmentModuleNode>,
importedBindings: Map<string, Set<string>> | null,
acceptedModules: Set<string | EnvironmentModuleNode>,
acceptedExports: Set<string> | null,
isSelfAccepting: boolean,
staticImportedUrls?: Set<string>,
): Promise<Set<EnvironmentModuleNode> | undefined> {
mod.isSelfAccepting = isSelfAccepting
const prevImports = mod.importedModules
let noLongerImported: Set<EnvironmentModuleNode> | undefined
// 并行解析所有新的导入模块
let resolvePromises = []
let resolveResults = new Array(importedModules.size)
let index = 0
for (const imported of importedModules) {
const nextIndex = index++
if (typeof imported === 'string') {
resolvePromises.push(
this.ensureEntryFromUrl(imported).then((dep) => {
dep.importers.add(mod)
resolveResults[nextIndex] = dep
}),
)
} else {
imported.importers.add(mod)
resolveResults[nextIndex] = imported
}
}
if (resolvePromises.length) {
await Promise.all(resolvePromises)
}
const nextImports = new Set(resolveResults)
mod.importedModules = nextImports
// 清理不再被导入的依赖
prevImports.forEach((dep) => {
if (!mod.importedModules.has(dep)) {
dep.importers.delete(mod)
if (!dep.importers.size) {
;(noLongerImported || (noLongerImported = new Set())).add(dep)
}
}
})
// ... 更新 acceptedHmrDeps、acceptedHmrExports、importedBindings ...
return noLongerImported
}这个方法在每次模块转换完成后被调用。其核心逻辑可以分为三步:
- 建立新的正向依赖:遍历新解析出的导入列表,为每个依赖创建模块节点(如不存在),并建立双向链接。
- 替换导入列表:用新的导入集合替换旧的
importedModules。 - 清理过时的反向依赖:遍历旧的导入列表,移除不再存在的依赖关系。如果某个依赖因此不再有任何导入者,将其标记为"孤立模块"。
返回的 noLongerImported 集合告诉调用方哪些模块已经不再被任何模块导入,可以触发清理逻辑(如通知客户端移除相应的样式注入)。这个返回值在 HMR 管道中被传递给 handlePrunedModules 函数,最终通过 WebSocket 向客户端发送 prune 消息。
值得特别关注的是解析操作的并行化策略。updateModuleInfo 不会逐个等待导入模块的解析完成,而是将所有解析操作放入 resolvePromises 数组中,使用 Promise.all 一次性并行解析。每个解析结果通过索引对应到 resolveResults 数组的正确位置,保证了即使是并行解析,最终的模块顺序也与原始导入顺序一致。这一优化在含有大量导入的模块中(如一个 barrel 导出文件可能有数十个子模块导入)效果尤为显著。
6.5 失效策略:软失效与硬失效
invalidateModule 方法是模块图中最精密的逻辑之一。它实现了两种不同粒度的失效策略,以最小化 HMR 更新的开销。
6.5.1 两种失效状态
invalidationState 字段有三种可能的值:
undefined:模块有效,无需重新处理。TransformResult(上次的转换结果):软失效状态。模块的代码逻辑没有变化,只需要更新导入语句中的时间戳参数。'HARD_INVALIDATED':硬失效状态。模块需要完整地重新加载和转换。
6.5.2 失效传播逻辑
typescript
invalidateModule(
mod: EnvironmentModuleNode,
seen: Set<EnvironmentModuleNode> = new Set(),
timestamp: number = monotonicDateNow(),
isHmr: boolean = false,
softInvalidate = false,
): void {
const prevInvalidationState = mod.invalidationState
if (softInvalidate) {
mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED'
} else {
mod.invalidationState = 'HARD_INVALIDATED'
}
if (seen.has(mod) && prevInvalidationState === mod.invalidationState) {
return
}
seen.add(mod)
if (isHmr) {
mod.lastHMRTimestamp = timestamp
mod.lastHMRInvalidationReceived = false
} else {
mod.lastInvalidationTimestamp = timestamp
}
const etag = mod.transformResult?.etag
if (etag) this.etagToModuleMap.delete(etag)
mod.transformResult = null
mod.importers.forEach((importer) => {
if (!importer.acceptedHmrDeps.has(mod)) {
const shouldSoftInvalidateImporter =
(importer.staticImportedUrls?.has(mod.url) || softInvalidate) &&
importer.type === 'js'
this.invalidateModule(
importer, seen, timestamp, isHmr, shouldSoftInvalidateImporter,
)
}
})
}这段代码有几个关键的设计决策:
软失效的保守策略:当尝试对一个模块进行软失效时,使用了空值合并运算符 ??=。这意味着如果模块已经被硬失效,后续的软失效不会"降级"为更温和的状态。反之,硬失效可以覆盖软失效——直接将 invalidationState 设置为 'HARD_INVALIDATED'。
传播的终止条件:如果一个导入者(importer)已经通过 acceptedHmrDeps 声明接受了当前模块的更新,则不向该导入者继续传播失效。这就是 HMR 边界的实现——acceptedHmrDeps 中的模块充当了失效传播的防火墙。
软失效的条件:只有满足以下全部条件时,导入者才会被软失效:
- 导入者是通过静态 import 语句导入的当前模块(
importer.staticImportedUrls?.has(mod.url)),或者当前模块本身就是软失效的(softInvalidate)。 - 导入者是 JS 类型模块(CSS 模块不能被软失效,因为 CSS 没有时间戳注入机制)。
6.5.3 软失效的实际效果
为什么软失效如此重要?考虑这样一个场景:
main.ts --> utils.ts --> helper.ts当 helper.ts 发生变更时,如果 utils.ts 静态导入了 helper.ts,那么 utils.ts 只需要更新其导入 helper.ts 的时间戳参数(例如 import './helper.ts?t=1234' 变为 import './helper.ts?t=5678'),而不需要重新执行 utils.ts 的全部转换管道。这在大型项目中可以显著减少 HMR 的延迟。
软失效时,旧的 transformResult 被暂存到 invalidationState 中。当下一次 transformRequest 处理这个模块时,它会从暂存的结果中提取代码,仅替换其中的导入时间戳,然后返回更新后的结果——跳过了完整的 load 和 transform 钩子调用。
6.5.4 失效传播路径示例
下面用一个更具体的示例展示软失效和硬失效如何在模块图中传播:
在这个场景中:
helper.ts发生文件变更,被硬失效(红色)。utils.ts静态导入了helper.ts,被软失效(黄色)。App.vue和Footer.vue静态导入了utils.ts,也被软失效。但因为它们都是isSelfAccepting,所以失效传播到此终止。Header.vue没有依赖变更的模块,保持有效(绿色)。
6.6 文件变更与模块删除处理
文件系统事件是触发模块图状态变化的外部信号。Vite 使用 chokidar 监听文件系统的变更事件(create、update、delete),然后将这些事件传递给模块图进行处理。模块图提供了两个入口方法,分别对应文件变更和文件删除两种场景:
typescript
onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
const seen = new Set<EnvironmentModuleNode>()
mods.forEach((mod) => {
this.invalidateModule(mod, seen)
})
}
}
onFileDelete(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
mods.forEach((mod) => {
mod.importedModules.forEach((importedMod) => {
importedMod.importers.delete(mod)
})
})
}
}onFileChange 将文件对应的所有模块标记为硬失效(因为没有传入 softInvalidate 参数,默认为 false)。seen 集合在多个模块之间共享,确保环形依赖不会导致无限递归。
onFileDelete 的处理更为简洁——它不需要失效传播,因为删除的文件不会再被请求。它只需要清理反向依赖关系,即遍历被删除模块的 importedModules,从每个依赖模块的 importers 集合中移除对自身的引用,防止悬空引用的产生。需要注意的是,onFileDelete 并不会从 fileToModulesMap 中移除该文件的条目——这些清理工作会在后续的模块图整理过程中完成。这种惰性清理的策略减少了删除操作的即时开销,同时也为"快速删除后重新创建"这种常见的编辑器行为留出了缓冲。
6.7 混合模块图:兼容层的设计
在 Vite 5 及更早版本中,模块图只有一个全局实例,浏览器端和 SSR 端的模块信息混合存储在同一个数据结构中。这种设计在引入多环境支持后变得不再适用——同一个文件在浏览器端可能被转换为带有 HMR 注入代码的 ESM 模块,在 SSR 端则保留为 CommonJS 格式,它们的依赖关系、转换结果和失效状态都是独立的。
Vite 6 引入了环境 API(Environment API),将每个运行环境的模块图独立管理。但这带来了一个严峻的兼容性挑战——大量的第三方 Vite 插件(特别是 Vue、React 等框架的插件)依赖旧的统一模块图 API。为了让这些插件在不修改代码的情况下继续工作,mixedModuleGraph.ts 提供了一个精巧的桥接层。这个桥接层通过代理模式(Proxy Pattern)将新的环境独立模块图包装为旧的统一接口,实现了零破坏性的向后兼容。
6.7.1 ModuleNode:双环境代理
ModuleNode 是一个代理对象,它内部持有 _clientModule 和 _ssrModule 两个引用,将属性访问委托到对应的 EnvironmentModuleNode:
typescript
export class ModuleNode {
_moduleGraph: ModuleGraph
_clientModule: EnvironmentModuleNode | undefined
_ssrModule: EnvironmentModuleNode | undefined
_get<T extends keyof EnvironmentModuleNode>(
prop: T,
): EnvironmentModuleNode[T] {
return (this._clientModule?.[prop] ?? this._ssrModule?.[prop])!
}
get url(): string { return this._get('url') }
get id(): string | null { return this._get('id') }
get importers(): Set<ModuleNode> {
return this._getModuleSetUnion('importers')
}
get importedModules(): Set<ModuleNode> {
return this._getModuleSetUnion('importedModules')
}
}对于简单的标量属性(如 url、id),采用"client 优先"策略——如果客户端模块存在则返回客户端的值,否则回退到 SSR 的值。这种优先级的选择是有道理的:在典型的 Vite 应用中,大部分模块同时存在于 client 和 ssr 两个环境中,且它们的 url 和 id 通常是相同的,因此使用哪个环境的值差别不大。但对于少数仅存在于某一环境的模块(如浏览器特有的 Web Worker 模块或 SSR 特有的 Node.js 内置模块),这种回退机制确保了兼容性代理始终能返回有效的值。
对于集合属性(如 importers、importedModules),_getModuleSetUnion 方法返回两个环境的并集,且通过 ID 去重避免同一个物理模块出现两次。这种并集策略的代价是每次访问集合属性都会创建新的 Set 对象,但由于兼容层主要用于插件的 handleHotUpdate 钩子中(非热路径),这个开销是可接受的。
6.7.2 ModuleGraph:索引 Map 的代理
ModuleGraph 类同样采用代理模式,其 urlToModuleMap、idToModuleMap 等属性都是代理 Map,背后合并了 client 和 ssr 两个环境的数据:
为了避免重复创建 ModuleNode 代理对象,ModuleGraph 使用了一个 DualWeakMap 缓存:
typescript
class DualWeakMap<K1 extends WeakKey, K2 extends WeakKey, V> {
private map = new WeakMap<K1 | object, WeakMap<K2 | object, V>>()
private undefinedKey = {}
get(key1: K1 | undefined, key2: K2 | undefined): V | undefined {
const k1 = key1 ?? this.undefinedKey
const k2 = key2 ?? this.undefinedKey
return this.map.get(k1)?.get(k2)
}
}这是一个优雅的二维弱引用缓存。以 (clientModule, ssrModule) 作为复合键,确保每对环境模块节点只会创建一个 ModuleNode 代理。当环境模块节点被垃圾回收后,对应的代理也会自动释放,避免内存泄漏。undefinedKey 哨兵对象的设计处理了"一个环境存在模块而另一个环境不存在"的情况。
6.8 File-Only Entry:无 URL 的幽灵模块
并非所有需要参与 HMR 的文件都有自己的请求 URL。最典型的例子是通过 CSS @import 引入的文件:
typescript
createFileOnlyEntry(file: string): EnvironmentModuleNode {
file = normalizePath(file)
let fileMappedModules = this.fileToModulesMap.get(file)
if (!fileMappedModules) {
fileMappedModules = new Set()
this.fileToModulesMap.set(file, fileMappedModules)
}
const url = `${FS_PREFIX}${file}`
for (const m of fileMappedModules) {
if ((m.url === url || m.id === file) && m.type === 'asset') {
return m
}
}
const mod = new EnvironmentModuleNode(url, this.environment)
mod.type = 'asset'
mod.file = file
fileMappedModules.add(mod)
return mod
}这些"幽灵模块"不会出现在 urlToModuleMap 或 idToModuleMap 中,只存在于 fileToModulesMap 中。它们的 type 被设置为 'asset',并使用 /@fs/ 前缀的虚拟 URL。它们存在的唯一目的是:当被 @import 的 CSS 文件发生变更时,通过 fileToModulesMap 找到这个幽灵模块,再通过其 importers 找到主 CSS 文件,从而触发主 CSS 文件的 HMR 更新。
这个设计体现了模块图的一个重要原则:任何可能触发 HMR 的文件都必须在模块图中有对应的节点,即使这个节点没有自己的公开 URL。如果没有幽灵模块,当 _variables.scss 被修改时,Vite 将无法通过 fileToModulesMap 找到任何关联的模块,也就无法触发使用了这些变量的主样式文件的更新。CSS 预处理器(如 Sass、Less、Stylus)的 @import 关系正是通过这种机制被纳入 HMR 体系的。在 CSS 插件处理模块时,它会解析出所有 @import 的文件路径,并为每个文件调用 createFileOnlyEntry,然后将创建的幽灵模块添加到主 CSS 模块的 importedModules 中。
6.9 循环依赖的安全处理
循环依赖在模块图中是一个需要特别注意的问题。Vite 在多个层面处理了循环依赖:
失效传播中的循环保护:invalidateModule 使用 seen 集合追踪已处理的模块,防止循环调用。但仅靠 seen 集合还不够——如果一个模块在第一次访问时被软失效,第二次因为另一条路径需要被硬失效,那么 seen 集合不应阻止第二次更新。因此代码检查的条件是:
typescript
if (seen.has(mod) && prevInvalidationState === mod.invalidationState) {
return
}只有当模块已被访问且失效状态没有变化时,才跳过。这确保了"软失效升级为硬失效"的路径不会被误拦截。
HMR 传播中的循环检测:当 HMR 更新沿导入链向上传播时,propagateUpdate 函数通过 currentChain 数组检查传播链中是否存在循环。如果一个模块在循环导入中被标记为 HMR 边界,它的更新无法保证正确的执行顺序,因此会标记 isWithinCircularImport。客户端收到带有此标记的更新后,如果热更新应用失败,会回退到整页重载。
全量失效的安全阀:invalidateAll 方法提供了一个紧急手段,将所有已知模块标记为失效。它在模块图出现不一致状态时作为最后的兜底方案。
6.10 设计决策与权衡
为什么选择双向图而不是单向图?
单向图(只记录 importedModules)在构建阶段是足够的——构建时的依赖分析只需要从入口出发向下遍历即可。但在开发阶段,HMR 需要从变更的模块出发,向上追溯到所有受影响的模块,直到找到 HMR 边界。如果只有 importedModules 这一方向的边,则需要遍历整个模块图来找到"谁依赖了这个模块",时间复杂度是 O(N),其中 N 是模块总数。维护 importers 的反向引用使这个查找变为 O(K),其中 K 是直接依赖当前模块的模块数量(通常远小于 N)。这种以额外内存换取查找速度的权衡,在开发场景下是非常划算的——每个 Set 只需要存储对象引用,几乎不增加内存压力,但将 HMR 传播的效率提升了几个数量级。
为什么 fileToModulesMap 是一对多的?
这是由前端框架和工具链的特性决定的。最典型的例子是 Vue 单文件组件:一个 .vue 文件中的 <script>、<template>、<style> 块在 Vite 中被 Vue 插件拆分为独立的虚拟模块,它们共享同一个物理文件但有不同的查询参数(如 ?vue&type=script、?vue&type=style&index=0)。类似地,Svelte 文件也会被拆分为逻辑和样式两个模块。CSS 预处理器的 @import 关系通过前面讨论的 File-Only Entry 机制创建额外的模块条目。如果使用一对一的映射,当 .vue 文件变更时,系统只能找到其中一个子模块进行更新,其他子模块将被遗漏,导致页面显示不一致。一对多的映射保证了文件变更能通知到该文件关联的所有模块,每个模块可以独立地决定自己是否需要更新以及如何更新。
为什么需要软失效机制?
在一个典型的 React/Vue 项目中,一个组件文件的变更可能沿导入链向上影响数十甚至上百个模块。如果每个模块都需要完整地重新转换,HMR 延迟将随项目规模线性增长。软失效机制识别出"代码没变,只是依赖的时间戳变了"这一常见情况,将大部分间接受影响的模块的更新开销降低到近乎零。
为什么 ETag Map 只用于客户端环境?
typescript
updateModuleTransformResult(
mod: EnvironmentModuleNode,
result: TransformResult | null,
): void {
if (this.environment === 'client') {
const prevEtag = mod.transformResult?.etag
if (prevEtag) this.etagToModuleMap.delete(prevEtag)
if (result?.etag) this.etagToModuleMap.set(result.etag, mod)
}
mod.transformResult = result
}ETag 是 HTTP 条件请求的机制,只有通过 HTTP 请求加载模块的客户端环境才需要它。SSR 环境中模块是通过 Node.js 直接加载的,不涉及 HTTP 缓存协商。
为什么兼容层使用 WeakMap 而不是 Map?
DualWeakMap 使用 WeakMap 作为底层存储,这意味着当 EnvironmentModuleNode 被垃圾回收后(例如模块从模块图中被移除),对应的 ModuleNode 代理也会被自动回收。如果使用普通 Map,兼容层会持有对所有已创建代理的强引用,导致模块图永远无法释放已移除模块的内存。
6.11 小结
回顾本章讨论的全部内容,模块图是 Vite 开发服务器的记忆中枢,也是连接请求处理、代码转换和热更新三大子系统的数据枢纽。它不仅存储了每个模块的转换缓存,更重要的是维护了模块之间的双向依赖关系和 HMR 接受关系。EnvironmentModuleNode 作为基本单元,通过 importers 和 importedModules 构成有向图;EnvironmentModuleGraph 通过四张索引 Map 提供多维度查找能力;软失效与硬失效的两级策略在保证正确性的前提下最小化了更新开销。
混合模块图兼容层通过 DualWeakMap 和代理模式,以零额外内存开销的方式桥接了新的环境 API 与旧的统一接口。File-Only Entry 机制则为没有独立 URL 但需要参与 HMR 的资源文件提供了优雅的解决方案。
在实际的大型前端项目中,模块图可能包含数千个节点和上万条边。Vite 的模块图设计在这种规模下仍能保持高效,得益于以下几个工程决策:第一,所有查找操作都是 O(1) 的 Map 查找,而非遍历;第二,失效传播通过 seen 集合保证每个节点最多被访问一次;第三,软失效机制将大部分间接影响的模块的更新开销降低到近乎为零;第四,_unresolvedUrlToModuleMap 的 Promise 缓存消除了并发解析的重复开销。
从更宏观的视角来看,模块图的设计理念可以概括为"以运行时的内存开销换取开发时的响应速度"。在构建模式下,Rollup/Rolldown 可以在磁盘上序列化和反序列化依赖信息;但在开发模式下,Vite 选择将所有信息保存在内存中的 Map 和 Set 中,以获得最快的查找和更新速度。这是开发工具设计中一个常见的权衡——开发服务器的生命周期通常不超过几个小时,内存的消耗可以在进程结束时自然释放。
在下一章中,我们将进入热模块替换的完整流程,深入分析模块图如何与热更新管道紧密协同工作,将文件变更信号精确地从服务端传递到浏览器端并完成模块的原地替换。