VoociiBlog
HomeBlogAI TrendingPortfolioBooksLinksToolsAbout

© 2026 Voocii. Built with Next.js & tRPC.

GitHubXEmailRSS


前端热更新(HMR)的原理和工作流程

frontendrick-hayekrick-hayek2025年8月5日

做前端开发时,你在IDE里改了某个文件,保存后会发现浏览器那边立刻更新了你修改的部分,这就是HMR。

HMR(Hot Module Replacement,热模块替换)让开发者修改代码后,只更新发生变化的模块,并尽可能保留页面状态。它不是“重新刷新页面”的别名,而是一套由文件监听、依赖图分析、增量编译、消息推送和运行时替换共同组成的开发时基础设施。

HMR 概念示意图

一、为什么需要 HMR

传统开发流程通常是:修改文件 → 重新构建 → 浏览器刷新。刷新虽然简单,但会带来三个问题:

  1. 反馈慢:大型项目需要重新构建大量模块。
  2. 状态丢失:表单输入、滚动位置、组件内部状态和调试上下文都会被清空。
  3. 变化范围过大:只改了一个 CSS 颜色,却让整个页面重新执行。

HMR 的目标可以概括为:

找到变化的模块,只替换它及其必要的依赖边界;如果无法安全替换,再退化为整页刷新。

二、核心原理:一张依赖图加两套运行时

HMR 的本质不是某个框架 API,而是“构建时依赖图”与“浏览器端模块运行时”之间的增量同步。

flowchart TD
  A[源代码文件] --> B[文件监听器]
  B --> C[增量编译器]
  C --> D[模块依赖图]
  D --> E[变更模块与更新边界]
  E --> F[开发服务器]
  F <-->|WebSocket / SSE| G[浏览器 HMR Runtime]
  G --> H[模块缓存与依赖关系]
  G --> I[框架运行时 / 组件状态]

其中有两个关键角色:

1. 开发服务器端

  • 监听文件系统变化;
  • 重新编译受影响的模块;
  • 计算模块的新版本和更新边界;
  • 通过 WebSocket、SSE 或其他长连接通知浏览器;
  • 提供新模块代码、source map 和更新元信息。

2. 浏览器端 HMR Runtime

  • 建立与开发服务器的连接;
  • 接收 hash、update、error、full-reload 等消息;
  • 下载新模块;
  • 执行模块的 dispose / accept 生命周期;
  • 更新模块缓存和依赖关系;
  • 必要时触发框架级重渲染或整页刷新。

三、一次热更新的完整工作流程

下面以“修改一个 React/Vue 组件”为例,拆开整个链路。

sequenceDiagram
  participant Dev as 开发者
  participant FS as 文件系统
  participant S as Dev Server
  participant C as 编译器/插件
  participant B as 浏览器 Runtime
  participant UI as 框架组件树

  Dev->>FS: 保存 Button.tsx
  FS->>S: change 事件
  S->>C: 重新编译受影响模块
  C-->>S: 新模块代码 + hash + update 元信息
  S-->>B: WebSocket: update
  B->>S: 请求新模块
  S-->>B: 返回新模块 / JS / CSS
  B->>B: 执行 dispose,保存旧模块状态
  B->>B: 替换模块缓存并执行 accept
  B->>UI: 通知框架重新渲染
  UI-->>Dev: 页面局部更新,状态尽量保留

步骤 1:监听文件变化

开发服务器通常使用 chokidar、原生 fs.watch、Watchman 或操作系统文件事件 API。监听器发现文件变化后,不会立即把整个项目推倒重来,而是把文件路径交给编译器和依赖图。

实际项目中常见的额外处理包括:

  • 合并短时间内连续触发的事件(debounce);
  • 区分新增、删除和修改;
  • 忽略 node_modules、构建产物等目录;
  • 在 Docker、网络文件系统中采用轮询模式。

步骤 2:增量编译

编译器读取变化模块,重新执行转译、语法分析、插件处理和资源生成。现代工具会缓存 AST、loader 结果或依赖解析结果,尽量只重做必要部分。

例如:

// 修改前
export default function Button() {
  return <button>保存</button>
}

// 修改后:通常只需要重新生成这个模块
export default function Button() {
  return <button className="primary">保存</button>
}

步骤 3:计算更新边界

工具会沿依赖图向上查找“谁能够接收这个更新”。这就是 更新边界(update boundary)。

flowchart TB
  App[App] --> Page[Page]
  Page --> Button[Button 发生变化]
  Button --> CSS[button.css]
  Button -.->|向上查找 accept| Page
  Page -.->|没有 accept,继续向上| App
  Button --> Boundary[找到可接受边界]
  Boundary --> Result[只替换边界内模块]

如果模块本身或某个父模块注册了 accept,更新就可以局部应用;如果一直找到根仍没有可接受边界,工具通常执行 full reload。这个回退机制是 HMR 稳定性的关键。

步骤 4:服务器发送更新消息

浏览器通常预先建立一条长连接。服务器发送的消息可能类似:

{
  "type": "update",
  "updates": [
    {
      "path": "/src/components/Button.tsx",
      "acceptedPath": "/src/components/Button.tsx",
      "timestamp": 1720000000000
    }
  ]
}

不同工具的字段、请求 URL 和协议细节并不完全相同,但信息通常包含:更新类型、模块路径、版本标识、时间戳,以及是否需要整页刷新。

步骤 5:浏览器拉取并替换模块

收到通知后,浏览器端 runtime 一般会:

  1. 根据路径拼出新模块 URL;
  2. 加入时间戳或 hash,避免缓存命中旧代码;
  3. 下载并执行新模块;
  4. 更新模块缓存、导出值和依赖关系;
  5. 调用模块或框架注册的 accept 回调。

伪代码如下:

socket.onmessage = async (message) => {
  const update = JSON.parse(message.data)

  if (update.type === 'full-reload') {
    location.reload()
    return
  }

  if (update.type === 'update') {
    const nextModule = await fetchAndEvaluate(update.path)
    hotData[update.path] = nextModule
    runAcceptCallbacks(update.path, nextModule)
  }
}

这里的 fetchAndEvaluate、模块缓存和路径解析都由具体工具实现;示例只用于说明流程。

步骤 6:执行清理与接收回调

一个模块可能需要在被替换前清理副作用,例如定时器、事件监听器或 WebSocket 连接:

if (import.meta.hot) {
  import.meta.hot.dispose((data) => {
    data.timerId = timerId
    clearInterval(timerId)
  })

  import.meta.hot.accept((newModule) => {
    render(newModule.default)
  })
}

dispose 负责旧模块卸载,accept 负责接收新模块。框架插件通常会自动注入这些逻辑,所以业务代码未必需要手写。

hmr-core-workflow.svg

四、HMR 为什么能够保留状态

“保留状态”不是浏览器自动完成的,而是框架运行时主动设计的结果。

以组件框架为例,热更新可能只替换组件的渲染函数,而保留组件实例、DOM 节点和状态容器:

flowchart LR
  Old[旧组件定义] -->|提取可更新部分| Patch[更新组件定义]
  State[组件实例状态] --> Patch
  DOM[已有 DOM / Fiber / VNode] --> Patch
  Patch --> New[新渲染逻辑 + 旧状态]

但状态保留有边界:

  • 修改组件结构可能导致状态重置;
  • 修改模块导出类型可能无法安全替换;
  • 修改顶层副作用可能触发整页刷新;
  • 修改路由、全局 store 或入口文件,影响范围可能很大。

因此,HMR 的准确表述是“尽可能保留状态的局部更新”,而不是“任何修改都不刷新”。

五、不同工具的实现差异

Webpack HMR

Webpack 在构建时生成模块图,并通过 dev server 的 HMR runtime 传递更新。模块可以使用 module.hot.accept() 和 module.hot.dispose() 注册边界与清理逻辑。

特点:

  • 生态成熟,loader/plugin 可扩展性强;
  • HMR 能力与 loader、插件和框架适配紧密相关;
  • 对 CommonJS、ES Module 和打包后的模块体系都有较深抽象;
  • 大型项目冷启动和全量构建成本可能较高,但缓存和持久化缓存可以缓解。

Vite HMR

Vite 开发时通常直接提供原生 ESM,浏览器按需请求模块。修改文件后,Vite 根据模块图发送更新消息,并利用 import.meta.hot 完成替换。

特点:

  • 开发阶段减少了传统打包步骤,启动速度快;
  • ESM URL 天然适合按模块请求和缓存;
  • 生产构建仍通常交给 Rollup(或其后继工具链);
  • 通过插件向 React、Vue、Svelte 等框架注入状态保留逻辑。

React Fast Refresh

Fast Refresh 是 React 生态的组件级热更新方案,重点不只是“替换一个 JS 模块”,而是判断组件边界、重新执行组件代码,并尽量保留 hooks 状态。

它通常要求:

  • 文件主要导出 React 组件;
  • 组件 identity 能够被稳定识别;
  • 文件同时导出普通值、修改组件签名或存在不安全副作用时,可能扩大更新范围。

Vue HMR

Vue 的 SFC(单文件组件)编译器可以分别处理 template、script 和 style。修改 style 时往往只替换 CSS;修改 template 时可以更新渲染函数并保留实例状态;修改 script 时通常更可能重建组件实例。

Next.js / Nuxt 等框架

这类全栈框架在 HMR 之上还要协调:

  • 客户端组件与服务端组件;
  • 路由模块和页面边界;
  • 服务端渲染结果;
  • 数据获取缓存与开发时失效策略;
  • CSS、静态资源和中间件。

因此它们的“热更新”往往是 HMR、Fast Refresh、SSR 重渲染和路由刷新策略的组合,而不是单一协议。

六、不同实现的本质区别

可以从四个层面理解差异:

层面传统 Webpack HMRVite HMRReact Fast RefreshVue SFC HMR
模块来源打包器生成的模块图原生 ESM + 开发服务器转换JS 模块 + React 组件签名SFC 拆分后的多个虚拟模块
更新单位模块或模块边界ESM 模块 / CSS 模块React 组件边界template / script / style
状态保留依赖 accept 回调和插件依赖 import.meta.hot 与插件框架运行时识别组件与 hooksVue 运行时区分修改类型
失败回退full reloadfull reload 或失效模块重载组件重置或整页刷新组件重建或整页刷新
核心优化增量重编译与缓存按需 ESM 请求组件级状态连续性SFC 子模块级更新

真正的本质区别不在于消息是不是 WebSocket,也不在于命令叫不叫 hot,而在于:

  1. 更新粒度:模块、组件、文件子模块还是路由边界;
  2. 边界识别方式:显式 accept、依赖图推导还是框架签名分析;
  3. 状态归属:状态在模块缓存、组件实例、hooks、store 还是服务端缓存中;
  4. 失败策略:无法安全替换时,选择重建组件、刷新入口,还是整页刷新。

七、CSS、静态资源和 JavaScript 的差别

HMR 不只用于 JavaScript:

  • CSS:通常通过替换 <style> 标签或更新 <link> 的内容完成,最容易做到无刷新;
  • 图片、字体:常通过更新资源 URL、hash 或重新加载资源完成;
  • JavaScript:需要处理模块依赖、执行顺序、缓存和副作用,复杂度最高;
  • HTML / 入口文件:通常直接 full reload,因为它可能改变整个运行时环境。
flowchart TD
  Change[资源发生变化]
  Change --> CSS{CSS?}
  CSS -->|是| ReplaceStyle[替换 style/link 内容]
  CSS -->|否| JS{JavaScript 模块?}
  JS -->|是| Check[检查 HMR 边界与副作用]
  Check -->|安全| Swap[下载并替换模块]
  Check -->|不安全| Reload[整页刷新]
  JS -->|否| Asset[更新资源 URL / 重新请求]

八、执行 npm run dev 之后,工具到底做了什么?

我们看到的往往只是终端上一行 ready in xx ms,但在这行字打印出来之前,工具已经做了不少事情。

npm run dev 之后的时间线

以一个典型的现代前端工具(不论 Webpack 还是 Vite)为例,大致会依次发生:

  1. 解析配置:读取 vite.config.* 或 webpack.config.*,合并命令行参数、环境变量、插件列表,得到最终生效的配置对象。
  2. 启动开发服务器:基于 Node.js 的 HTTP 服务(Vite 基于 connect 中间件体系,Webpack 借助 webpack-dev-server)在指定端口监听请求,并挂载各类中间件(静态资源、代理、HTML 处理等)。
  3. 初始编译:
    • Webpack 会从入口开始做一次完整的依赖分析和打包,生成初始 bundle;
    • Vite 不会打包应用代码,但会对 node_modules 里的第三方依赖做一次"预构建"(用 esbuild 把 CommonJS 依赖转换成 ESM,并做合并,减少后续请求数量),应用自身的源码则留到浏览器实际请求时才转换。
  4. 向 HTML 注入客户端脚本:开发服务器会在返回的 index.html 里插入一段 HMR 客户端运行时代码,这段代码负责监听 WebSocket 消息、按需拉取新模块、执行替换逻辑。
  5. 建立 WebSocket 连接:浏览器加载页面后,注入的客户端脚本会主动和开发服务器建立一条 WebSocket 长连接,这条连接会一直保持,直到你关掉浏览器标签页或停掉开发服务器。
  6. 启动文件系统监听:这时候监听器才真正"上岗",开始盯着源码目录里的每一次写入操作。

这六步做完,终端才会打印出那行熟悉的 ready。之后,整个系统就进入"待命"状态——直到你保存下一次修改,触发第三节里描述的那个完整循环。

九、常见问题与排查思路

修改后总是整页刷新

检查模块是否存在可接受边界、框架插件是否启用、入口模块是否被修改,以及导出形态是否破坏了框架的组件识别规则。

浏览器提示更新失败

优先检查 WebSocket 代理配置、端口转发、HTTPS 与 WSS 是否匹配、反向代理是否支持 Upgrade,以及新模块 URL 是否返回了正确的 JavaScript。

状态偶尔丢失

观察修改的是组件实现、组件签名、store、路由还是入口文件。状态丢失通常不是 bug,而是运行时无法证明替换安全时的保守选择。

Docker 或网络盘中更新很慢

文件事件可能没有正确透传,需要启用 polling;同时应降低监听范围,并避免把依赖目录和生成目录加入 watch。

十、总结

HMR 可以抽象成一条链路:

文件变化 → 增量编译 → 依赖图分析 → 开发服务器推送 → 浏览器下载新模块 → 执行 dispose/accept → 框架局部重渲染 → 必要时整页刷新。

不同工具的差异,主要来自模块系统、更新边界和状态管理方式:Webpack 更偏向打包器模块替换,Vite 更偏向原生 ESM 的按需更新,React Fast Refresh 和 Vue HMR 则进一步把“模块更新”提升为“组件级更新”。

理解了这几个层次,就能解释大多数热更新现象:为什么 CSS 修改几乎瞬间生效,为什么组件状态有时保留、有时重置,以及为什么某些修改最终必须刷新页面。


记住: HMR 不是让页面永远不刷新,而是在安全边界内,用最小代价把新代码接入正在运行的应用。

Comments (0)

No comments yet. Be the first!

On this page
  • 一、为什么需要 HMR
  • 二、核心原理:一张依赖图加两套运行时
  • 三、一次热更新的完整工作流程
  • 四、HMR 为什么能够保留状态
  • 五、不同工具的实现差异
  • 六、不同实现的本质区别
  • 七、CSS、静态资源和 JavaScript 的差别
  • 八、执行 npm run dev 之后,工具到底做了什么?
  • 九、常见问题与排查思路
  • 十、总结