Vite 设计与实现
第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 开发时性能优化和热更新机制的基础。接下来我们从模块图的最小单元开始,自底向上地剖析整个数据结构。
graph TD
A[浏览器请求模块] --> B{模块图中是否存在?}
B -->|是| C{transformResult 有效?}
B -->|否| D[创建 EnvironmentModuleNode]
D --> E[解析并转换模块]
E --> F[更新模块图依赖关系]
C -->|有效| G[返回缓存结果]
C -->|软失效| H[仅替换时间戳]
C -->|硬失效| E
H --> G
F --> G
6.2 EnvironmentModuleNode:模块图的基本单元
每一个被 Vite 处理过的模块,在内存中都对应着一个 EnvironmentModuleNode 实例。这个类定义在 packages/vite/src/node/server/moduleGraph.ts 文件中,只有不到一百行代码,却承载了模块在开发阶段的全部生命周期信息——从身份标识到依赖关系,从转换缓存到失效状态,从热更新接受声明到时间戳管理。让我们逐一解析它的关键字段,理解每个字段在系统中扮演的角色。
6.2.1 身份标识字段
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 字段的判定逻辑非常简洁:
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 依赖关系字段
// packages/vite/src/node/server/moduleGraph.ts:28
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 可以向下检查。
graph LR
subgraph "双向依赖关系"
A["main.ts"] -->|importedModules| B["utils.ts"]
B -->|importers| A
A -->|importedModules| C["App.vue"]
C -->|importers| A
C -->|importedModules| D["style.css"]
D -->|importers| C
end
subgraph "HMR 接受关系"
A -.->|acceptedHmrDeps| C
C -.->|isSelfAccepting: true| C
end
acceptedHmrDeps 记录的是通过 import.meta.hot.accept('./dep.js', callback) 显式声明接受的依赖模块。isSelfAccepting 表示模块通过 import.meta.hot.accept() 接受自身更新。acceptedHmrExports 与 importedBindings 配合使用,实现了部分接受(partial accept)的能力——如果一个模块导出了 10 个函数,其中 2 个通过 import.meta.hot.acceptExports 声明为可接受,而某个导入者只用到了这 2 个,那么更新传播到这个导入者时就不必继续向上扩散。
staticImportedUrls 是一个内部字段,用于区分静态导入和其他类型的导入(如 glob 导入或文件监听依赖)。这一区分在失效传播中至关重要——只有静态导入的模块变更可以触发软失效,其他类型的变更必须触发硬失效(除非被导入的模块本身也处于软失效,见 6.5.2 节)。
6.2.3 缓存与失效字段
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 提供了多维度的模块查找能力:
// packages/vite/src/node/server/moduleGraph.ts:90
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()
}
graph TB
subgraph "四张索引 Map"
URL["urlToModuleMap<br/>/src/App.vue --> node"]
ID["idToModuleMap<br/>/abs/path/src/App.vue --> node"]
ETAG["etagToModuleMap<br/>W/abc123 --> node"]
FILE["fileToModulesMap<br/>/abs/path/src/App.vue --> Set of nodes"]
end
NODE["EnvironmentModuleNode"]
URL --> NODE
ID --> NODE
ETAG --> NODE
FILE --> NODE
subgraph "同一文件的多个模块"
N1["App.vue (script)"]
N2["App.vue?type=style"]
N3["App.vue?type=template"]
end
FILE -.-> N1
FILE -.-> N2
FILE -.-> N3
每张 Map 服务于不同的查找场景:
- urlToModuleMap:当浏览器发起模块请求时,通过公开 URL 查找模块。
- idToModuleMap:当解析后的模块 ID 已知时(如
getModuleById、_ensureEntryFromUrl中按 id 去重),直接定位模块。 - etagToModuleMap:仅客户端环境使用,支持 HTTP 304 条件请求机制。
- fileToModulesMap:当磁盘上某个文件发生变更时,查找该文件关联的所有模块(一个文件可能对应多个模块)。
还有一张内部的 _unresolvedUrlToModuleMap,它缓存了从原始 URL(已去掉 ?t= 时间戳和 ?import 查询,但可能没有扩展名)到模块节点的映射。这个缓存的巧妙之处在于,它可以暂存一个 Promise<EnvironmentModuleNode>,即当多个请求并发解析同一个 URL 时,第二个请求可以直接等待第一个请求的解析 Promise,避免重复解析。
6.3.1 模块的创建流程
ensureEntryFromUrl 是创建或获取模块节点的核心方法:
// packages/vite/src/node/server/moduleGraph.ts:343
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 没有,则自动补上扩展名:
// packages/vite/src/node/server/moduleGraph.ts:468
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 被规范化为同一个带扩展名的 URL。虚拟模块(URL 中含 \0 或带 virtual: 前缀)被排除在外,因为它们没有真实的文件系统路径,补充扩展名对它们没有意义。
这个看似简单的扩展名补充逻辑解决了一个在 ESM 开发中频繁出现的实际问题。在 TypeScript 项目中,开发者通常写 import { foo } from './utils' 而不带扩展名,但磁盘上的文件是 utils.ts。如果不做补充,节点本身仍会因 idToModuleMap 按 id 去重而只有一个,但它的 url 字段取决于哪种写法先被请求——可能是 /src/utils,也可能是 /src/utils.ts,urlToModuleMap 里也会同时出现两个键。补齐扩展名之后,同一个模块只有一种规范的公开 URL,节点的 url 字段与索引的键保持一致。
6.4 双向依赖图的增量维护
模块图的依赖关系不是一次性构建的,而是随着每个模块被请求和转换而逐步填充。updateModuleInfo 方法是这个增量维护过程的核心:
// packages/vite/src/node/server/moduleGraph.ts:252
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
}
sequenceDiagram
participant T as 转换管道
participant G as ModuleGraph
participant M as ModuleNode
T->>G: updateModuleInfo(mod, newImports, ...)
Note over G: 保存旧的 importedModules
loop 对每个新导入
G->>G: ensureEntryFromUrl(imported)
G->>M: dep.importers.add(mod)
end
Note over G: 设置 mod.importedModules = nextImports
loop 对每个旧导入
alt 不在新导入中
G->>M: dep.importers.delete(mod)
alt dep.importers 为空
G-->>T: 加入 noLongerImported
end
end
end
T->>T: handlePrunedModules(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':硬失效状态。模块需要完整地重新加载和转换。
stateDiagram-v2
[*] --> Valid : 初始状态
Valid --> SoftInvalidated : 静态导入的依赖发生失效
Valid --> HardInvalidated : 文件内容变更或非静态导入变更
SoftInvalidated --> HardInvalidated : 后续硬失效覆盖
SoftInvalidated --> Valid : 重新请求时仅替换时间戳
HardInvalidated --> Valid : 重新请求时完整重新转换
6.5.2 失效传播逻辑
// packages/vite/src/node/server/moduleGraph.ts:166
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.ssrModule / mod.ssrError)
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,
)
}
})
// ...(省略:this._hasResolveFailedErrorModules.delete(mod))
}
这段代码有几个关键的设计决策:
软失效的保守策略:当尝试对一个模块进行软失效时,使用了空值合并运算符 ??=。这意味着如果模块已经被硬失效,后续的软失效不会"降级"为更温和的状态。反之,硬失效可以覆盖软失效——直接将 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 '/src/helper.ts?t=1234' 变为 import '/src/helper.ts?t=5678'),而不需要重新执行 utils.ts 的全部转换管道。这在大型项目中可以显著减少 HMR 的延迟。
软失效时,旧的 transformResult 被暂存到 invalidationState 中。当下一次 transformRequest 处理这个模块时,它会从暂存的结果中提取代码,仅替换其中的导入时间戳,然后返回更新后的结果——跳过了完整的 load 和 transform 钩子调用。
6.5.4 失效传播路径示例
下面用一个更具体的示例展示软失效和硬失效如何在模块图中传播:
graph TD
subgraph "失效传播示例"
A["App.vue<br/>isSelfAccepting: true"] -->|静态导入| B["Header.vue<br/>isSelfAccepting: true"]
A -->|静态导入| C["utils.ts"]
C -->|静态导入| D["helper.ts<br/>(文件变更)"]
E["Footer.vue<br/>isSelfAccepting: true"] -->|静态导入| C
end
style D fill:#ff6b6b,color:#333
style C fill:#ffd93d,color:#333
style A fill:#ffd93d,color:#333
style E fill:#ffd93d,color:#333
style B fill:#c8e6c9,color:#333
在这个场景中:
helper.ts发生文件变更,被硬失效(红色)。utils.ts静态导入了helper.ts,被软失效(黄色)。App.vue和Footer.vue静态导入了utils.ts,也被软失效。注意isSelfAccepting并不会让invalidateModule停下——只要导入者没有在acceptedHmrDeps里声明接受,失效就会继续沿importers向上传播(图中没有画出它们的导入者);isSelfAccepting决定的是第 7 章propagateUpdate里的 HMR 边界。Header.vue没有依赖变更的模块,保持有效(绿色)。
6.6 文件变更与模块删除处理
文件系统事件是触发模块图状态变化的外部信号。Vite 使用 chokidar 监听文件系统的变更事件(add、change、unlink,Vite 内部分别记作 create、update、delete),然后将这些事件传递给模块图进行处理。模块图提供了两个入口方法,分别对应文件变更和文件删除两种场景:
// packages/vite/src/node/server/moduleGraph.ts:145
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、urlToModuleMap 等索引中移除该文件的节点——源码里也没有任何后续的"模块图整理"过程,这些节点会一直留在图中直到服务器重启。这种不做清理的策略减少了删除操作的即时开销;一个副作用是,"快速删除后重新创建"这种常见的编辑器行为可以直接复用原有节点。
6.7 混合模块图:兼容层的设计
在 Vite 5 及更早版本中,模块图只有一个全局实例,浏览器端和 SSR 端的模块信息混合存储在同一个数据结构中。这种设计在引入多环境支持后变得不再适用——同一个文件在浏览器端可能被转换为带有 HMR 注入代码的 ESM 模块,在 SSR 端则被 ssrTransform 改写为 __vite_ssr_import__() 调用形式,它们的依赖关系、转换结果和失效状态都是独立的。
Vite 6 引入了环境 API(Environment API),将每个运行环境的模块图独立管理。但这带来了一个严峻的兼容性挑战——大量的第三方 Vite 插件(特别是 Vue、React 等框架的插件)依赖旧的统一模块图 API。为了让这些插件在不修改代码的情况下继续工作,mixedModuleGraph.ts 提供了一个精巧的桥接层。这个桥接层通过代理模式(Proxy Pattern)将新的环境独立模块图包装为旧的统一接口,尽量贴近旧行为地保持向后兼容(部分字段源码注释自称只是对旧逻辑的"good approximation",见 packages/vite/src/node/server/mixedModuleGraph.ts:60、:285)。
6.7.1 ModuleNode:双环境代理
ModuleNode 是一个代理对象,它内部持有 _clientModule 和 _ssrModule 两个引用,将属性访问委托到对应的 EnvironmentModuleNode:
// packages/vite/src/node/server/mixedModuleGraph.ts:20
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 端导入的服务端模块),这种回退机制确保了兼容性代理始终能返回有效的值。
对于集合属性(如 importers、importedModules),_getModuleSetUnion 方法返回两个环境的并集,且通过 ID 去重避免同一个物理模块出现两次。这种并集策略的代价是每次访问集合属性都会创建新的 Set 对象,但由于兼容层主要用于插件的 handleHotUpdate 钩子中(非热路径),这个开销是可接受的。
6.7.2 ModuleGraph:索引 Map 的代理
ModuleGraph 类同样采用代理模式,其 urlToModuleMap、idToModuleMap 等属性都是代理 Map(由 createBackwardCompatibleModuleMap 构造的、实现了 Map 接口的普通对象,并非 JS Proxy),背后合并了 client 和 ssr 两个环境的数据:
graph TB
subgraph "兼容层 ModuleGraph"
MG["ModuleGraph"]
MG --> URL_PROXY["urlToModuleMap (代理 Map)"]
MG --> ID_PROXY["idToModuleMap (代理 Map)"]
end
subgraph "Client 环境"
CG["EnvironmentModuleGraph (client)"]
CG --> CURL["urlToModuleMap"]
CG --> CID["idToModuleMap"]
end
subgraph "SSR 环境"
SG["EnvironmentModuleGraph (ssr)"]
SG --> SURL["urlToModuleMap"]
SG --> SID["idToModuleMap"]
end
URL_PROXY -.->|合并| CURL
URL_PROXY -.->|合并| SURL
ID_PROXY -.->|合并| CID
ID_PROXY -.->|合并| SID
为了避免重复创建 ModuleNode 代理对象,ModuleGraph 使用了一个 DualWeakMap 缓存:
// packages/vite/src/node/server/mixedModuleGraph.ts:520
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 代理。需要注意,类型声明里内层写的是 WeakMap,但 set 方法实际创建的内层容器是 new Map<K2, V>()(packages/vite/src/node/server/mixedModuleGraph.ts:534),所以只有第一维(client 模块)是弱引用:client 模块被垃圾回收后,整张内层 Map 连同其中的代理一起释放;第二维的 ssr 模块和代理则被内层 Map 强引用。undefinedKey 哨兵对象的设计处理了"一个环境存在模块而另一个环境不存在"的情况。
6.8 File-Only Entry:无 URL 的幽灵模块
并非所有需要参与 HMR 的文件都有自己的请求 URL。最典型的例子是通过 CSS @import 引入的文件:
// packages/vite/src/node/server/moduleGraph.ts:395
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 集合不应阻止第二次更新。因此代码检查的条件是:
if (seen.has(mod) && prevInvalidationState === mod.invalidationState) {
return
}
只有当模块已被访问且失效状态没有变化时,才跳过。这确保了"软失效升级为硬失效"的路径不会被误拦截。
HMR 传播中的循环检测:当 HMR 更新沿导入链向上传播时,propagateUpdate 函数通过 currentChain 数组检查传播链中是否存在循环。如果一个模块在循环导入中被标记为 HMR 边界,它的更新无法保证正确的执行顺序,因此会标记 isWithinCircularImport。客户端收到带有此标记的更新后,如果热更新应用失败,会回退到整页重载。
全量失效的安全阀:invalidateAll 方法提供了一个紧急手段,将 idToModuleMap 中的所有模块标记为失效。它用在缓存的转换结果整体过时的场合:依赖预构建重新运行后需要整页重载时(packages/vite/src/node/optimizer/optimizer.ts:527),以及 tsconfig.json 变更时(packages/vite/src/node/plugins/esbuild.ts:558)。
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 只用于客户端环境?
// packages/vite/src/node/server/moduleGraph.ts:430
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,以 client 模块节点为键,这意味着当 client 端的 EnvironmentModuleNode 不再被引用、被垃圾回收后(例如整个环境的模块图被丢弃),对应的 ModuleNode 代理也会随之被回收。如果外层使用普通 Map,兼容层会持有对所有已创建代理的强引用,模块图本身丢弃后这些内存也无法释放。不过如 6.7.2 节所说,内层实际是普通 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 中,以获得最快的查找和更新速度。这是开发工具设计中一个常见的权衡——开发服务器的生命周期通常不超过几个小时,内存的消耗可以在进程结束时自然释放。
在下一章中,我们将进入热模块替换的完整流程,深入分析模块图如何与热更新管道紧密协同工作,将文件变更信号精确地从服务端传递到浏览器端并完成模块的原地替换。