Skip to content

HMR(Hot Module Replacement)

A Note to Our Readers

While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.

如果您使用 Vite 构建项目,那么您很可能也使用了热模块替换(HMR)。HMR 允许您更新代码而无需刷新页面,比如编辑组件标记或调整样式,更改会立即在浏览器中反映出来,这有助于加快代码交互速度并提升开发者体验。

虽然 HMR 也是其他打包工具(如 WebpackParcel)的功能之一,在本文中我们将深入探讨它在 Vite 中的工作原理。通常情况下其他打包工具应该也类似地运行。

首先要说明的是,HMR 并不简单,并且某些主题可能需要一些时间来消化,但我希望已经引起了您的兴趣!在本页上,您将学到:

模块替换需要哪些条件呢

实质上,HMR 是在应用程序运行时动态替换模块的过程。大多数打包工具会使用 ECMAScript 模块(ESM)作为模块,因为它更容易分析模块的导入和导出,这有利于了解一个模块中的替换将如何影响其他相关模块。

一个模块通常可以访问 HMR 生命周期 API,这些 API 用来处理旧模块被丢弃时和新模块到位时的操作。在 Vite 中,你有如下 API 可以使用:

  • import.meta.hot.accept()
  • import.meta.hot.dispose()
  • import.meta.hot.prune()
  • import.meta.hot.invalidate()

整体来说,它们的工作原理是这样的:

需要注意的是,您需要使用这些 API 才能使 HMR 正常工作。例如,Vite 在开箱即用时会使用这些 API 处理 CSS 文件,但对于其他文件(如 VueSvelte),您可以使用一个 Vite 插件来调用这些 HMR API。或者根据需要手动处理。否则,默认情况下对文件的更新将导致整个页面重新加载。

除此之外,让我们深入了解这些 API 是如何工作的!

import.meta.hot.accept()

当您使用 import.meta.hot.accept() 并附加回调时,回调将负责用 新模块 替换 旧模块。使用此 API 的模块也被称为 已接受模块

已接受模块 创建了一个 HMR边界HMR边界 包含 该模块本身 以及所有递归导入的模块(依赖当前模块的所有父模块及其所有祖先模块)。已接受的模块也是 HMR边界 的“根”,因为该边界通常是图形结构。

已接受模块 也可以根据 HMR 回调的附加方式缩小为“自我接受的模块”。import.meta.hot.accept 有两个函数签名:

  1. import.meta.hot.accept(cb: Function) - 接受来自自身的变化
  2. import.meta.hot.accept(deps: string | string[], cb: Function) - 从导入的模块接受更改

如果使用第一个签名,则当前模块可以称做 自接受模块。区分对于 HMR 传播 是重要的,我们稍后会谈到。

这是它们如何被使用的:

ts
export let data = [1, 2, 3];

if (import.meta.hot) {
  import.meta.hot.accept(newModule => {
    // Replace the old value with the new one
    data = newModule.data;
  });
}
js
import { value } from './stuff.js';

document.querySelector('#value').textContent = value;

if (import.meta.hot) {
  import.meta.hot.accept(['./stuff.js'], ([newModule]) => {
    // Re-render with the new value
    document.querySelector('#value').textContent = newModule.value;
  });
}

import.meta.hot.dispose()

当一个被接受的模块或被其他模块接受的模块被替换为新模块,或者正在移除时,我们可以使用 import.meta.hot.dispose() 进行清理。这允许我们清理旧模块创建的任何副作用,比如移除事件监听器、清除定时器或重置状态。

这是 API 的一个示例:

js
globalThis.__my_lib_data__ = {};

if (import.meta.hot) {
  import.meta.hot.dispose(() => {
    // Reset global state
    globalThis.__my_lib_data__ = {};
  });
}

import.meta.hot.prune()

当一个模块需要从运行时完全移除,例如文件被删除时,我们可以使用 import.meta.hot.prune() 执行最终的清理工作。这类似于 import.meta.hot.dispose(),但只在模块被移除时调用 一次

在内部,Vite 通过导入分析(分析模块的导入)在不同阶段修剪模块,因为唯一能知道一个模块不再被使用的时机是当这个模块不再被任何其他模块导入(依赖)时。

以下是 Vite 使用 CSS HMR API 的示例:

js
// Import utilities to update/remove style tags in the HTML
import { removeStyle, updateStyle } from '/@vite/client';

updateStyle('/src/style.css', 'body { color: red; }');

if (import.meta.hot) {
  // Empty accept callback is we want to accept, but we don't have to do anything.
  // `updateStyle` will automatically get rid of the old style tag.
  import.meta.hot.accept();
  // Remove style when the module is no longer used
  import.meta.hot.prune(() => {
    removeStyle('/src/style.css');
  });
}

import.meta.hot.invalidate()

与上述 API 不同,import.meta.hot.invalidate() 是一个 操作 而不是 生命周期钩子。您通常会在 import.meta.hot.accept 内部使用它,在运行时可能会意识到模块无法安全更新,需要退出。

当调用此方法时,Vite 服务器 将被告知该模块已失效,就好像已经更新了一样。HMR 传播 将再次执行以确定其任何导入者是否可以递归地接受此更改。

以下是 API 的示例:

ts
export const data = [1, 2, 3];

if (import.meta.hot) {
  import.meta.hot.accept(newModule => {
    // If the `data` export is deleted or renamed
    if (!(data in newModule)) {
      // Bail out and invalidate the module
      import.meta.hot.invalidate();
    }
  });
}

Other HMR APIs

Vite HMR文档涵盖了更多的API。然而,它们并不是理解HMR工作原理的关键,所以我们暂时跳过它们,但在稍后讨论HMR客户端时会回到这些API。

如果您对它们在某些情况下如何有用感兴趣,请快速阅读文档!

从一开始

从上述内容中,我们已经了解了 HMR API 以及这些 API 是如何允许让开发者 替换管理 被修改的模块。但仍然有一个缺失的部分:我们如何知道何时替换一个模块?HMR通常发生在编辑文件之后,但之后会发生什么呢?

乍一看,情况大致是这样的:

以下我们来详细解释一下具体的流程。

Editing a file(编辑一个文件)

HMR 在您编辑文件并保存时开始。像 chokidar 这样的文件系统监视器会检测到更改,并将已编辑文件路径传递给下一步。

Processing edited modules(处理已编辑过的模块)

Vite 开发服务器会收到编辑过的文件路径信息。有了这些编辑过的文件路径,借助模块依赖图中找到所对应的模块信息。需要注意的是,“文件”“模块” 是两个不同的概念,一个文件可能对应 一个多个 模块。例如,Vue 文件 可以编译为 JavaScript 模块 以及相关的 CSS 模块

这些模块随后传递给 Vite 插件的 handleHotUpdate() 钩子进行进一步处理。插件可以选择 筛选扩展 所需的模块数组。最终的模块将被传递到下一步骤。

以下是一些插件示例:

js
// Example: filter out array of modules
function vuePlugin() {
  return {
    name: 'vue',
    async handleHotUpdate(ctx) {
      if (ctx.file.endsWith('.vue')) {
        const oldContent = cache.get(ctx.file);
        const newContent = await ctx.read();
        // If only the style has changed when editing the file, we can filter
        // out the JS module and only trigger the CSS module for HMR.
        if (isOnlyStyleChanged(oldContent, newContent)) {
          return ctx.modules.filter(m => m.url.endsWith('.css'));
        }
      }
    }
  };
}
js
// Example: extending array of modules
function globalCssPlugin() {
  return {
    name: 'global-css',
    handleHotUpdate(ctx) {
      if (ctx.file.endsWith('.css')) {
        // If a CSS file is edited, we also trigger HMR for this special
        // `virtual:global-css` module that needs to be re-transformed.
        const mod = ctx.server.moduleGraph.getModuleById(
          'virtual:global-css'
        );
        if (mod) {
          return ctx.modules.concat(mod);
        }
      }
    }
  };
}

Module invalidation(使模块失效)

HMR 传播 之前,我们急切地递归地使更新模块的最终数组及其导入者无效。每个模块转换后的代码将被移除,并附加一个失效时间戳。该时间戳将用于在下一次请求时在客户端上获取新模块。

HMR propagation(HMR 传播/冒泡)

要更新模块的最终数组现在将通过 HMR 传播。这是“魔法”发生的地方,通常会导致 HMR 不按预期工作的困惑源头。

HMR 传播 本质上就是通过要更新模块做为起点来检索所需的 HMR 边界。如果所有要更新的模块都在一个边界内,则 Vite 开发服务器将通知 HMR 客户端,告知接受的模块执行 HMR。如果有些不在边界内,则将触发完整页面重新加载(reload)。

为了更好地理解它是如何工作的,请让我们逐个案例来看这个示例:

  • 场景1:如果更新了 stuff.js,传播将递归查找其父级依赖模块及其所有祖先依赖模块来找到一个 被接受模块。在这种情况下,我们会发现 app.jsx 是一个可接受模块。但在结束传播之前,我们需要确定 app.jsx 是否可以接受来自 stuff.js 的更改。这取决于 import.meta.hot.accept() 如何调用。

    1. 场景1(a):如果 app.jsx自我接受(self-accepting) 的,或者它接受来自 stuff.js 的更改,我们可以在此停止继续往上传播,因为没有其他模块依赖 stuff.js 模块。然后 HMR客户端 将通知 app.jsx 执行 HMR
    2. 场景1(b):如果 app.jsx 不接受 stuff.js 模块的更改,则我们将继续向上传播来找到一个 被接受模块。但由于没有其他 被接受模块,我们将达到“根” index.html 文件。将触发完整页面重新加载。
  • 场景2:如果更新了 main.jsother.js,则传播将再次递归查查看其父级依赖模块及其所有祖先依赖模块。然而,发现并没有 被接受模块,并且我们会达到“根”index.html文件。因此会触发完整页面重新加载。

  • 场景3:如果更新了 app.jsx,则立即发现它是一个 被接受模块。然而,一些模块可能无法对自身变化进行更新。我们可以通过检查 被接受模块 是否是 自我接受(self-accepting) 来判断 被接受模块 是否可以更新自身。

    1. 场景3(a): 如果 app.jsx自我接受(self-accepting), 我们可以在这里停止并让 HMR客户端 通知这个模块来执行 HMR
    2. 场景3(b): 如果 app.jsx 不是 自我接受(self-accepting)的,我们将继续向上传播来找到一个 被接受模块。但由于没有找到,并且我们将达到“root” index.html 文件,将触发完整页面重新加载。
  • 场景4:如果更新了 utils.js,传播将再次递归查看其父级依赖模块及其所有祖先依赖模块。首先,我们会找到 app.jsx 作为 被接受模块,并在那里停止传播(假设符合 场景1(a) 的情况下)。然后,我们也会递归地查看 other.js 及其父级依赖模块及其所有祖先依赖模块,但发现没有找到 被接受模块,最终到达“根”index.html文件。如果 存在至少一个 没有被接受的模块的情况,则将触发完整页面重新加载。

如果您想了解涉及多个HMR边界的一些更高级场景,请单击下面折叠的部分:

Details

切换高级场景让我们来看一个不同的例子,涉及到三个 .jsx 文件中的 3 个 HMR 边界:

  • 场景5:如果更新了 stuff.js,传播将递归地查找其导入者来找到一个 被接受模块。我们会发现 comp.jsx 是一个 被接受模块,并且处理方式与 场景1 相同。重申一下:

    1. 场景5(a):如果 comp.jsx自我接受(self-accepting),或者 comp.jsx 接受来自 stuff.js 的更改,那么就可以停止传播。然后 HMR客户端 将通知 comp.jsx 执行 HMR
    2. 场景5(b):如果 comp.jsx 不接受这个更改,我们将继续向上传播来找到一个 被接受模块。我们会发现 app.jsx被接受模块,并且处理方式与此情况 场景5 相同!直到找到能够接受 stuff.js 更改的模块,若存在一条分支检索到“根”index.html,那么就需要进行完整页面加载。
  • 场景6:如果更新了 bar.js,传播将递归地查找其导入者并发现 comp.jsxalert.jsx被接受模块。我们也会像处理 场景5 一样处理这两个模块。假设最佳情况是,comp.jsx 模块和 alert.jsx 模块均符合场景5(a),即两者都接收 bar.js 的更新,那么 HMR客户端 将通知 comp.jsxalert.jsx 两个模块一起执行 HMR

  • 场景7:如果更新了 utils.js,传播将再次递归地查看其导入者(importers),并找到所有直接导入者 comp.jsxalert.jsxapp.jsx,并且均是 被接受模块。我们也会以与 场景5 相同的方式处理这三个模块(comp.jsxalert.jsxapp.jsx)。假设最佳情况是所有 被接受模块 都符合 场景5(a),即使 comp.jsx 也是app.jsxHMR边界 的一部分,HMR客户端 也会通知他们三个执行 HMR。(未来,Vite可能会检测到这一点,并且只通知 app.jsxalert.jsx,但这在很大程度上是一个实现细节!)

  • 场景8:如果更新了 comp.jsx,我们立即发现它是一个被接受的模块。与 场景3 类似,我们需要首先检查 comp.jsx 是否是 自我接受(self-accepting)

    1. 场景8(a):如果 comp.jsx自我接受(self-accepting),那么就停止传播,并让 HMR 客户端 通知 comp.jsx 执行 HMR
    2. 场景8(b):如果 comp.jsx 不是 自我接受(self-accepting),则可以像处理 场景5(b) 一样处理。

除了上述内容之外,还有许多其他未在此处涵盖的边缘情况,因为它们有点复杂,包括循环导入、部分接受模块、仅 CSS 导入器等。然而,在您对整个流程更加熟悉时,可以重新查看它们!

最后,HMR 传播 的结果是为了确定是否要执行完整的页面加载还是应该在客户端中执行 HMR 更新

若确定需要执行完整的页面重新加载,那么会向 HMR 客户端 发送一条消息并告知需要重新加载页面。

若确定可以热更新的模块,那么在 HMR 传播期间所有符合条件的 被接受的模块 合成数组发送到 HMR 客户端,客户端会触发我们上面讨论过的 HMR API,从而执行 HMR

这个 HMR客户端 是如何工作的呢?

Vite 应用程序中,您可能会注意到 Vite 会往 HTML 中添加了一个特殊的脚本,请求 /@vite/client 脚本。该脚本中就包含了处理 HMR 客户端 的逻辑。

HMR 客户端 负责内容如下:

  1. Vite 开发服务器建立 WebSocket 连接。
  2. 监听来自 Vite 服务器的 HMR payloads
  3. 在运行阶段提供和触发 HMR API
  4. 将任何事件发送回 Vite 开发服务器

从更大的角度看,HMR 客户端 有助于将 Vite 开发服务器HMR API 建立连接。让我们看看这种连接是如何运作的。

Client initialization(客户端初始化)

HMR 客户端 有能力可以从 Vite 开发服务器 接收到任何消息之前,它需要首先通过 WebSockets 建立连接。以下是一个设置 WebSocket 连接的示例,用于进一步处理 Vite 开发服务器 返回的 HMR 传播 的结果:

js
// /@vite/client (URL)

const ws = new WebSocket('ws://localhost:5173');

ws.addEventListener('message', ({ data }) => {
  const payload = JSON.parse(data);
  switch (payload.type) {
    case '...':
    // Handle payloads...
  }
});

// Send any events to the Vite dev server
ws.send('...');

在下一章节中,我们将更详细地讨论 payload 处理。

除此之外,HMR 客户端 还初始化了一些用于处理 HMR 所需的状态,并导出了几个 API,例如 createHotContext(),供使用 HMR API 的模块使用。例如:

tsx
// Injected by Vite's import-analysis plugin
import { createHotContext } from '/@vite/client';
import.meta.hot = createHotContext('/src/app.jsx');

export default function App() {
  return <div>Hello World</div>;
}

// Injected by `@vitejs/plugin-react`
if (import.meta.hot) {
  // ...
}

传递给 createHotContext()URL 字符串(也称为“owner path”)有帮助确定哪个模块能够接受更改。在内部,createHotContext 将把已注册的 HMR 回调 分配给一个映射单例,映射单例会包含 owner path 到 accept callbacks、dispose callback 和 prune callback 的所有映射。我们稍后会详细介绍这一点!

这基本上就是 模块 如何与 HMR 客户端 交互并执行 HMR 更改 的方式。

Handling payloads from the server(执行服务器的回调)

建立 WebSocket 连接后,我们可以开始处理来自 Vite开发服务器 的有效回调。

js
// /@vite/client (URL)

ws.addEventListener('message', ({ data }) => {
  const payload = JSON.parse(data);
  switch (payload.type) {
    case 'full-reload': {
      location.reload();
      break;
    }
    case 'update': {
      const updates = payload.updates;
      // => { type: string, path: string, acceptedPath: string, timestamp: number }[]
      for (const update of updates) {
        handleUpdate(update);
      }
      break;
    }
    case 'prune': {
      handlePrune(payload.paths);
      break;
    }
    // Handle other payload types...
  }
});

上面的示例处理了 HMR 传播 的结果,根据结果来确定是否要触发 完整页面重新加载 还是 HMR 更新,具体取决于 full-reloadupdate 回调参数来进行区分。它还在模块不再使用时执行 prune

回调参数中还有其他属性,并非都是为 HMR 而设计的,但简要提及一下:

  1. connected:当建立 WebSocket 连接时发送。
  2. error:在服务器端出现报错时发送,Vite 可以在浏览器控制台中显示错误的具体内容。
  3. custom:由 Vite 插件发送来通知客户端任何事件。对于客户端和服务器之间的互联很有用。

继续前进,让我们看看 HMR 更新 实际是如何工作的。

HMR update(HMR更新)

HMR 传播 过程中找到的每个 HMR 边界 通常对应于一个 HMR 更新。在 Vite 中,更新采用这种签名:

ts
interface Update {
  // The type of update
  type: 'js-update' | 'css-update';
  // The URL path of the accepted module (HMR boundary root)
  path: string;
  // The URL path that is accepted (usually the same as above)
  // (We'll talk about this later)
  acceptedPath: string;
  // The timestamp when the update happened
  timestamp: number;
}

不同的 HMR 实现可以自由地重新塑造上述的更新签名。在 Vite 中,Update 被区分为 js updatecss update

css update 被特殊处理为在 HTML 中更新时简单地交换 link 标签。

对于 js update,我们需要找到对应的新模块并调用其 import.meta.hot.accept() 的回调函数,通过回调函数对自身应用执行 HMR。由于在 createHotContext() 中我们已经将路径注册为第一个参数(即在页面执行时确定路径与执行函数之间的映射),那么我们可以很轻易通过 更新的路径 来找到相匹配的模块。并且根据 更新的时间戳,我们可以获取最新的模块信息,并将新模块传递给 import.meta.hot.accept() 的回调函数。实现逻辑简化如下:

ts
// /@vite/client (URL)

// Map populated by `createHotContext()`
const ownerPathToAcceptCallbacks = new Map<string, Function[]>();

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path);
  const newModule = await import(
    `${update.acceptedPath}?t=${update.timestamp}`
  );

  for (const cb of acceptCbs) {
    cb(newModule);
  }
}

然而,需要注意的是 import.meta.hot.accept() 有两个函数签名?

  • import.meta.hot.accept(cb: Function)

  • import.meta.hot.accept(deps: string | string[], cb: Function)

上述实现逻辑仅适用于第一个函数签名(即 自我接受(self-accepting)模块),但不适用于第二个。第二个函数签名的回调只需要在依赖项更新时才被调用。我们可以将每个回调绑定到一组依赖项,如下:

js
// URL: /src/app.jsx
import { add } from './utils.js';
import { value } from './stuff.js';

if (import.meta.hot) {
  import.meta.hot.accept(/** ... */);
  // { deps: ['/src/app.jsx'], fn: ... }

  import.meta.hot.accept('./utils.js' /** ... */);
  // { deps: ['/src/utils.js'], fn: ... }

  import.meta.hot.accept(['./stuff.js'] /** ... */);
  // { deps: ['/src/stuff.js'], fn: ... }
}

然后我们可以使用 acceptedPath 来匹配依赖项并触发正确的回调函数。例如,如果更新了stuff.js,则 acceptedPath 将是 /src/stuff.js,而路径将是 /src/app.jsx。我们可以调整 HMR 处理程序如下:

ts
// Map populated by `createHotContext()`
const ownerPathToAcceptCallbacks = new Map<
  string,
  { deps: string[]; fn: Function }[]
>()

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path)
  const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)

  for (const cb of acceptCbs) {
    // Make sure to only execute callbacks that can handle `acceptedPath`
    if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
      cb.fn(newModule)
    }
  }
}

但还没有结束。在导入新模块之前,我们还需要通过 import.meta.hot.dispose() 来确保旧模块被正确处理。

ts
// Maps populated by `createHotContext()`
const ownerPathToAcceptCallbacks = new Map<
  string,
  { deps: string[]; fn: Function }[]
>()
const ownerPathToDisposeCallback = new Map<string, Function>()

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path)

  // Call the dispose callback if there's any
  ownerPathToDisposeCallbacks.get(update.path)?.()

  const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)

  for (const cb of acceptCbs) {
    // Make sure to only execute callbacks that can handle `acceptedPath`
    if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
      cb.fn(newModule)
    }
  }
}

那么我们基本上就已经实现了大部分的 HMR 客户端 要做的事情!作为进一步练习,您也可以尝试实现错误处理、空所有者检查、对可预测性进行排队并行更新等功能,这将使最终形式更加健壮。

HMR pruning(HMR 剪枝)

如在 import.meta.hot.prune() 中讨论的那样,Vite 在“import analysis”阶段内部处理 HMR pruning。当一个模块不再被任何其他模块导入时,Vite 开发服务器将向 HMR 客户端 发送 { type: 'prune', paths: string[] } 对象,HMR 客户端会独立地在运行时剪除这些无效的模块。

ts
// /@vite/client (URL)
// Maps populated by `createHotContext()`
const ownerPathToDisposeCallback = new Map<string, Function>();
const ownerPathToPruneCallback = new Map<string, Function>();

function handlePrune(paths: string[]) {
  for (const p of paths) {
    ownerPathToDisposeCallbacks.get(p)?.();
    ownerPathToPruneCallback.get(p)?.();
  }
}

HMR invalidation(HMR 失效)

与其他 HMR API 不同,import.meta.hot.invalidate() 是一个可以在 import.meta.hot.accept() 中调用的 API,作用是为了退出 HMR。在 /@vite/client 中,只需向 Vite 开发服务器发送 WebSocket 消息即可:

js
// /@vite/client (URL)
// `ownerPath` comes from `createHotContext()`
function handleInvalidate(ownerPath: string) {
  ws.send(
    JSON.stringify({
      type: 'custom',
      event: 'vite:invalidate',
      data: { path: ownerPath }
    })
  );
}

Vite 服务器收到此请求时,它将再次执行 热模块替换(HMR)传播,从其导入者开始,并将结果(完全重新加载执行 HMR 更新)发送回 HMR 客户端

HMR events(HMR 事件)

虽然对于 HMR 来说并非必需,但是当接收到特定负载时,HMR 客户端 也可以在运行时发出事件。import.meta.hot.onimport.meta.hot.off 可用于监听和取消监听这些事件。

js
if (import.meta.hot) {
  import.meta.hot.on('vite:invalidate', () => {
    // ...
  });
}

发出和跟踪这些事件与我们如何处理上面的 HMR 回调 非常相似。以 HMR失效 代码为例:

ts
const eventNameToCallbacks = new Map<string, Set<Function>>();

// `ownerPath` comes from `createHotContext()`
function handleInvalidate(ownerPath: string) {
  eventNameToCallbacks.get('vite:invalidate')?.forEach((cb) => cb());
  ws.send(
    JSON.stringify({
      type: 'custom',
      event: 'vite:invalidate',
      data: { path: ownerPath }
    })
  );
}

HMR data

最后,HMR 客户端 还提供了一种方式来存储要在 HMR API 之间 共享的数据,使用 import.meta.hot.data。这些数据可以看到传递给 import.meta.hot.dispose()import.meta.hot.prune()HMR 回调函数

保留数据也类似于我们跟踪 HMR 回调 的方式。以 HMR pruning 代码为例:

ts
// Maps populated by `createHotContext()`
const ownerPathToDisposeCallback = new Map<string, Function>();
const ownerPathToPruneCallback = new Map<string, Function>();
const ownerPathToData = new Map<string, Record<string, any>>();

function handlePrune(paths: string[]) {
  for (const p of paths) {
    const data = ownerPathToData.get(p);
    ownerPathToDisposeCallbacks.get(p)?.(data);
    ownerPathToPruneCallback.get(p)?.(data);
  }
}

总结

这就是有关 HMR 的全部内容!简而言之,我们学到了:

  1. 如何使用 HMR API 来处文件变更。
  2. 文件编辑如何导致 Vite开发服务器HMR客户端 发送 HMR更新
  3. HMR客户端 如何处理 HMR负载 并触发正确的 HMR API

在结束之前,请查看下面的常见问题解答,如果您对某些工作原理仍然有疑问。

常见的问题

  1. 我在哪里可以找到 ViteHMR 实现的源代码?

  2. 有没有可以学习的 HMR示例

    HMR 通常由 JS框架 实现,其中引入了“组件”概念,每个组件都能够隔离其状态并重新初始化自身。因此,您可以查看ReactVueSvelte等框架如何实现它们:

  3. Vite 的实现方式与 Webpack 和其他工具有何不同?

    我还没有深入研究 Webpack 的实现,只是从 Webpack 文档 和这篇 NativeScript 文章 中了解 Webpack 实现 HMR 的原理。据我所知,常见的区别在于 Webpack 处理 HMR 传播 是在客户端而不是服务器端。

    这种差异有一个好处,即 HMR API 可以更动态地使用。相比之下,Vite 需要在服务器端静态解析模块中使用的 HMR API,来确定模块是否调用了 import.meta.hot.accept()。然而,在客户端处理 HMR 传播 可能会很复杂,因为一些重要的信息(例如importers、exports、ids等)仅存在于服务器上。由于需要这些重要的信息,可能需要进行重构,在客户端获取序列化后的模块信息,并始终与服务端保持同步,这可能会很复杂。

  4. HMR在服务器端渲染中是如何工作的?

    在撰写本文时(Vite 5.0),SSR 中的 HMR 尚不受支持,但将作为 Vite 5.1实验性功能 推出。即使在 SSR 中没有 HMR,对于 VueSvelte 这样的 JS 框架,您仍然可以在客户端获取到 HMR

    对服务器端代码进行更改需要完全重新执行 SSR 入口点,这可以通过 HMR 传播 来触发(这也适用于 SSR)。但通常情况下,服务器端代码的 HMR 传播 会导致整个页面重新加载,这非常适合客户端重新向服务器发送请求,并由服务器执行重新执行操作。

  5. 如何在handleHotUpdate()中触发页面重新加载?

    handleHotUpdate() API 旨在处理要无效的模块或处理 HMR 传播。但是,可能存在检测到更改需要立即重新加载页面的情况。

    Vite 中,您可以使用 server.ws.send({ type: 'full-reload' }) 来触发完整页面重新加载,并确保模块失效且不进行 HMR 传播(这可能会错误地导致不必要的 HMR),您可以使用 server.moduleGraph.invalidateModule()

    js
    function reloadPlugin() {
      return {
        name: 'reload',
        handleHotUpdate(ctx) {
          if (ctx.file.includes('/special/')) {
            // Trigger page reload
            ctx.server.ws.send({ type: 'full-reload' });
    
            // Invalidate the modules ourselves
            const invalidatedModules = new Set();
            for (const mod of ctx.modules) {
              ctx.server.moduleGraph.invalidateModule(
                mod,
                invalidatedModules,
                ctx.timestamp,
                true
              );
            }
    
            // Don't return any modules so HMR doesn't happen,
            // and because we already invalidated above
            return [];
          }
        }
      };
    }
  6. HMR API 有任何规范吗?

    我知道的唯一规范是在这个 文档 中有提及,该规范已被存档。Vite 在刚开始时实现了这个规范,但后来稍微有所偏离,例如 import.meta.hot.decline() 没有被实现。

    如果您有兴趣实现自己的 HMR API,可能需要在 ViteWebpack 等之间选择一个版本。但从本质上讲,接受和使更改无效的术语将保持不变。

  7. 有其他学习 HMR 的资源吗?

    除了关于 ViteWebpackParcel 的热模块替换(HMR)文档外,没有太多资源深入探讨 HMR 究竟是如何工作的。然而,以下是我发现有帮助的几个资源:

    HMR到底是什么鬼?- Pedro Cattori(YouTube)

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)