:::tip 本章要点
在传统的打包工具(如 Webpack 和 Rollup)中,依赖关系在构建时一次性解析完成,生成一个静态的依赖图。整个项目的所有模块在构建阶段就被扫描和分析,最终产出的是一个不再变化的依赖拓扑结构。但 Vite 的开发服务器采用了截然不同的策略——按需编译模式。只有当浏览器实际请求某个模块时,这个模块才会被解析和转换。这意味着依赖关系是动态增长的,模块图随着用户在浏览器中的导航和交互而不断扩展。更重要的是,每次文件变更都可能改变依赖关系——一个新增的 import 语句会在图中创建新的边,一个删除的 import 语句会使某些依赖变为孤立节点。
模块图(Module Graph)就是 Vite 用来追踪这种动态依赖关系的核心数据结构。如果说开发服务器是 Vite 的心脏,那么模块图就是它的神经系统——感知变化、传递信号、协调响应。它在运行时回答了以下几个关键问题:
理解模块图的设计,是理解 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
每一个被 Vite 处理过的模块,在内存中都对应着一个 EnvironmentModuleNode 实例。这个类定义在 src/node/server/moduleGraph.ts 文件中,只有不到一百行代码,却承载了模块在开发阶段的全部生命周期信息——从身份标识到依赖关系,从转换缓存到失效状态,从热更新接受声明到时间戳管理。让我们逐一解析它的关键字段,理解每个字段在系统中扮演的角色。
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 状态,表示"尚未确定",这在后续的传播算法中有特殊的处理逻辑。
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 个,而这 2 个恰好被声明为可接受的,则不需要完整重载。
staticImportedUrls 是一个内部字段,用于区分静态导入和其他类型的导入(如 glob 导入或文件依赖)。这一区分在失效传播中至关重要——只有静态导入的模块变更可以触发软失效,其他类型的变更必须触发硬失效。
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() 请求。这个标志确保同一模块的同一次失效只被处理一次,避免重复触发更新。
EnvironmentModuleGraph 是模块图的容器,通过四张 Map 提供了多维度的模块查找能力:
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 服务于不同的查找场景:
还有一张内部的 _unresolvedUrlToModuleMap,它缓存了从原始 URL(可能没有扩展名、可能包含时间戳)到模块节点的映射。这个缓存的巧妙之处在于,它可以暂存一个 Promise<EnvironmentModuleNode>,即当多个请求并发解析同一个 URL 时,第二个请求可以直接等待第一个请求的解析 Promise,避免重复解析。
ensureEntryFromUrl 是创建或获取模块节点的核心方法:
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
}
这段代码体现了多个精巧的设计:
?import 和 ?t=xxx 查询参数,确保缓存命中率。_unresolvedUrlToModuleMap 查找,避免昂贵的解析操作。idToModuleMap 检查是否已有相同 ID 的模块(多个 URL 可能解析到同一文件)。_resolveUrl 方法调用插件链的 resolveId 钩子来解析模块路径,并且有一个重要的补充逻辑——如果解析后的 ID 带有文件扩展名,而原始 URL 没有,则自动补上扩展名:
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(' ') && !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 映射到同一个模块节点,避免了因扩展名缺失导致的重复模块创建。虚拟模块(以