Read OSS

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 中的 HMRClientHMRContext是统一的抽象层。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 模块执行。