Vite 中的热模块替换与依赖预构建
前置知识
- ›第 3 篇:开发服务器、模块图与转换管道
- ›WebSocket 协议基础
- ›理解 HMR 的核心概念(模块边界、自接受模块)
Vite 中的热模块替换与依赖预构建
让 Vite 开发体验保持高速的核心是两套系统:热模块替换(HMR)能在不刷新页面的情况下更新正在运行的应用,而依赖预构建则将 npm 包转换为优化后的 ESM,让浏览器无需为一句 import React from 'react' 发出数百个请求。这两套系统都与我们在第 3 篇探讨的模块图深度耦合。接下来,让我们从头到尾把它们捋清楚。
文件变更检测与 handleHMRUpdate
如第 3 篇所述,_createServer() 会启动一个 chokidar 监听器。在第 890 行,文件变更会触发以下逻辑:
watcher.on('change', async (file) => {
file = normalizePath(file)
// notify all environments' plugin containers
await Promise.all(
Object.values(server.environments).map((environment) =>
environment.pluginContainer.watchChange(file, { event: 'update' }),
),
)
// invalidate module graph cache
for (const environment of Object.values(server.environments)) {
environment.moduleGraph.onFileChange(file)
}
await onHMRUpdate('update', file)
})
随后,流程会分发给 handleHMRUpdate(),它首先会检查变更的文件类型是否特殊:
sequenceDiagram
participant FS as chokidar
participant HMR as handleHMRUpdate()
participant ENV as Each DevEnvironment
participant UM as updateModules()
participant WS as WebSocket
FS->>HMR: file changed
alt Config file / env file changed
HMR->>HMR: Restart server
else Vite client code changed
HMR->>WS: full-reload (all environments)
else Normal source file
loop Each environment
HMR->>ENV: Run hotUpdate plugin hooks
ENV-->>HMR: Filtered modules
HMR->>UM: updateModules(environment, file, modules)
UM->>WS: Send update/full-reload payload
end
end
如果变更的是 Vite 配置文件或环境变量文件,服务器会整体重启(第 389–406 行)。对于普通源文件,该函数会在每个环境的模块图中查找受影响的模块,并在各环境间并行执行 hotUpdate plugin hook。
模块图遍历与边界检测
HMR 的核心逻辑位于 updateModules() 和 propagateUpdate() 中。对于每个变更的模块,propagateUpdate() 会沿着 importer 链向上遍历:
flowchart TD
A["Changed Module"] --> B{"isSelfAccepting?"}
B -->|yes| C["✅ Boundary found:<br/>module accepts its own updates"]
B -->|no| D{"Has importers?"}
D -->|no| E["❌ Dead end → full reload"]
D -->|yes| F["Check each importer"]
F --> G{"Importer accepts<br/>this dep via hot.accept()?"}
G -->|yes| H["✅ Boundary: importer"]
G -->|no| I{"Importer isSelfAccepting?"}
I -->|yes| J["Continue upward anyway"]
I -->|no| K["Recurse: propagate to<br/>importer's importers"]
K --> D
这是一种深度优先遍历算法,用于收集"传播边界"——即那些通过 import.meta.hot.accept() 声明了自己能够处理更新的模块。如果遍历到达了根模块(没有 importer 的模块)仍未找到任何边界,就意味着进入了"死胡同",此时会触发整页刷新。
有一个细节值得关注:该函数会追踪循环引用(第 785 行),并为处于循环引用链中的边界模块标记 isWithinCircularImport: true。这个元信息会发送到客户端,让它在处理循环依赖的更新时更加谨慎。
收集完边界后,updateModules() 会在第 679–694 行构建一个 Update[] 有效载荷,并通过 hot channel 分发出去:
hot.send({ type: 'update', updates })
HotChannel 抽象层与 WebSocket 传输
HotChannel 接口是一个抽象传输层。Vite 并没有将 WebSocket 硬编码进去——该接口只要求实现 send()、on()、off()、listen() 和 close() 方法。这样一来,对于 SSR worker 等不支持 WebSocket 的环境,也可以使用自定义传输方式。
默认实现是基于 ws npm 包构建的 WebSocketServer,它扩展了 NormalizedHotChannel 接口。normalizeHotChannel() 包装函数(第 171–328 行)在此基础上提供了一层便利封装:包括带有重载 send() 的规范化客户端对象,以及允许客户端通过传输层调用服务端函数(如 fetchModule)的 invokeHandler 机制。
flowchart LR
subgraph "Server (Node.js)"
A[DevEnvironment.hot] -->|"NormalizedHotChannel"| B[WebSocketServer]
A2[SSR Environment.hot] -->|"NormalizedHotChannel"| C[ServerHotChannel<br/>in-process]
end
subgraph "Browser"
D[HMR Client]
end
subgraph "SSR Runner"
E[ModuleRunner]
end
B <-->|WebSocket| D
C <-->|Direct calls| E
提示: WebSocket 连接 URL 中包含一个用于安全验证的
token查询参数:ws://host:port?token=xxx。这可以防止恶意页面连接到你的开发服务器的 WebSocket。该 token 由clientInjectionsPlugin注入到客户端代码中。
客户端 HMR 处理
浏览器端的 HMR 客户端位于 src/client/client.ts。它通过注入的配置常量(__HMR_PROTOCOL__、__HMR_HOSTNAME__、__HMR_PORT__、__WS_TOKEN__)建立 WebSocket 连接,并从共享模块中创建一个 HMRClient 实例:
const transport = normalizeModuleRunnerTransport(
createWebSocketModuleRunnerTransport({
createConnection: () =>
new WebSocket(`${socketProtocol}://${socketHost}?token=${wsToken}`, 'vite-hmr'),
pingInterval: hmrTimeout,
})
)
src/shared/hmrHandler.ts 中的 createHMRHandler 负责处理接收到的消息。对于 type: 'update' 类型的消息,它会通过带时间戳的动态 import 获取每个更新后的模块,然后调用已注册的 HMR 回调。
共享 HMR 逻辑:HMRClient 与 HMRContext
src/shared/hmr.ts 中的 HMRClient 和 HMRContext 类是统一的抽象层。HMRContext 实现了用户代码所使用的 import.meta.hot API:
export class HMRContext implements ViteHotContext {
accept(deps?: any, callback?: any): void {
if (typeof deps === 'function' || !deps) {
this.acceptDeps([this.ownerPath], ([mod]) => deps?.(mod))
} else if (typeof deps === 'string') {
this.acceptDeps([deps], ([mod]) => callback?.(mod))
} else if (Array.isArray(deps)) {
this.acceptDeps(deps, callback)
}
}
// ...
}
同一个 HMRClient 类会同时被浏览器客户端和 ModuleRunner(用于 SSR HMR)实例化,区别仅在于传入了不同的 transport。这种共享确保了 HMR 协议语义在各环境中完全一致。
classDiagram
class HMRClient {
+hotModulesMap: Map
+dataMap: Map
+customListenersMap: Map
+notifyListeners(event, data)
}
class HMRContext {
+accept(deps?, callback?)
+dispose(callback)
+invalidate(message?)
+on(event, callback)
+send(event, data?)
}
class BrowserClient["Browser (client.ts)"]
class ModuleRunner["SSR (runner.ts)"]
HMRClient --> HMRContext : creates per module
BrowserClient --> HMRClient : uses with WebSocket transport
ModuleRunner --> HMRClient : uses with server transport
使用 Rolldown 进行依赖扫描
接下来进入第二套核心系统:依赖预构建。当 Vite 遇到类似 import React from 'react' 这样的裸导入时,它需要在 .vite/deps/ 中准备好预构建的 ESM 版本。第一步是扫描——找出你的应用实际用到了哪些依赖。
ScanEnvironment 是一个轻量级环境,它使用来自 rolldown/experimental 的 Rolldown scan() API 来爬取入口点。它只创建一个 PluginContainer,而不包含模块图或依赖优化器——仅保留解析 import 所需的最基本基础设施:
import { scan } from 'rolldown/experimental'
export class ScanEnvironment extends BaseEnvironment {
mode = 'scan' as const
// ...
}
扫描器从 HTML 入口点出发,沿静态 import 遍历模块图,记录所有遇到的裸导入。每个发现的依赖都会被记录为一个 ExportsData 条目,其中包含导出列表以及是否包含 ES module 语法。
flowchart TD
A["index.html"] -->|"scan"| B["src/main.ts"]
B --> C["import React from 'react'"]
B --> D["import { useState } from 'react'"]
B --> E["import dayjs from 'dayjs'"]
C --> F["Discovered: react"]
D --> F
E --> G["Discovered: dayjs"]
F --> H["ExportsData { hasModuleSyntax, exports }"]
G --> H
依赖打包与服务
发现所有依赖后,runOptimizeDeps() 会使用 Rolldown 对它们进行打包。每个依赖都会生成一个独立的 ESM 文件,存放在缓存目录(.vite/deps/)中,并无论原始格式如何都提供正确的具名导出。
optimizedDepsPlugin 负责拦截对这些预构建文件的请求:
export function optimizedDepsPlugin(): Plugin {
return {
name: 'vite:optimized-deps',
applyToEnvironment(environment) {
return !isDepOptimizationDisabled(environment.config.optimizeDeps)
},
resolveId(id) {
if (environment.depsOptimizer?.isOptimizedDepFile(id)) return id
},
async load(id) {
if (depsOptimizer?.isOptimizedDepFile(id)) {
// read from .vite/deps/ cache
}
},
}
}
applyToEnvironment hook 是 Environment API 的一个典型示例——依赖优化可以按环境单独启用或禁用。
运行时发现与重新优化
并非所有依赖都能通过静态分析发现。动态 import、条件式 require,以及 plugin 生成的 import 都可能在运行时引入新的依赖。createDepsOptimizer() 通过防抖重新优化策略来应对这种情况:
const debounceMs = 100
export function createDepsOptimizer(environment: DevEnvironment): DepsOptimizer {
// ...
const depsOptimizer: DepsOptimizer = {
init,
metadata,
registerMissingImport,
run: () => debouncedProcessing(0),
// ...
}
}
当 import 分析 plugin 遇到一个不在已优化依赖元数据中的裸导入时,它会调用 depsOptimizer.registerMissingImport(),将该依赖加入队列。在连续 100ms 没有新发现之后(即防抖周期结束),优化器会重新打包所有已知依赖,并触发页面刷新,让浏览器加载新版本的预构建文件。
holdUntilCrawlEnd 选项(默认开启)增加了另一层保障:在初始页面加载期间,所有发现操作会被批量处理,直到静态 import 图完全爬取完毕,从而避免启动阶段出现多轮重复优化。
sequenceDiagram
participant IA as importAnalysis plugin
participant DO as DepsOptimizer
participant RD as Rolldown
participant BR as Browser
IA->>DO: registerMissingImport('lodash-es')
Note over DO: Start 100ms debounce timer
IA->>DO: registerMissingImport('date-fns')
Note over DO: Reset debounce timer
Note over DO: 100ms elapsed, no new deps
DO->>RD: Re-bundle all deps
RD-->>DO: New optimized files
DO->>BR: full-reload (new dep hashes)
提示: 如果在开发过程中频繁看到"new dependencies optimized"的提示,可以将这些依赖添加到配置文件的
optimizeDeps.include中。这样可以彻底消除这些包在运行时被发现再触发刷新的循环。
下一步
至此,我们已经深入了解了让 Vite 开发模式高速运转的两套系统:用于即时反馈的 HMR,以及用于兼容 npm 包的依赖预构建。在最后一篇文章中,我们将探讨运行 vite build 时发生了什么——createBuilder() 如何通过 Rolldown 编排多环境生产构建,以及 Module Runner 如何在支持 HMR 的情况下实现 SSR 模块执行。