Vite 设计与实现

第7章 HMR 热更新

作者 杨艺韬 · 9,207 字

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

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

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 管道:

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 还包含了配置文件中通过 requireimport 引入的辅助文件,确保这些间接依赖的变更也能触发重启。环境变量文件(如 .env.env.local.env.development)通过 getEnvFilesForMode 函数根据当前模式确定需要监听的文件列表。

  // 第二道判断: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 钩子)允许插件过滤、添加或替换受影响的模块列表:

  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 文件变更时哪些子模块需要热更新,而不是粗暴地重载整个组件。插件按照 prenormalpost 三个阶段排序执行,确保处理顺序的可预测性。

7.2.2 多环境并行处理

Vite 6 支持多个运行环境(如 client、ssr 以及自定义环境)。HMR 更新在所有环境中并行处理:

  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() 声明了它有能力在不刷新页面的情况下处理自身或其依赖的代码变更。

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 替换后可能无法恢复正确的执行顺序:

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 的结果转化为可发送给客户端的更新消息:

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 机制),但也支持配置独立端口以适应反向代理等复杂部署场景:

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 的验证:

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 会缓冲消息:

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 的转换管道在编译时注入到代码中的全局常量:

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

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

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 消息,有一个重要的边界情况处理:

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

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

7.6.3 CSS 热更新的特殊处理

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

// 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> 标签注入,这种情况的更新由 updateStyleremoveStyle 函数处理:

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(模块路径)来区分身份:

export function createHotContext(ownerPath: string): ViteHotContext {
  return new HMRContext(hmrClient, ownerPath)
}

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

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 接受模式:

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:清理副作用

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:主动触发重新加载

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:跨更新持久化数据

get data(): any {
  return this.hmrClient.dataMap.get(this.ownerPath)
}

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

// 使用示例
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 更新队列

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 模块获取与回调执行

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):

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 函数优雅地解决了这些问题:

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 内联创建,避免了额外的文件请求(此时服务器可能尚未就绪)。

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

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 函数通知客户端清理这些孤立模块:

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 消息,客户端收到后执行对应模块的 disposeprune 回调:

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?

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 更新仍然应该被应用。

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

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 提供卓越开发体验的核心机制,也是前端开发工具链中技术含量最高的功能之一。从文件系统事件到浏览器中的模块替换,这条管道由服务端的 handleHMRUpdatepropagateUpdateupdateModules 和客户端的 HMRClientHMRContext 协同完成。

propagateUpdate 算法沿着模块图的 importers 链向上搜索 HMR 边界,遇到 isSelfAcceptingacceptedHmrDeps 则停止,到达无出口的根模块则触发整页重载。客户端通过更新队列确保异步获取和同步执行的正确顺序,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 回调参数中为打包开发模式和传统的非打包模式分别提供两套独立实现的设计意图。


延伸阅读:HMR 的思想源流与 30 年演化

热模块替换”这个概念、最早可以追溯到 1980 年代的 Smalltalk 环境——Smalltalk-80 让程序员可以在运行时修改任何对象、所有相关引用立即更新——这是”活编程”(Live Programming)的鼻祖后来 Erlang 在电信场景下发扬光大——Erlang 的”hot code swap”让电话交换机可以”不停机升级”、把 HMR 思想带入工业级可靠性场景Lisp 的 REPL 也是类似思想的体现——代码可以在运行时被重新定义、已有对象保持状态这条从 Smalltalk → Erlang → Lisp 的血脉、在 21 世纪前端领域开花结果Webpack 在 2014 年引入 HMR 时、是前端第一次实现工业级的热更新;Vite 在 2020 年用 ESM 原生能力把 HMR 推向了更优雅的实现——跳过了打包环节、直接利用浏览器的模块缓存

Vite HMR 的最大创新、不是”更快”(虽然确实更快)、而是”更准Webpack 的 HMR 有时会出现”明明改了代码但页面没反应”的诡异情况——根源是 Webpack 的模块包裹和运行时太复杂、边界条件难以覆盖周全Vite 的 HMR 建立在 ESM 原生之上、模块替换就是浏览器的 import() 动态加载——语义更清晰、边界更少、bug 更容易定位这是”站在 Web 标准肩膀上”的力量——利用浏览器的原生能力、胜过重新发明一套运行时《React 18 源码》第 14 章、《微前端源码》第 8 章都讨论过类似的”用标准胜过自研”的工程哲学——都值得一读

延伸阅读:HMR 与开发体验的哲学

HMR”作为一个功能、表面看是”代码改动不刷新页面”——但它背后的哲学、是”最小化反馈回路一个开发者改一行代码、到看到效果、中间经过了”保存文件 → 触发 HMR → 模块失效 → 重新编译 → WebSocket 推送 → 浏览器替换 → 组件重新渲染”七个步骤——如果这七个步骤的总耗时超过 100 毫秒、开发者就会感知到”卡顿”;超过 1 秒、开发体验就会显著下降;超过 10 秒、开发者就会开始用”console.log + 手动刷新”来绕过HMR 的目标、就是把这个回路压缩到开发者”感知不到”的程度——让”改代码”和”看效果”之间的心流不被打断

这个看似”微小”的改进、对生产力的影响是巨大的想象一个前端工程师一天改 500 次代码——每次节省 5 秒、一天就是 40 分钟、一年就是 200 小时、相当于 25 个工作日这还不算”心流被打断”的认知成本——根据心理学研究、被打断后恢复到深度专注需要 15-20 分钟——频繁打断的代价远大于”等待时间本身所以 HMR 不只是”快一点的编译”、而是”保护开发者注意力的基础设施”——它的价值、要用”一年节省多少心流”来衡量这种”为人而不是为机器”的设计哲学、是 Vite 团队最值得称道的地方

延伸阅读:从 HMR 到”活文档”与”活产品

HMR 的思想、如果推到极致、会变成一种更根本的产品形态——“活产品想象一个正在运行的生产系统、用户在使用过程中、工程师发现一个 bug、直接在代码里修复、修复即时生效——没有”部署”、没有”重启”、没有”用户感知到的停服”——这就是”活产品Erlang 在电信领域实现了这种能力(AXD301 交换机 20 年零停机)、但在通用软件领域、这种能力至今仍然很稀缺

前端领域、HMR 在”开发环境”实现了这种能力、但”生产环境”还没有这是一个很有意思的发展方向——未来有没有可能、把 HMR 的技术扩展到生产环境?让前端 bug 能被”热修”而不需要重新部署?目前一些”灰度发布”、“微前端”、“Module Federation”工具正在探索这个方向但真正的”生产 HMR”还有很多挑战——状态一致性、版本兼容、回滚机制、安全性——还需要整个行业继续努力在这个意义上、Vite 的 HMR 不是终点、而是起点——它让我们看到了”软件可以这样开发”的样子、剩下的就是把这种体验扩展到更多场景希望本书的读者、有机会成为这个扩展过程的参与者

延伸阅读:HMR 在 2026 年面临的新挑战

2026 年这个时间点、HMR 面临几个新的挑战第一个挑战是”Server Components”(React Server Components、Vue Server Components)——这种新型组件在服务端运行、不直接映射到浏览器模块——传统的 HMR 假设”模块在浏览器执行”、不完全适用于 Server ComponentsVite 和 Next.js 都在探索”服务端组件的 HMR”、但还没有成熟方案第二个挑战是”Edge Runtime”——越来越多的前端代码运行在 Edge(Cloudflare Workers、Vercel Edge、Deno Deploy)上——这些环境的调试和热更新工具还很初级第三个挑战是”AI 驱动的代码生成”——Agent 会频繁修改代码、HMR 需要在”机器生成的修改”下保持稳定

这些挑战、是 HMR 的下一个战场Vite 作为前端工具链的前沿、必然会在这些方向持续探索感兴趣的读者、可以关注 Vite 的 GitHub 讨论区、Roadmap 文档、Evan You 的推特——这些是观察前端工具演化的最佳窗口理解 HMR 的今天、也是为理解前端的明天做准备——两者是同一件事的不同阶段

延伸阅读:HMR 边界的艺术

本章反复提到”HMR 边界”——这是一个看似简单但深藏玄机的概念边界”的本质是”状态可以被安全重置的地方”——如果一个模块的状态可以被丢弃(比如一个纯函数组件)、它就是 HMR 边界;如果一个模块的状态必须被保留(比如一个保存了用户输入的 store)、它就不是 HMR 边界React Fast Refresh 和 Vue 的 HMR 插件做的主要工作、就是自动识别这些边界——让组件的 props 和内部 state 在热更新时尽可能被保留这种”自动识别边界”的能力、是框架级 HMR 体验的关键——没有它、开发者要手动写 import.meta.hot.accept 声明、体验会差很多

好的 HMR 边界识别、需要框架对自己的组件模型有深刻理解React 的函数组件天然是”可以安全重渲染”的——只要保留 hooks 的执行顺序、state 就能被保留Vue 的单文件组件通过 HMR 插件识别 setup 函数和 <template>、分别决定”逻辑变化触发 state 重置”还是”模板变化只重新渲染Svelte、Solid、Qwik 都有类似机制——具体实现各有不同、但思路一致这是”框架 + 构建工具”协同的典范——Vite 提供底层 HMR 管道、框架插件提供”边界识别”逻辑——两者合力打造出前端开发者习以为常的”丝滑热更新这种分层协作的设计、让 Vite 能支持所有主流框架、而不需要针对每个框架重新实现 HMR