Appearance
第7章 HMR 热更新
"热模块替换的核心挑战不在于替换模块本身,而在于精确地判断哪些模块需要替换、哪些模块只需要通知、哪些时候必须放弃治疗整页重载。"
本章要点
- HMR 的完整链路由四个阶段构成:文件系统变更检测、服务端模块图失效与更新传播、WebSocket 消息推送、客户端模块替换执行
- propagateUpdate 是 HMR 的核心算法:它沿着模块图的 importers 链向上搜索 HMR 边界,遇到 isSelfAccepting 或 acceptedHmrDeps 则停止传播,到达无 importers 的根模块则触发整页重载
- 客户端 HMRClient 实现了有序的异步更新队列:通过 queueUpdate 机制确保同一次文件变更触发的多个更新按发送顺序执行,避免竞态条件
- CSS HMR 采用 link 标签替换而非 style 注入:对于通过
<link>标签引用的 CSS,Vite 创建新的 link 元素并在加载完成后移除旧元素,消除了无样式内容闪烁(FOUC) - import.meta.hot API 通过 HMRContext 类实现:每个模块拥有独立的 HMRContext 实例,accept、dispose、invalidate 等方法都是在该上下文中注册回调
- WebSocket 通信使用 token 机制防止跨站劫持:连接建立时必须携带服务器生成的 token,防止恶意网站通过 WebSocket 连接窃取开发服务器数据
7.1 HMR 的完整链路
热模块替换(Hot Module Replacement,简称 HMR)是现代前端开发体验的基石。当开发者保存一个文件后,从磁盘写入到浏览器中看到效果,整个过程通常在几十毫秒到数百毫秒之间完成。这背后是一条精密编排的处理管道,涉及文件系统监听、模块图失效、更新传播算法、WebSocket 消息推送、客户端模块重新导入和回调执行等多个环节。
与传统的"修改代码-手动刷新"的开发模式相比,HMR 的核心价值在于两个方面:第一,速度快——只重新加载和执行变更的模块及其直接消费者,而非整个页面;第二,保持状态——页面中其他模块的运行时状态(如 React 的组件状态、表单的输入值、滚动位置等)不会因为刷新而丢失。但要实现这两个目标,需要在"精确性"和"安全性"之间做出细致的权衡——更新范围太小可能导致状态不一致,更新范围太大则失去了 HMR 的意义。
让我们沿着这条管道,从文件系统事件的触发开始,逐一深入每个环节的实现细节。
让我们沿着这条管道,逐一深入每个环节。
7.2 服务端入口:handleHMRUpdate
handleHMRUpdate 是整个 HMR 链路的服务端起点,定义在 src/node/server/hmr.ts 中。当 chokidar 文件系统监听器检测到文件变更后,会调用这个函数。它接收三个参数:变更类型(create 表示新建文件、update 表示修改文件、delete 表示删除文件)、变更文件的绝对路径以及 Vite 开发服务器实例。这个函数的职责是判断变更类型并决定后续的处理策略——配置文件变更触发服务器重启、客户端代码变更触发全页重载、普通模块变更则进入精细的 HMR 管道:
typescript
export async function handleHMRUpdate(
type: 'create' | 'delete' | 'update',
file: string,
server: ViteDevServer,
): Promise<void> {
const { config } = server
const shortFile = getShortName(file, config.root)
// 第一道判断:配置文件或环境变量文件变更,直接重启服务器
const isConfig = file === config.configFile
const isConfigDependency = config.configFileDependencies.some(
(name) => file === name,
)
const isEnv =
config.envDir !== false &&
getEnvFilesForMode(config.mode, config.envDir).includes(file)
if (isConfig || isConfigDependency || isEnv) {
debugHmr?.(`[config change] ${colors.dim(shortFile)}`)
config.logger.info(
colors.green(`${normalizePath(path.relative(process.cwd(), file))} changed, restarting server...`),
{ clear: true, timestamp: true },
)
try {
await restartServerWithUrls(server)
} catch (e) {
config.logger.error(colors.red(e))
}
return
}配置文件和环境变量文件的变更不走 HMR 管道,而是直接重启整个开发服务器。这是因为这些文件影响的是全局配置——如插件列表、别名解析、服务器选项等——这些配置在服务器启动时就已经固化,无法通过模块级别的替换来更新。configFileDependencies 还包含了配置文件中通过 require 或 import 引入的辅助文件,确保这些间接依赖的变更也能触发重启。环境变量文件(如 .env、.env.local、.env.development)通过 getEnvFilesForMode 函数根据当前模式确定需要监听的文件列表。
typescript
// 第二道判断:Vite 客户端代码自身变更,触发全页重载
if (file.startsWith(withTrailingSlash(normalizedClientDir))) {
environments.forEach(({ hot }) =>
hot.send({
type: 'full-reload',
path: '*',
triggeredBy: path.resolve(config.root, file),
}),
)
return
}@vite/client 是运行在浏览器中的 HMR 客户端代码本身。如果这段代码发生变更,它无法"自己更新自己",只能通知所有环境执行全页重载。
7.2.1 插件钩子的介入
在确定受影响的模块后,Vite 给予插件修改 HMR 行为的机会。hotUpdate 钩子(以及即将废弃的 handleHotUpdate 钩子)允许插件过滤、添加或替换受影响的模块列表:
typescript
for (const plugin of getSortedHotUpdatePlugins(server.environments.client)) {
if (plugin.hotUpdate) {
const filteredModules = await getHookHandler(plugin.hotUpdate).call(
clientContext,
clientHotUpdateOptions,
)
if (filteredModules) {
clientHotUpdateOptions.modules = filteredModules
}
}
}这一机制使得 Vue 插件可以精确控制 .vue 文件变更时哪些子模块需要热更新,而不是粗暴地重载整个组件。插件按照 pre、normal、post 三个阶段排序执行,确保处理顺序的可预测性。
7.2.2 多环境并行处理
Vite 6 支持多个运行环境(如 client、ssr 以及自定义环境)。HMR 更新在所有环境中并行处理:
typescript
const hotUpdateEnvironments =
server.config.server.hotUpdateEnvironments ??
((server, hmr) => {
return Promise.all(
Object.values(server.environments).map((environment) =>
hmr(environment),
),
)
})
await hotUpdateEnvironments(server, hmr)默认策略是对所有环境并行执行 HMR,但用户可以通过 server.hotUpdateEnvironments 配置自定义策略,例如串行执行或跳过某些环境。这种可配置性对于特殊的部署场景很重要:例如在微前端架构中,不同的子应用可能运行在不同的环境中,某些环境可能需要特殊的更新顺序以保证状态一致性。
另一个值得注意的设计细节是 hotMap 的使用。在调用插件钩子之前,代码为每个环境独立地收集受影响的模块列表。对于新创建的文件(type === 'create'),除了文件直接关联的模块外,还会将所有"解析失败"的模块(_hasResolveFailedErrorModules)加入候选列表。这处理了一个常见的场景:开发者在代码中导入了一个尚不存在的文件,导致该模块被标记为解析失败;当这个文件被实际创建后,Vite 需要通知之前失败的模块重新尝试解析。
7.3 更新传播:propagateUpdate 算法
propagateUpdate 是整个 HMR 系统中最关键的算法,它决定了一次文件变更会影响哪些模块、在哪里停止传播、以及是否需要放弃热更新转而整页重载。这个算法本质上是一个深度优先搜索(DFS),从变更的模块出发,沿着模块图的 importers 链(反向依赖边)向上搜索,寻找能够"接受"更新的边界模块。所谓"接受",是指模块通过 import.meta.hot.accept() 声明了它有能力在不刷新页面的情况下处理自身或其依赖的代码变更。
typescript
function propagateUpdate(
node: EnvironmentModuleNode,
traversedModules: Set<EnvironmentModuleNode>,
boundaries: PropagationBoundary[],
currentChain: EnvironmentModuleNode[] = [node],
): HasDeadEnd {
if (traversedModules.has(node)) {
return false
}
traversedModules.add(node)
// 未分析的模块说明还没有在浏览器中加载,不需要传播
if (node.id && node.isSelfAccepting === undefined) {
return false
}
// 自接受模块:找到一个边界
if (node.isSelfAccepting) {
boundaries.push({
boundary: node,
acceptedVia: node,
isWithinCircularImport: isNodeWithinCircularImports(node, currentChain),
})
return false
}
// 部分接受模块
if (node.acceptedHmrExports) {
boundaries.push({
boundary: node,
acceptedVia: node,
isWithinCircularImport: isNodeWithinCircularImports(node, currentChain),
})
} else {
// 没有 importers 也没有自接受能力:死胡同
if (!node.importers.size) {
return true
}
}
// 继续向上遍历每个 importer
for (const importer of node.importers) {
const subChain = currentChain.concat(importer)
// importer 显式接受了当前模块的更新
if (importer.acceptedHmrDeps.has(node)) {
boundaries.push({
boundary: importer,
acceptedVia: node,
isWithinCircularImport: isNodeWithinCircularImports(importer, subChain),
})
continue
}
// 检查部分接受:importer 只导入了 node 的部分导出,且这些导出都是可接受的
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id)
if (
importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)
) {
continue
}
}
// 避免循环,继续递归
if (
!currentChain.includes(importer) &&
propagateUpdate(importer, traversedModules, boundaries, subChain)
) {
return true
}
}
return false
}算法的返回值 HasDeadEnd 可以是 false(所有路径都找到了边界)或 true/字符串(遇到了无法接受更新的死胡同)。当返回死胡同时,调用方会放弃 HMR 转而触发整页重载。
从算法复杂度的角度来看,propagateUpdate 的时间复杂度取决于模块图的拓扑结构。在最好的情况下(变更的模块自身自接受),时间复杂度是 O(1)。在最坏的情况下(没有任何模块声明自接受,需要遍历到根模块),时间复杂度是 O(V+E),其中 V 是模块数量,E 是依赖边数量。traversedModules 集合确保每个模块最多被访问一次,避免了指数级爆炸。在实际的前端项目中,由于 Vue 和 React 的框架插件会为组件文件自动注入 import.meta.hot.accept(),大部分更新在传播一到两层后就会找到边界。
7.3.1 循环导入的检测
isNodeWithinCircularImports 函数检测一个 HMR 边界模块是否处于循环导入链中。这一检测至关重要——在循环导入中,模块的执行顺序是不确定的,HMR 替换后可能无法恢复正确的执行顺序:
typescript
function isNodeWithinCircularImports(
node: EnvironmentModuleNode,
nodeChain: EnvironmentModuleNode[],
currentChain: EnvironmentModuleNode[] = [node],
traversedModules = new Set<EnvironmentModuleNode>(),
): boolean {
if (traversedModules.has(node)) {
return false
}
traversedModules.add(node)
for (const importer of node.importers) {
if (importer === node) continue
const importerIndex = nodeChain.indexOf(importer)
if (importerIndex > -1) {
if (debugHmr) {
const importChain = [
importer,
...[...currentChain].reverse(),
...nodeChain.slice(importerIndex, -1).reverse(),
]
debugHmr(
colors.yellow(`circular imports detected: `) +
importChain.map((m) => colors.dim(m.url)).join(' -> '),
)
}
return true
}
if (!currentChain.includes(importer)) {
const result = isNodeWithinCircularImports(
importer, nodeChain, currentChain.concat(importer), traversedModules,
)
if (result) return result
}
}
return false
}当检测到循环导入时,更新信息中会标记 isWithinCircularImport: true。客户端收到后不会立即放弃,而是尝试应用更新——如果应用失败(抛出异常),才回退到整页重载。这是一个务实的策略:许多循环导入在运行时并不会引发问题。
7.4 updateModules:组装更新消息
updateModules 函数将 propagateUpdate 的结果转化为可发送给客户端的更新消息:
typescript
export function updateModules(
environment: DevEnvironment,
file: string,
modules: EnvironmentModuleNode[],
timestamp: number,
firstInvalidatedBy?: string,
): void {
const { hot } = environment
const updates: Update[] = []
const invalidatedModules = new Set<EnvironmentModuleNode>()
const traversedModules = new Set<EnvironmentModuleNode>()
let needFullReload: HasDeadEnd = modules.length === 0
for (const mod of modules) {
const boundaries: PropagationBoundary[] = []
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
environment.moduleGraph.invalidateModule(
mod, invalidatedModules, timestamp, true,
)
if (hasDeadEnd) {
needFullReload = hasDeadEnd
continue
}
// 检查循环失效
if (
firstInvalidatedBy &&
boundaries.some(
({ acceptedVia }) =>
normalizeHmrUrl(acceptedVia.url) === firstInvalidatedBy,
)
) {
needFullReload = 'circular import invalidate'
continue
}
updates.push(
...boundaries.map(({ boundary, acceptedVia, isWithinCircularImport }) => ({
type: `${boundary.type}-update` as const,
timestamp,
path: normalizeHmrUrl(boundary.url),
acceptedPath: normalizeHmrUrl(acceptedVia.url),
explicitImportRequired:
boundary.type === 'js' ? isExplicitImportRequired(acceptedVia.url) : false,
isWithinCircularImport,
firstInvalidatedBy,
})),
)
}