Read OSS

热模块替换与依赖预构建

高级

前置知识

  • 第 1 篇:架构与代码库导航
  • 第 2 篇:配置与环境系统
  • 第 3 篇:开发服务器与转换流水线
  • 第 4 篇:插件系统与核心插件

热模块替换与依赖预构建

Vite 开发体验的核心由两大特性支撑:即时热模块替换(HMR)与透明的依赖预构建。HMR 让你在不刷新页面的情况下实时看到代码改动的效果;预构建则将 node_modules 中成千上万个 CommonJS 文件合并成浏览器可高效加载的单个 ESM bundle。

这两套系统的复杂程度远超表面所见。HMR 需要遍历模块图来确定更新边界、处理循环导入,并通过 WebSocket 在服务端与客户端之间协调通信。预构建则需要发现依赖、用 Rolldown 进行打包,并无缝地提供服务——甚至还要在运行时动态发现新依赖。

服务端 HMR:从文件变更到更新载荷

保存文件时,chokidar 检测到变更,Vite 的文件监听器随即触发 handleHMRUpdate。这个函数统筹协调了整个服务端 HMR 流程:

sequenceDiagram
    participant FS as File System
    participant Chokidar
    participant handleHMRUpdate
    participant PluginHooks
    participant updateModules
    participant WebSocket

    FS->>Chokidar: File changed
    Chokidar->>handleHMRUpdate: type, file, server
    handleHMRUpdate->>handleHMRUpdate: Is config/env file? → restart server
    handleHMRUpdate->>handleHMRUpdate: Is Vite client code? → full reload
    handleHMRUpdate->>handleHMRUpdate: Look up modules in each environment's moduleGraph
    handleHMRUpdate->>PluginHooks: Run hotUpdate hooks (plugins can filter modules)
    PluginHooks-->>handleHMRUpdate: Filtered module list
    handleHMRUpdate->>updateModules: propagate updates per environment
    updateModules->>updateModules: Find HMR boundaries via graph traversal
    updateModules->>WebSocket: Send update payload

第 391–416 行的前置判断负责处理配置文件和 env 文件的变更——此时会重启整个服务器。如果变更的是 Vite 自身 client 目录下的文件,则向所有环境发送全量刷新指令。

对于普通模块的变更,函数会遍历所有环境,通过 moduleGraph.getModulesByFile() 查找受影响的模块,并运行 hotUpdate plugin hook。这些 hook 允许框架(如 Vue 的 SFC 编译器)缩小更新范围——例如,当只有 <template> 块发生变化时,仅重新渲染模板部分。

hook 的执行分两轮进行:先处理 client 环境(第 481–559 行),再处理其他所有环境。这样设计是为了向后兼容已废弃的 handleHotUpdate hook,该 hook 仍然操作混合模块图。

HMR 边界传播

updateModules 函数负责执行核心的图遍历逻辑,以确定 HMR 边界:

graph TD
    CHANGED["Changed Module<br/>(src/utils.ts)"] --> IMP1["Importer A<br/>(src/App.tsx)"]
    CHANGED --> IMP2["Importer B<br/>(src/Header.tsx)"]
    IMP1 -->|"self-accepting ✓"| BOUNDARY1["HMR Boundary<br/>Update App.tsx"]
    IMP2 --> IMP3["Importer C<br/>(src/main.tsx)"]
    IMP3 -->|"not accepting"| DEADEND["Dead End → Full Reload"]

    style BOUNDARY1 fill:#4ade80
    style DEADEND fill:#f87171

对于每个发生变更的模块,propagateUpdate 函数会沿着导入链向上查找自接受模块——即调用了 import.meta.hot.accept() 的模块,这些模块就是 HMR 边界。遍历过程中会追踪以下信息:

  • PropagationBoundary:包含边界模块、触发接受的模块,以及更新路径中是否存在循环导入。
  • 循环导入检测:如果遍历过程中遇到已访问过的模块,会在边界上设置 isWithinCircularImport: true,客户端收到后会捕获导入错误并回退到全量刷新。
  • 死路处理:如果遍历到达模块图的根节点仍未找到接受边界,则触发全页面刷新。

最终生成的 Update 载荷包含模块路径、被接受的路径以及时间戳。CSS 更新有独立的 type: 'css-update' 载荷,用于触发样式表替换而非重新执行模块。

浏览器端 HMR 客户端

如第 1 篇所述,浏览器端的 HMR client 位于 src/client/client.ts,其中声明了一组编译期常量,在构建时由 clientInjectionsPlugin 替换为实际值:

declare const __BASE__: string
declare const __SERVER_HOST__: string
declare const __HMR_PROTOCOL__: string | null
declare const __HMR_TIMEOUT__: number
declare const __HMR_ENABLE_OVERLAY__: boolean
declare const __WS_TOKEN__: string
declare const __BUNDLED_DEV__: boolean

客户端创建一个 HMRClient 实例(来自共享模块),并将其连接到 WebSocket 传输层。第 204–336 行handleMessage 函数按载荷类型分别处理:

  • update:对于 JS 更新,通过添加缓存破坏时间戳查询参数重新导入模块;对于 CSS 更新,克隆并替换 <link> 标签,避免出现无样式内容的闪烁。
  • full-reload:防抖处理(20ms)的页面刷新,用于批量处理快速连续的变更。
  • prune:移除不再被导入的模块。
  • error:展示包含调用栈和代码帧的错误遮罩层。

有一个细节值得注意:客户端对 __BUNDLED_DEV__ 模式的处理有所不同。在 bundle 模式下,更新的模块通过 Rolldown 的运行时(globalThis.__rolldown_runtime__.loadExports(acceptedPath))加载,而非原生 ESM import()

提示: 客户端的 waitForSuccessfulPing 函数使用 SharedWorker 来协调多个标签页之间的重连行为。这意味着重启开发服务器后,所有打开的标签页会同时重新连接,而不是各自独立轮询。

使用 Rolldown 扫描依赖

在开发服务器处理任何请求之前,Vite 会先对依赖进行预构建。扫描器位于 optimizer/scan.ts,使用 Rolldown 原生的 scan() 函数:

flowchart TD
    A["scanImports()"] --> B["Create ScanEnvironment"]
    B --> C["rolldown/experimental scan()"]
    C --> D["rolldownDepPlugin filters imports"]
    D --> E["Discovered bare imports<br/>(react, vue, lodash-es, ...)"]
    E --> F["Return deps map"]

ScanEnvironment 是一个轻量环境(mode: 'scan'),它会创建自己的 plugin 容器,但不需要模块图或热更新通道。devToScanEnvironment 辅助函数为扫描过程创建了一个受限的 DevEnvironment 视图——只暴露配置和名称,屏蔽对开发专属功能的访问。

扫描结果是一张从裸导入说明符到其解析文件路径的映射表,该映射表随后作为优化步骤的输入。

DepsOptimizer 生命周期

createDepsOptimizer 函数创建一个 DepsOptimizer 来管理完整的生命周期:

flowchart TD
    INIT["init()"] --> CACHE{"Cached metadata exists?"}
    CACHE -->|Yes| LOAD["Load cached optimized deps"]
    CACHE -->|No| SCAN["scanImports() → discover bare imports"]
    SCAN --> BUNDLE["runOptimizeDeps() → Rolldown bundle"]
    BUNDLE --> SERVE["Serve pre-bundled from .vite/deps/"]
    SERVE --> RUNTIME{"New dep discovered at runtime?"}
    RUNTIME -->|Yes| DEBOUNCE["Debounce 100ms"]
    DEBOUNCE --> REBUNDLE["Re-bundle with new dep"]
    REBUNDLE --> RELOAD["Full page reload"]
    RUNTIME -->|No| SERVE

优化器使用 Rolldown API 中的 rolldown() 将每个依赖打包成单个 ESM 文件。rolldownDepPlugin 负责处理打包过程,解析各个入口点并标记外部依赖。

运行时发现机制是其中最巧妙的部分。当 import 分析 plugin 遇到不在已优化依赖中的裸导入时,会将其注册到优化器。经过 100ms 的防抖处理(用于批量收集多个发现结果)后,优化器重新打包并触发全页面刷新。holdUntilCrawlEnd 选项会将此过程推迟到初始静态导入爬取完成之后,从而减少启动阶段不必要的重复构建。

实验性功能:全量打包开发模式

Vite 8 引入了实验性的 FullBundleDevEnvironment,利用 Rolldown 的 DevEngine API 在开发阶段直接提供全量打包输出。通过 --experimentalBundle 参数启用。

graph TD
    subgraph "Standard Dev Mode"
        REQ1["Browser request"] --> TRANSFORM["Per-module transform"]
        TRANSFORM --> SERVE1["Serve individual module"]
    end
    subgraph "Full Bundle Dev Mode"
        CHANGE["File change"] --> ENGINE["Rolldown DevEngine"]
        ENGINE --> MEMORY["MemoryFiles (lazy eval)"]
        REQ2["Browser request"] --> MIDDLEWARE["memoryFilesMiddleware"]
        MIDDLEWARE --> MEMORY
        MEMORY --> SERVE2["Serve bundled output"]
    end

MemoryFiles 类将打包输出存储在内存中,并采用惰性内容求值——条目可以以 thunk 形式存储,仅在被访问时才真正生成内容。这样就避免了为从未被请求的文件计算 etag 和编码的开销。

这种模式会完全禁用依赖优化器(传入 disableDepsOptimizer: true),因为所有打包工作都由 Rolldown 接管。HMR 也改为通过 Rolldown 原生的 HMR API 工作,而非 Vite 基于模块图的方案。

目前该功能仅限于 client 环境,但它已经指明了 Vite 的未来方向:以 Rolldown 为基础的全量打包开发模式,彻底消弭开发与生产构建之间的差距。

下一篇预告

至此,我们已经全面了解了 Vite 开发体验的两大支柱——用于快速迭代的 HMR,以及用于 npm 依赖服务的预构建。在最后一篇文章中,我们将深入探讨生产构建:buildEnvironment 如何解析 Rolldown 选项、ViteBuilder.buildApp() 如何编排多环境构建,以及支持 HMR 和跨环境传输的服务端代码执行系统 ModuleRunner