Vite内核解析-第7章 HMR 热更新

《Vite 设计与实现》完整目录

第7章 HMR 热更新

"热模块替换的核心挑战不在于替换模块本身,而在于精确地判断哪些模块需要替换、哪些模块只需要通知、哪些时候必须放弃治疗整页重载。"

:::tip 本章要点

  • 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 的意义。

让我们沿着这条管道,从文件系统事件的触发开始,逐一深入每个环节的实现细节。

sequenceDiagram participant FS as 文件系统 participant W as chokidar 监听器 participant S as handleHMRUpdate participant MG as ModuleGraph participant P as propagateUpdate participant WS as WebSocket participant C as 客户端 HMRClient FS->>W: 文件写入事件 W->>S: handleHMRUpdate(type, file, server) S->>S: 检查是否为配置文件/env 文件 S->>MG: getModulesByFile(file) S->>S: 调用 hotUpdate 插件钩子 S->>P: propagateUpdate(mod, boundaries) P->>P: 递归搜索 HMR 边界 P-->>S: 返回 boundaries / hasDeadEnd S->>MG: invalidateModule(mod) S->>WS: hot.send({ type: 'update', updates }) WS->>C: JSON 消息推送 C->>C: queueUpdate(update) C->>C: fetchUpdate -> import() 新模块 C->>C: 执行 accept 回调

让我们沿着这条管道,逐一深入每个环节。

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
}
graph TD START["变更的模块"] --> CHECK_SELF{isSelfAccepting?} CHECK_SELF -->|是| BOUNDARY1["记为边界<br/>boundary = self"] CHECK_SELF -->|否| CHECK_EXPORTS{acceptedHmrExports?} CHECK_EXPORTS -->|是| BOUNDARY2["记为部分边界"] CHECK_EXPORTS -->|否| CHECK_IMPORTERS{有 importers?} CHECK_IMPORTERS -->|否| DEAD_END["死胡同: 需要全页重载"] CHECK_IMPORTERS -->|是| LOOP["遍历每个 importer"] LOOP --> CHECK_ACCEPTED{importer 接受了<br/>当前模块?} CHECK_ACCEPTED -->|是| BOUNDARY3["记为边界<br/>boundary = importer"] CHECK_ACCEPTED -->|否| CHECK_BINDINGS{部分接受<br/>检查通过?} CHECK_BINDINGS -->|是| SKIP["跳过此 importer"] CHECK_BINDINGS -->|否| RECURSE["递归处理 importer"] RECURSE --> CHECK_SELF

算法的返回值 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,
      })),
    )
  }

注意每个更新条目包含了丰富的信息:

  • type:区分 'js-update' 和 'css-update',客户端对两者采用完全不同的处理策略。
  • path:接受更新的边界模块路径。
  • acceptedPath:实际变更的模块路径。当两者不同时,表示边界模块是通过 hot.accept('./dep') 接受了另一个模块的更新。
  • explicitImportRequired:某些模块(如 CSS)需要显式添加 ?import 查询参数才能作为 JS 模块导入。
  • isWithinCircularImport:标记是否处于循环导入中。
  • firstInvalidatedBy:追踪是哪个模块首先通过 import.meta.hot.invalidate() 触发的失效,用于检测循环失效。

7.5 WebSocket 通信层

HMR 更新消息需要从服务端实时推送到客户端。Vite 使用 WebSocket 协议来实现这种双向通信,而非 HTTP 长轮询或 Server-Sent Events(SSE)。选择 WebSocket 的原因很直接:它支持真正的双向通信(客户端也需要向服务端发送 import.meta.hot.invalidate() 等消息),且延迟极低。整个 WebSocket 通信层的实现位于 src/node/server/ws.ts 文件中。

7.5.1 服务端 WebSocket 架构

Vite 使用 ws 库创建 WebSocket 服务器。在大多数场景下,WebSocket 共享 HTTP 服务器的端口(通过 HTTP Upgrade 机制),但也支持配置独立端口以适应反向代理等复杂部署场景:

typescript 复制代码
export function createWebSocketServer(
  server: HttpServer | null,
  config: ResolvedConfig,
  httpsOptions?: HttpsServerOptions,
): WebSocketServer {
  const wss = new WebSocketServerRaw({ noServer: true })

  // 共享 HTTP 端口时,通过 upgrade 事件拦截 WebSocket 握手
  if (wsServer) {
    hmrServerWsListener = (req, socket, head) => {
      const protocol = req.headers['sec-websocket-protocol']!
      const parsedUrl = new URL(`http://example.com${req.url!}`)
      if (
        [HMR_HEADER, 'vite-ping'].includes(protocol) &&
        parsedUrl.pathname === hmrBase
      ) {
        handleUpgrade(req, socket, head, protocol === 'vite-ping')
      }
    }
    wsServer.on('upgrade', hmrServerWsListener)
  }

WebSocket 使用两种子协议:vite-hmr 用于正常的 HMR 通信,vite-ping 用于服务器重启后的连接探测。vite-ping 连接在握手成功后立即关闭(ws.close(1000)),不会被添加到 wss.clients 中。

7.5.2 Token 安全机制

为防止跨站 WebSocket 劫持攻击,Vite 实现了基于 token 的验证:

typescript 复制代码
function hasValidToken(config: ResolvedConfig, url: URL) {
  const token = url.searchParams.get('token')
  if (!token) return false

  try {
    const isValidToken = crypto.timingSafeEqual(
      Buffer.from(token),
      Buffer.from(config.webSocketToken),
    )
    return isValidToken
  } catch {}
  return false
}

Token 通过 URL 查询参数传递,使用 crypto.timingSafeEqual 进行时序安全的比较,防止时序攻击。Token 在每次服务器进程启动时重新生成。

对于非浏览器客户端(如编辑器插件、CLI 工具),Vite 允许无 token 连接------这些客户端不受同源策略限制,即使没有 WebSocket 也可以直接发送 HTTP 请求获取同等信息。

7.5.3 消息缓冲机制

当错误发生在客户端建立连接之前时,Vite 会缓冲消息:

typescript 复制代码
let bufferedMessage: ErrorPayload | FullReloadPayload | null = null

send(payload) {
  if (
    (payload.type === 'error' || payload.type === 'full-reload') &&
    !wss.clients.size
  ) {
    bufferedMessage = payload
    return
  }
  const stringified = JSON.stringify(payload)
  wss.clients.forEach((client) => {
    if (client.readyState === 1) {
      client.send(stringified)
    }
  })
}

这处理了一个重要的时序问题:当页面首次加载时如果某个模块编译失败,错误信息会在客户端 WebSocket 连接建立之前产生。缓冲机制确保客户端连接后能立即收到这个错误。

7.5.4 WebSocket 消息协议

Vite 的 HMR 通信使用 JSON 格式的消息,类型由 HotPayload 联合类型定义:

graph LR subgraph "服务端发送的消息类型" CONNECTED["connected<br/>连接确认"] UPDATE["update<br/>模块更新列表"] FULL_RELOAD["full-reload<br/>整页重载"] PRUNE["prune<br/>清理已移除模块"] ERROR["error<br/>编译错误"] CUSTOM["custom<br/>自定义事件"] end subgraph "客户端发送的消息类型" C_CUSTOM["custom<br/>自定义事件"] C_INVALIDATE["vite:invalidate<br/>请求失效"] end

7.6 客户端 HMR 实现

客户端 HMR 代码是 Vite 架构中唯一在浏览器环境中运行的核心代码。它作为 @vite/client 模块被自动注入到 HTML 页面中,负责建立 WebSocket 连接、处理服务端推送的更新消息、动态导入更新后的模块、以及管理错误覆盖层等功能。

7.6.1 连接建立

客户端代码位于 src/client/client.ts,它在页面加载时自动执行。WebSocket 的连接参数不是硬编码的,而是通过 Vite 的转换管道在编译时注入到代码中的全局常量:

typescript 复制代码
const socketProtocol =
  __HMR_PROTOCOL__ || (importMetaUrl.protocol === 'https:' ? 'wss' : 'ws')
const hmrPort = __HMR_PORT__
const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
  hmrPort || importMetaUrl.port
}${__HMR_BASE__}`

连接失败时有一个备用策略------如果端口是自动推断的(不是用户显式配置的),会尝试直接连接目标地址:

typescript 复制代码
transport = normalizeModuleRunnerTransport((() => {
  let wsTransport = createWebSocketModuleRunnerTransport({
    createConnection: () =>
      new WebSocket(
        `${socketProtocol}://${socketHost}?token=${wsToken}`,
        'vite-hmr',
      ),
    pingInterval: hmrTimeout,
  })

  return {
    async connect(handlers) {
      try {
        await wsTransport.connect(handlers)
      } catch (e) {
        if (!hmrPort) {
          // 备用连接:使用直接目标地址
          wsTransport = createWebSocketModuleRunnerTransport({
            createConnection: () =>
              new WebSocket(
                `${socketProtocol}://${directSocketHost}?token=${wsToken}`,
                'vite-hmr',
              ),
            pingInterval: hmrTimeout,
          })
          await wsTransport.connect(handlers)
        }
      }
    },
    // ...
  }
})())

7.6.2 消息处理状态机

客户端的 handleMessage 函数是一个消息分发器,根据消息类型执行不同的处理逻辑:

stateDiagram-v2 [*] --> WaitingForMessage WaitingForMessage --> Connected : type = connected WaitingForMessage --> ProcessUpdate : type = update WaitingForMessage --> FullReload : type = full-reload WaitingForMessage --> Prune : type = prune WaitingForMessage --> ShowError : type = error WaitingForMessage --> CustomEvent : type = custom ProcessUpdate --> CheckFirstUpdate : 是首次更新? CheckFirstUpdate --> FullReload : 已有错误覆盖层 CheckFirstUpdate --> ClearOverlay : 清除覆盖层 ClearOverlay --> ProcessEachUpdate : 遍历 updates ProcessEachUpdate --> JSUpdate : js-update ProcessEachUpdate --> CSSUpdate : css-update JSUpdate --> QueueUpdate : hmrClient.queueUpdate CSSUpdate --> ReplaceLinkTag : 创建新 link 标签 CustomEvent --> CheckDisconnect : vite:ws:disconnect? CheckDisconnect --> PollReconnect : 轮询服务器重启 state ProcessEachUpdate { [*] --> DispatchByType } WaitingForMessage --> WaitingForMessage : type = ping (noop)

对于 update 消息,有一个重要的边界情况处理:

typescript 复制代码
case 'update':
  if (hasDocument) {
    if (isFirstUpdate && hasErrorOverlay()) {
      location.reload()
      return
    } else {
      if (enableOverlay) {
        clearErrorOverlay()
      }
      isFirstUpdate = false
    }
  }

如果这是页面加载后的第一次更新,并且页面上已经显示了错误覆盖层(说明初始加载时就有编译错误),那么普通的 HMR 更新无法修复这种状态------因为顶层的模块脚本可能根本没有成功执行。此时唯一的选择是整页重载。

7.6.3 CSS 热更新的特殊处理

CSS 通过 <link> 标签引入时,更新策略与 JS 完全不同:

typescript 复制代码
// css-update
const { path, timestamp } = update
const searchUrl = cleanUrl(path)
const el = Array.from(
  document.querySelectorAll<HTMLLinkElement>('link'),
).find(
  (e) => !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl),
)

if (!el) return

const newPath = `${base}${searchUrl.slice(1)}${
  searchUrl.includes('?') ? '&' : '?'
}t=${timestamp}`

return new Promise((resolve) => {
  const newLinkTag = el.cloneNode() as HTMLLinkElement
  newLinkTag.href = new URL(newPath, el.href).href
  const removeOldEl = () => {
    el.remove()
    console.debug(`[vite] css hot updated: ${searchUrl}`)
    resolve()
  }
  newLinkTag.addEventListener('load', removeOldEl)
  newLinkTag.addEventListener('error', removeOldEl)
  outdatedLinkTags.add(el)
  el.after(newLinkTag)
})

这个实现有几个精心考虑的细节:

  1. 克隆替换而非直接修改 href :如果直接修改现有 <link> 的 href,浏览器在加载新样式表期间会移除旧样式,导致无样式内容闪烁。通过先插入新 link、等加载完成后再移除旧 link,实现了无闪烁的平滑过渡。
  2. outdatedLinkTags 去重:使用 WeakSet 标记已经被标记为过时的 link 元素,防止快速连续编辑时同一个 link 元素被多次处理。
  3. error 事件也触发清理:即使新样式表加载失败,也要移除旧元素,否则页面上会出现两个 link 标签指向相似但不完全相同的 URL。

对于通过 JS 导入的 CSS(import './style.css'),Vite 使用 <style> 标签注入,这种情况的更新由 updateStyle 和 removeStyle 函数处理:

typescript 复制代码
export function updateStyle(id: string, content: string): void {
  if (linkSheetsMap.has(id)) return

  let style = sheetsMap.get(id)
  if (!style) {
    style = document.createElement('style')
    style.setAttribute('type', 'text/css')
    style.setAttribute('data-vite-dev-id', id)
    style.textContent = content
    if (cspNonce) {
      style.setAttribute('nonce', cspNonce)
    }

    if (!lastInsertedStyle) {
      document.head.appendChild(style)
      setTimeout(() => { lastInsertedStyle = undefined }, 0)
    } else {
      lastInsertedStyle.insertAdjacentElement('afterend', style)
    }
    lastInsertedStyle = style
  } else {
    style.textContent = content
  }
  sheetsMap.set(id, style)
}

lastInsertedStyle 变量确保了 CSS 的插入顺序与构建后的单文件顺序一致。异步重置(setTimeout(() => { lastInsertedStyle = undefined }, 0))使得不同代码分割 chunk 的 CSS 不会互相影响插入位置。

7.7 import.meta.hot API 的实现

import.meta.hot 是 Vite HMR 体系暴露给模块开发者的公共 API 接口。通过这个 API,模块可以声明自己是否能接受热更新、注册清理回调、在更新间持久化状态、以及与其他模块通信。这个 API 的设计遵循了 ESM HMR 规范的约定,并在此基础上增加了 Vite 特有的扩展。它的核心实现位于 src/shared/hmr.ts 文件中,这个文件被客户端和服务端共享,体现了 Vite 在代码复用上的设计理念。

7.7.1 HMRContext:每个模块的 HMR 上下文

每个通过 Vite 处理的模块在加载时都会被注入一个 import.meta.hot 对象。这个对象实际上是一个 HMRContext 类的实例,每个模块拥有自己独立的实例,通过 ownerPath(模块路径)来区分身份:

typescript 复制代码
export function createHotContext(ownerPath: string): ViteHotContext {
  return new HMRContext(hmrClient, ownerPath)
}

HMRContext 的构造函数执行了重要的清理工作------当一个模块被热更新时,会重新创建其 HMRContext,此时需要清理旧的回调和事件监听器:

typescript 复制代码
export class HMRContext implements ViteHotContext {
  private newListeners: CustomListenersMap

  constructor(
    private hmrClient: HMRClient,
    private ownerPath: string,
  ) {
    if (!hmrClient.dataMap.has(ownerPath)) {
      hmrClient.dataMap.set(ownerPath, {})
    }

    // 清理旧的 accept 回调
    const mod = hmrClient.hotModulesMap.get(ownerPath)
    if (mod) {
      mod.callbacks = []
    }

    // 清理旧的自定义事件监听器
    const staleListeners = hmrClient.ctxToListenersMap.get(ownerPath)
    if (staleListeners) {
      for (const [event, staleFns] of staleListeners) {
        const listeners = hmrClient.customListenersMap.get(event)
        if (listeners) {
          hmrClient.customListenersMap.set(
            event,
            listeners.filter((l) => !staleFns.includes(l)),
          )
        }
      }
    }

    this.newListeners = new Map()
    hmrClient.ctxToListenersMap.set(ownerPath, this.newListeners)
  }

7.7.2 accept:声明接受能力

accept 方法支持三种调用形式,对应三种 HMR 接受模式:

typescript 复制代码
accept(deps?: any, callback?: any): void {
  if (typeof deps === 'function' || !deps) {
    // 自接受:hot.accept(() => {})
    this.acceptDeps([this.ownerPath], ([mod]) => deps?.(mod))
  } else if (typeof deps === 'string') {
    // 接受单个依赖:hot.accept('./dep.js', (mod) => {})
    this.acceptDeps([deps], ([mod]) => callback?.(mod))
  } else if (Array.isArray(deps)) {
    // 接受多个依赖:hot.accept(['./a.js', './b.js'], ([a, b]) => {})
    this.acceptDeps(deps, callback)
  } else {
    throw new Error(`invalid hot.accept() usage.`)
  }
}

private acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}): void {
  const mod: HotModule = this.hmrClient.hotModulesMap.get(this.ownerPath) || {
    id: this.ownerPath,
    callbacks: [],
  }
  mod.callbacks.push({ deps, fn: callback })
  this.hmrClient.hotModulesMap.set(this.ownerPath, mod)
}

所有形式最终都归结为在 hotModulesMap 中注册一个回调。当 HMR 更新到达时,fetchUpdate 方法会查找匹配的回调并执行。

7.7.3 dispose 和 prune:清理副作用

typescript 复制代码
dispose(cb: (data: any) => void): void {
  this.hmrClient.disposeMap.set(this.ownerPath, cb)
}

prune(cb: (data: any) => void): void {
  this.hmrClient.pruneMap.set(this.ownerPath, cb)
}

dispose 回调在模块即将被替换时执行,用于清理定时器、事件监听器等副作用。prune 回调在模块被完全从页面中移除时执行(即不再被任何模块导入)。两者的区别在于:dispose 发生在 HMR 更新链中,之后模块会被重新加载;prune 发生在模块被彻底淘汰时。

7.7.4 invalidate:主动触发重新加载

typescript 复制代码
invalidate(message: string): void {
  const firstInvalidatedBy =
    this.hmrClient.currentFirstInvalidatedBy ?? this.ownerPath
  this.hmrClient.notifyListeners('vite:invalidate', {
    path: this.ownerPath,
    message,
    firstInvalidatedBy,
  })
  this.send('vite:invalidate', {
    path: this.ownerPath,
    message,
    firstInvalidatedBy,
  })
}

invalidate 允许模块在 accept 回调执行后发现自己无法正确处理更新,请求重新从服务端获取。firstInvalidatedBy 字段追踪了最初触发失效的模块路径,用于检测循环失效------如果 A invalidate 了自己,导致 B 重新评估,B 又 invalidate 了自己导致 A 需要重新评估,这种情况下服务端会检测到循环并触发全页重载。

7.7.5 data:跨更新持久化数据

typescript 复制代码
get data(): any {
  return this.hmrClient.dataMap.get(this.ownerPath)
}

import.meta.hot.data 提供了一个在模块更新前后持久化数据的机制。典型用法是在 dispose 回调中保存状态,在新模块初始化时从 data 中恢复:

typescript 复制代码
// 使用示例
if (import.meta.hot) {
  import.meta.hot.dispose((data) => {
    data.count = currentCount  // 保存当前状态
  })
  if (import.meta.hot.data.count) {
    currentCount = import.meta.hot.data.count  // 恢复状态
  }
}

7.8 HMRClient:更新的执行引擎

HMRClient 是客户端 HMR 系统的中央协调器,也定义在 src/shared/hmr.ts 中。它管理着所有与 HMR 相关的运行时状态:已注册的热模块映射(hotModulesMap)、清理回调(disposeMap)、裁剪回调(pruneMap)、持久化数据(dataMap)和自定义事件监听器(customListenersMap)。可以说,HMRContext 是面向单个模块的控制面板,而 HMRClient 是面向整个应用的调度中心。

7.8.1 更新队列

typescript 复制代码
private updateQueue: Promise<(() => void) | undefined>[] = []
private pendingUpdateQueue = false

public async queueUpdate(payload: Update): Promise<void> {
  this.updateQueue.push(this.fetchUpdate(payload))
  if (!this.pendingUpdateQueue) {
    this.pendingUpdateQueue = true
    await Promise.resolve()
    this.pendingUpdateQueue = false
    const loading = [...this.updateQueue]
    this.updateQueue = []
    ;(await Promise.all(loading)).forEach((fn) => fn && fn())
  }
}

这个队列机制解决了一个微妙的问题:一次文件保存可能触发多个模块的更新(例如一个 .vue 文件的 script 和 style 块)。这些更新通过同一个 WebSocket 消息发送,但 map 处理中每个更新是独立的 Promise。通过 await Promise.resolve() 微任务延迟,将同一事件循环中入队的所有更新收集起来,然后并行获取但按顺序执行回调。

7.8.2 模块获取与回调执行

typescript 复制代码
private async fetchUpdate(update: Update): Promise<(() => void) | undefined> {
  const { path, acceptedPath, firstInvalidatedBy } = update
  const mod = this.hotModulesMap.get(path)
  if (!mod) return

  let fetchedModule: ModuleNamespace | undefined
  const isSelfUpdate = path === acceptedPath

  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
    deps.includes(acceptedPath),
  )

  if (isSelfUpdate || qualifiedCallbacks.length > 0) {
    const disposer = this.disposeMap.get(acceptedPath)
    if (disposer) await disposer(this.dataMap.get(acceptedPath))
    try {
      fetchedModule = await this.importUpdatedModule(update)
    } catch (e) {
      this.warnFailedUpdate(e, acceptedPath)
    }
  }

  return () => {
    try {
      this.currentFirstInvalidatedBy = firstInvalidatedBy
      for (const { deps, fn } of qualifiedCallbacks) {
        fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
      }
    } finally {
      this.currentFirstInvalidatedBy = undefined
    }
  }
}

fetchUpdate 将模块获取(异步,涉及网络请求)与回调执行(同步)分离。importUpdatedModule 通过动态 import() 加载带有新时间戳的模块 URL,浏览器会发起新的请求获取更新后的代码。回调函数被包装在一个闭包中返回,由 queueUpdate 统一调度执行。

7.8.3 模块导入策略

客户端支持两种模块导入模式------普通模式和打包模式(bundledDev):

typescript 复制代码
const hmrClient = new HMRClient(
  { error: (err) => console.error('[vite]', err), debug: (...msg) => console.debug('[vite]', ...msg) },
  transport,
  isBundleMode
    ? async function importUpdatedModule({ url, acceptedPath, isWithinCircularImport }) {
        const importPromise = import(base + url!).then(() =>
          globalThis.__rolldown_runtime__.loadExports(acceptedPath),
        )
        if (isWithinCircularImport) {
          importPromise.catch(() => {
            console.info(`[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page...`)
            pageReload()
          })
        }
        return await importPromise
      }
    : async function importUpdatedModule({ acceptedPath, timestamp, explicitImportRequired, isWithinCircularImport }) {
        const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
        const importPromise = import(
          base + acceptedPathWithoutQuery.slice(1) +
            `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}`
        )
        if (isWithinCircularImport) {
          importPromise.catch(() => { pageReload() })
        }
        return await importPromise
      },
)

在普通模式下,通过给 URL 添加 ?t=timestamp 参数来绕过浏览器缓存。在打包模式(使用 Rolldown 运行时)下,通过 __rolldown_runtime__.loadExports 获取模块的最新导出。两种模式都处理了循环导入的回退------如果模块导入失败且处于循环导入链中,则降级为整页重载。

7.9 服务器重启后的连接恢复

开发过程中,当 vite.config.ts 或 .env 文件被修改时,Vite 会自动重启开发服务器。重启意味着旧的 WebSocket 连接会被断开,客户端需要感知服务器何时重新就绪,然后自动刷新页面以加载新的配置。这个看似简单的需求隐藏着几个技术挑战:服务器重启期间无法建立连接、页面可能处于非活跃标签页(浏览器会限制定时器)、以及需要区分"服务器正在重启"和"网络故障"两种情况。Vite 通过 waitForSuccessfulPing 函数优雅地解决了这些问题:

typescript 复制代码
function waitForSuccessfulPing(socketUrl: string) {
  if (typeof SharedWorker === 'undefined') {
    // 不支持 SharedWorker 的环境,直接在主线程轮询
    return waitForSuccessfulPingInternal(socketUrl, visibilityManager)
  }

  // 使用 SharedWorker 轮询,即使页面不可见也能工作
  const blob = new Blob([
    '"use strict";',
    `const waitForSuccessfulPingInternal = ${waitForSuccessfulPingInternal.toString()};`,
    `const fn = ${pingWorkerContentMain.toString()};`,
    `fn(${JSON.stringify(socketUrl)})`,
  ], { type: 'application/javascript' })
  const sharedWorker = new SharedWorker(URL.createObjectURL(blob))
  // ...
}

这里有一个巧妙的设计:使用 SharedWorker 而非主线程来执行轮询。原因是当用户切换到其他标签页时,浏览器可能会限制非活跃标签的定时器和网络请求。SharedWorker 不受这些限制,可以持续轮询直到服务器重启完成。Worker 的代码通过 Blob URL 内联创建,避免了额外的文件请求(此时服务器可能尚未就绪)。

轮询逻辑还集成了页面可见性管理:

typescript 复制代码
async function waitForSuccessfulPingInternal(
  socketUrl: string,
  visibilityManager: VisibilityManager,
  ms = 1000,
) {
  while (true) {
    if (visibilityManager.currentState === 'visible') {
      if (await ping()) break
      await wait(ms)
    } else {
      await waitForWindowShow(visibilityManager)
    }
  }
}

当页面不可见时暂停轮询,避免不必要的网络开销。用户切回页面时立即恢复。

7.9.1 连接恢复时序图

下面用时序图展示客户端从感知到连接断开、到轮询服务器重启、到最终页面刷新的完整过程:

sequenceDiagram participant C as 客户端主线程 participant SW as SharedWorker participant S as Vite 服务器 Note over S: 配置文件变更,服务器开始重启 S--xC: WebSocket 断开 C->>C: 触发 vite:ws:disconnect C->>SW: 创建 SharedWorker 开始轮询 SW->>S: WebSocket ping (vite-ping) S--xSW: 连接失败 SW->>SW: 等待 1000ms SW->>S: WebSocket ping (vite-ping) S--xSW: 连接失败 Note over S: 服务器重启完成 SW->>S: WebSocket ping (vite-ping) S->>SW: 连接成功 SW->>C: postMessage({ type: 'success' }) C->>C: location.reload()

7.10 孤立模块的清理

在模块热更新过程中,代码变更可能导致某些模块不再被任何其他模块导入。这些"孤立模块"虽然不再参与应用的运行逻辑,但它们此前可能注入了样式表、注册了全局事件监听器或启动了定时器等副作用。如果不清理这些副作用,页面会逐渐积累无用的样式和事件处理器,导致视觉异常和内存泄漏。服务端通过 handlePrunedModules 函数通知客户端清理这些孤立模块:

typescript 复制代码
export function handlePrunedModules(
  mods: Set<EnvironmentModuleNode>,
  { hot }: DevEnvironment,
): void {
  const t = monotonicDateNow()
  mods.forEach((mod) => {
    mod.lastHMRTimestamp = t
    mod.lastHMRInvalidationReceived = false
    debugHmr?.(`[dispose] ${colors.dim(mod.file)}`)
  })
  hot.send({
    type: 'prune',
    paths: [...mods].map((m) => m.url),
  })
}

服务端发送 prune 消息,客户端收到后执行对应模块的 dispose 和 prune 回调:

typescript 复制代码
public async prunePaths(paths: string[]): Promise<void> {
  await Promise.all(
    paths.map((path) => {
      const disposer = this.disposeMap.get(path)
      if (disposer) return disposer(this.dataMap.get(path))
    }),
  )
  await Promise.all(
    paths.map((path) => {
      const fn = this.pruneMap.get(path)
      if (fn) return fn(this.dataMap.get(path))
    }),
  )
}

先执行 dispose(模块级别的清理),再执行 prune(永久移除的清理)。两个阶段的分离允许模块在不同的场景下使用不同的清理策略------dispose 用于"我即将被替换为新版本"的场景,可能需要保存状态以供新版本恢复;prune 用于"我被永久移除"的场景,需要彻底清理所有痕迹。

典型用例包括:一个 CSS 模块被从 JS 导入中移除后,其 prune 回调会将对应的 <style> 标签从 DOM 中移除;一个注册了全局键盘事件监听器的模块被移除后,其 dispose 回调会调用 removeEventListener 清理事件监听器;一个启动了 setInterval 的动画模块被移除后,其 dispose 回调会调用 clearInterval 停止定时器。

7.11 设计决策与权衡

为什么 HMR 边界由模块自身声明而非自动推断?

理论上,框架可以自动为所有模块添加 HMR 接受逻辑,就像某些实验性的全自动 HMR 方案尝试做的那样。但 Vite 选择将 HMR 边界的声明权交给模块自身(通过 import.meta.hot.accept 调用)或框架插件(如 @vitejs/plugin-vue 和 @vitejs/plugin-react 在编译时自动注入)。这一设计决策背后的考虑是安全性:只有模块的作者或了解模块完整语义的框架才能正确判断一个模块是否能在运行时被安全地替换。

一个有全局副作用的模块(例如修改了 window 对象的属性、向 DOM 根节点注册了事件委托、或者向第三方库注册了插件)如果被简单替换,旧版本的副作用不会自动清除,新版本的副作用会重复执行。这可能导致事件被多次触发、样式被重复注入、状态管理出现幽灵数据等难以排查的 bug。通过要求模块显式声明接受能力,并配合 dispose 回调来清理副作用,Vite 将正确性的责任明确地交给了最了解模块行为的开发者或框架。

为什么整页重载使用了 debounce?

typescript 复制代码
const debounceReload = (time: number) => {
  let timer: ReturnType<typeof setTimeout> | null
  return () => {
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
    timer = setTimeout(() => {
      location.reload()
    }, time)
  }
}
const pageReload = debounceReload(20)

当多个文件同时变更时(如 git checkout 切换分支),每个文件都可能触发 full-reload。如果不做 debounce,浏览器会在很短的时间内连续重载多次。20ms 的延迟足以合并这些请求为一次重载。

为什么错误处理不阻断后续更新?

在 handleMessage 中,Promise.all 处理所有更新。即使某个更新失败,其他更新仍然会尝试执行。这是因为一次文件保存可能同时触发 JS 和 CSS 的更新------即使 JS 更新失败,CSS 更新仍然应该被应用。

为什么文件读取需要重试?

typescript 复制代码
async function readModifiedFile(file: string): Promise<string> {
  const content = await fsp.readFile(file, 'utf-8')
  if (!content) {
    const mtime = (await fsp.stat(file)).mtimeMs
    for (let n = 0; n < 10; n++) {
      await new Promise((r) => setTimeout(r, 10))
      const newMtime = (await fsp.stat(file)).mtimeMs
      if (newMtime !== mtime) break
    }
    return await fsp.readFile(file, 'utf-8')
  }
  return content
}

文件系统事件有时会在文件写入完成之前触发。这是操作系统文件系统通知机制的固有特性------通知是在"有写入操作发生"时触发的,而非"写入操作完成"时。某些编辑器(如 Vim 和 Emacs)使用"先清空再写入"的策略保存文件,或者通过"写入临时文件然后重命名"的原子操作策略,这些都可能导致 Vite 在收到文件变更通知后读取到空文件或中间状态的文件。重试逻辑通过比较文件的最后修改时间(mtime)来判断写入是否已经完成------如果 mtime 发生了变化,说明有新的写入操作,可以尝试重新读取。最多重试 10 次,每次间隔 10 毫秒,总超时为 100 毫秒。这个超时时间足以覆盖绝大多数编辑器的保存延迟。

这个问题最初在 GitHub Issue #610 中被报告,是 Vite 早期遇到的一个真实的用户体验问题。该修复虽然简单,但对 HMR 的可靠性有重要意义------如果读取到空文件,转换结果将是错误的,客户端会收到一个空模块或编译错误,破坏了原本无缝的热更新体验。

7.12 小结

回顾本章的全部内容,HMR 热模块替换是 Vite 提供卓越开发体验的核心机制,也是前端开发工具链中技术含量最高的功能之一。从文件系统事件到浏览器中的模块替换,这条管道由服务端的 handleHMRUpdate、propagateUpdate、updateModules 和客户端的 HMRClient、HMRContext 协同完成。

propagateUpdate 算法沿着模块图的 importers 链向上搜索 HMR 边界,遇到 isSelfAccepting 或 acceptedHmrDeps 则停止,到达无出口的根模块则触发整页重载。客户端通过更新队列确保异步获取和同步执行的正确顺序,CSS 热更新通过 link 标签替换实现无闪烁过渡。

WebSocket 通信层实现了 token 验证、消息缓冲、连接重试和 SharedWorker 轮询等机制,确保了 HMR 在各种边界条件下的可靠性。import.meta.hot API 通过 HMRContext 类为每个模块提供了声明式的 HMR 控制能力,而框架插件可以通过 hotUpdate 钩子定制 HMR 行为。

整个 HMR 系统的设计哲学是"精确到模块级别的最小化更新"------尽可能只替换真正变更的模块,尽可能避免不必要的重载,但在不确定时果断回退到整页刷新,永远保证开发者看到的是正确的结果。

从系统工程的角度来看,Vite 的 HMR 实现是一个典型的"乐观策略与悲观回退"的混合设计。在正常路径上,它乐观地假设模块可以被安全替换,只有当遇到无法处理的情况(死胡同、循环失效、首次加载错误)时才回退到整页重载。这种策略在实践中表现良好------据 Vite 团队的统计,在使用 Vue 或 React 框架的典型项目中,超过 95% 的文件变更可以通过 HMR 完成,只有极少数边缘情况需要整页重载。

HMR 系统的另一个重要设计原则是"关注点分离"------服务端负责确定更新范围和传播路径,客户端负责实际的模块替换和回调执行,WebSocket 作为两者之间的通信管道只负责消息的可靠传递。这种分离使得每个部分都可以独立演进:例如,将来如果需要支持新的模块运行时(如 Rolldown 的打包模式),只需要替换客户端的模块导入策略,而无需修改服务端的传播逻辑。这正是 Vite 在 importUpdatedModule 回调参数中为打包开发模式和传统的非打包模式分别提供两套独立实现的设计意图。

相关推荐
yuanlaile8 小时前
大模型应用层开发实战:从 Prompt 工程到 Dify、私有 RAG、Agent 完整落地总结
prompt·agent·dify·大模型应用开发·llm 应用·prompt 工程
goehou10 小时前
Prompt 注入实战:直接注入、间接注入和四道防线(附测试样例)
安全·ai·llm·agent·prompt注入
杨杨杨大侠11 小时前
Agent 的上下文和记忆怎么管?从 Harness 到 AgentScope 实现
人工智能·agent
小七-七牛开发者13 小时前
Personal Agent 真能越用越懂你吗?AdaptArena 揭示个性化 Web Agent 的失效链路
ai·agent·web agent·personal agent·adaptarena·个性化智能体
独码侠13 小时前
第04篇·Codex Harness 审批×权限边界:4 档策略怎么选,密钥与仓库永远碰不到
ai·agent·codex
for_ever_love__14 小时前
LangChain 降级、重试与超时:让服务在模型抽风时还能用
python·自然语言处理·langchain·agent·超时·重试·with_retry
方方洛15 小时前
ai-agent教程-05-上下文工程
人工智能·llm·agent
用户7217465882615 小时前
三条热词通道的机制拆解:bl speech 的参数面、生效模型范围与错配读数
llm·agent·ai编程
森码15 小时前
一条 Prompt 在 Harness 中经过了谁?
agent
方方洛16 小时前
ai-agent教程-06-主流框架
人工智能·llm·agent