每日一报
Vite
中的 HMR
实现原理
如果您使用 Vite
构建项目,那么您很可能也使用了热模块替换(HMR
)。HMR
允许您更新代码而无需刷新页面,比如编辑组件标记或调整样式,更改会立即在浏览器中反映出来,这有助于加快代码交互速度并提升开发者体验。
虽然 HMR
也是其他打包工具(如 Webpack
和 Parcel
)的功能之一,在本文中我们将深入探讨它在 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
文件,但对于其他文件(如 Vue
和 Svelte
),您可以使用一个 Vite
插件来调用这些 HMR
API。或者根据需要手动处理。否则,默认情况下对文件的更新将导致整个页面重新加载。
除此之外,让我们深入了解这些 API 是如何工作的!
import.meta.hot.accept()
当您使用 import.meta.hot.accept()
并附加回调时,回调将负责用 新模块 替换 旧模块。使用此 API 的模块也被称为 已接受模块
。
已接受模块
创建了一个 HMR边界
。HMR边界
包含 该模块本身 以及所有递归导入的模块(依赖当前模块的所有父模块及其所有祖先模块)。已接受的模块也是 HMR边界
的“根”,因为该边界通常是图形结构。
已接受模块
也可以根据 HMR
回调的附加方式缩小为“自我接受的模块”。import.meta.hot.accept 有两个函数签名:
- import.meta.hot.accept(cb: Function) - 接受来自自身的变化
- import.meta.hot.accept(deps: string | string[], cb: Function) - 从导入的模块接受更改
如果使用第一个签名,则当前模块可以称做 自接受模块
。区分对于 HMR 传播
是重要的,我们稍后会谈到。
这是它们如何被使用的:
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;
});
}
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 的一个示例:
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 的示例:
// 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 的示例:
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()
钩子进行进一步处理。插件可以选择 筛选 或 扩展 所需的模块数组。最终的模块将被传递到下一步骤。
以下是一些插件示例:
// 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'));
}
}
}
};
}
// 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(a):如果
app.jsx
是自我接受(self-accepting)
的,或者它接受来自stuff.js
的更改,我们可以在此停止继续往上传播,因为没有其他模块依赖stuff.js
模块。然后HMR客户端
将通知app.jsx
执行HMR
。 - 场景1(b):如果
app.jsx
不接受stuff.js
模块的更改,则我们将继续向上传播来找到一个 被接受模块。但由于没有其他 被接受模块,我们将达到“根”index.html
文件。将触发完整页面重新加载。
- 场景1(a):如果
场景2:如果更新了
main.js
或other.js
,则传播将再次递归查查看其父级依赖模块及其所有祖先依赖模块。然而,发现并没有被接受模块
,并且我们会达到“根”index.html文件。因此会触发完整页面重新加载。场景3:如果更新了
app.jsx
,则立即发现它是一个被接受模块
。然而,一些模块可能无法对自身变化进行更新。我们可以通过检查被接受模块
是否是自我接受(self-accepting)
来判断被接受模块
是否可以更新自身。- 场景3(a): 如果
app.jsx
是自我接受(self-accepting)
, 我们可以在这里停止并让HMR客户端
通知这个模块来执行HMR
。 - 场景3(b): 如果
app.jsx
不是自我接受(self-accepting)
的,我们将继续向上传播来找到一个被接受模块
。但由于没有找到,并且我们将达到“root” index.html 文件,将触发完整页面重新加载。
- 场景3(a): 如果
场景4:如果更新了
utils.js
,传播将再次递归查看其父级依赖模块及其所有祖先依赖模块。首先,我们会找到app.jsx
作为被接受模块
,并在那里停止传播(假设符合 场景1(a) 的情况下)。然后,我们也会递归地查看other.js
及其父级依赖模块及其所有祖先依赖模块,但发现没有找到被接受模块
,最终到达“根”index.html文件。如果存在至少一个
没有被接受的模块的情况,则将触发完整页面重新加载。
如果您想了解涉及多个HMR边界的一些更高级场景,请单击下面折叠的部分:
Details
切换高级场景让我们来看一个不同的例子,涉及到三个 .jsx 文件中的 3 个 HMR 边界:
场景5:如果更新了
stuff.js
,传播将递归地查找其导入者来找到一个被接受模块
。我们会发现comp.jsx
是一个被接受模块
,并且处理方式与 场景1 相同。重申一下:- 场景5(a):如果
comp.jsx
是自我接受(self-accepting)
,或者comp.jsx
接受来自stuff.js
的更改,那么就可以停止传播。然后HMR客户端
将通知comp.jsx
执行HMR
。 - 场景5(b):如果
comp.jsx
不接受这个更改,我们将继续向上传播来找到一个被接受模块
。我们会发现app.jsx
是被接受模块
,并且处理方式与此情况 场景5 相同!直到找到能够接受stuff.js
更改的模块,若存在一条分支检索到“根”index.html,那么就需要进行完整页面加载。
- 场景5(a):如果
场景6:如果更新了
bar.js
,传播将递归地查找其导入者并发现comp.jsx
和alert.jsx
是被接受模块
。我们也会像处理 场景5 一样处理这两个模块。假设最佳情况是,comp.jsx
模块和alert.jsx
模块均符合场景5(a),即两者都接收bar.js
的更新,那么HMR客户端
将通知comp.jsx
和alert.jsx
两个模块一起执行HMR
。场景7:如果更新了
utils.js
,传播将再次递归地查看其导入者(importers),并找到所有直接导入者comp.jsx
、alert.jsx
和app.jsx
,并且均是被接受模块
。我们也会以与 场景5 相同的方式处理这三个模块(comp.jsx
、alert.jsx
和app.jsx
)。假设最佳情况是所有被接受模块
都符合 场景5(a),即使comp.jsx
也是app.jsx
的HMR边界
的一部分,HMR客户端
也会通知他们三个执行HMR
。(未来,Vite可能会检测到这一点,并且只通知app.jsx
和alert.jsx
,但这在很大程度上是一个实现细节!)场景8:如果更新了
comp.jsx
,我们立即发现它是一个被接受的模块。与 场景3 类似,我们需要首先检查comp.jsx
是否是自我接受(self-accepting)
。- 场景8(a):如果
comp.jsx
是自我接受(self-accepting)
,那么就停止传播,并让HMR 客户端
通知comp.jsx
执行HMR
。 - 场景8(b):如果
comp.jsx
不是自我接受(self-accepting)
,则可以像处理 场景5(b) 一样处理。
- 场景8(a):如果
除了上述内容之外,还有许多其他未在此处涵盖的边缘情况,因为它们有点复杂,包括循环导入、部分接受模块、仅 CSS 导入器等。然而,在您对整个流程更加熟悉时,可以重新查看它们!
最后,HMR 传播
的结果是为了确定是否要执行完整的页面加载还是应该在客户端中执行 HMR 更新
。
若确定需要执行完整的页面重新加载,那么会向 HMR 客户端
发送一条消息并告知需要重新加载页面。
若确定可以热更新的模块,那么在 HMR 传播期间所有符合条件的 被接受的模块
合成数组发送到 HMR 客户端
,客户端会触发我们上面讨论过的 HMR API
,从而执行 HMR
。
:::
这个 HMR客户端 是如何工作的呢?
在 Vite
应用程序中,您可能会注意到 Vite
会往 HTML
中添加了一个特殊的脚本,请求 /@vite/client
脚本。该脚本中就包含了处理 HMR 客户端
的逻辑。
HMR 客户端
负责内容如下:
- 与
Vite
开发服务器建立WebSocket
连接。 - 监听来自
Vite
服务器的HMR payloads
。 - 在运行阶段提供和触发
HMR API
。 - 将任何事件发送回
Vite 开发服务器
。
从更大的角度看,HMR 客户端
有助于将 Vite 开发服务器
和 HMR API
建立连接。让我们看看这种连接是如何运作的。
Client initialization(客户端初始化)
在 HMR 客户端
有能力可以从 Vite 开发服务器
接收到任何消息之前,它需要首先通过 WebSockets
建立连接。以下是一个设置 WebSocket
连接的示例,用于进一步处理 Vite 开发服务器
返回的 HMR 传播
的结果:
// /@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
的模块使用。例如:
// 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开发服务器
的有效回调。
// /@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-reload
和 update
回调参数来进行区分。它还在模块不再使用时执行 prune
。
回调参数中还有其他属性,并非都是为 HMR
而设计的,但简要提及一下:
- connected:当建立 WebSocket 连接时发送。
- error:在服务器端出现报错时发送,
Vite
可以在浏览器控制台中显示错误的具体内容。 - custom:由
Vite
插件发送来通知客户端任何事件。对于客户端和服务器之间的互联很有用。
继续前进,让我们看看 HMR 更新
实际是如何工作的。
HMR update(HMR更新)
在 HMR 传播
过程中找到的每个 HMR 边界
通常对应于一个 HMR 更新
。在 Vite
中,更新采用这种签名:
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 update
或 css update
。
css update
被特殊处理为在 HTML
中更新时简单地交换 link
标签。
对于 js update
,我们需要找到对应的新模块并调用其 import.meta.hot.accept()
的回调函数,通过回调函数对自身应用执行 HMR
。由于在 createHotContext()
中我们已经将路径注册为第一个参数(即在页面执行时确定路径与执行函数之间的映射),那么我们可以很轻易通过 更新的路径
来找到相匹配的模块。并且根据 更新的时间戳,我们可以获取最新的模块信息,并将新模块传递给 import.meta.hot.accept()
的回调函数。实现逻辑简化如下:
// /@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)模块
),但不适用于第二个。第二个函数签名的回调只需要在依赖项更新时才被调用。我们可以将每个回调绑定到一组依赖项,如下:
// 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
处理程序如下:
// 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()
来确保旧模块被正确处理。
// 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 客户端
会独立地在运行时剪除这些无效的模块。
// /@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
消息即可:
// /@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.on
和 import.meta.hot.off
可用于监听和取消监听这些事件。
if (import.meta.hot) {
import.meta.hot.on('vite:invalidate', () => {
// ...
});
}
发出和跟踪这些事件与我们如何处理上面的 HMR 回调
非常相似。以 HMR失效
代码为例:
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
代码为例:
// 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
的全部内容!简而言之,我们学到了:
- 如何使用
HMR API
来处文件变更。 - 文件编辑如何导致
Vite开发服务器
向HMR客户端
发送HMR更新
。 HMR客户端
如何处理HMR负载
并触发正确的HMR API
。
在结束之前,请查看下面的常见问题解答,如果您对某些工作原理仍然有疑问。
常见的问题
我在哪里可以找到
Vite
的HMR
实现的源代码?- vite/src/client/client.ts - /@vite/client 的实现源码.
- vite/src/shared/hmr.ts - The HMR client implementation used by /@vite/client. Also abstracted away for the HMR client in SSR. (See HMRClient)
- vite/src/node/server/hmr.ts - Handle HMR propagation. (See handleHMRUpdate)
有没有可以学习的
HMR示例
?HMR
通常由JS框架
实现,其中引入了“组件”概念,每个组件都能够隔离其状态并重新初始化自身。因此,您可以查看React
、Vue
、Svelte
等框架如何实现它们:React:Fast Refresh 和 @vitejs/plugin-react
Svelte:svelte-hmr 和 @vitejs/plugin-svelte
Vite
的实现方式与Webpack
和其他工具有何不同?我还没有深入研究
Webpack
的实现,只是从Webpack
文档 和这篇NativeScript
文章 中了解Webpack
实现HMR
的原理。据我所知,常见的区别在于Webpack
处理HMR 传播
是在客户端而不是服务器端。这种差异有一个好处,即
HMR API
可以更动态地使用。相比之下,Vite
需要在服务器端静态解析模块中使用的HMR API
,来确定模块是否调用了import.meta.hot.accept()
。然而,在客户端处理HMR 传播
可能会很复杂,因为一些重要的信息(例如importers、exports、ids等)仅存在于服务器上。由于需要这些重要的信息,可能需要进行重构,在客户端获取序列化后的模块信息,并始终与服务端保持同步,这可能会很复杂。HMR在服务器端渲染中是如何工作的?
在撰写本文时(
Vite 5.0
),SSR
中的HMR
尚不受支持,但将作为Vite 5.1
的 实验性功能 推出。即使在SSR
中没有HMR
,对于Vue
和Svelte
这样的JS
框架,您仍然可以在客户端获取到HMR
。对服务器端代码进行更改需要完全重新执行 SSR 入口点,这可以通过
HMR 传播
来触发(这也适用于SSR
)。但通常情况下,服务器端代码的HMR 传播
会导致整个页面重新加载,这非常适合客户端重新向服务器发送请求,并由服务器执行重新执行操作。如何在handleHotUpdate()中触发页面重新加载?
handleHotUpdate()
API 旨在处理要无效的模块或处理HMR
传播。但是,可能存在检测到更改需要立即重新加载页面的情况。在
Vite
中,您可以使用server.ws.send({ type: 'full-reload' })
来触发完整页面重新加载,并确保模块失效且不进行HMR 传播
(这可能会错误地导致不必要的HMR
),您可以使用server.moduleGraph.invalidateModule()
。jsfunction 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 []; } } }; }
HMR API 有任何规范吗?
我知道的唯一规范是在这个 文档 中有提及,该规范已被存档。
Vite
在刚开始时实现了这个规范,但后来稍微有所偏离,例如import.meta.hot.decline()
没有被实现。如果您有兴趣实现自己的
HMR API
,可能需要在Vite
或Webpack
等之间选择一个版本。但从本质上讲,接受和使更改无效的术语将保持不变。有其他学习 HMR 的资源吗?
除了关于 Vite、Webpack 和 Parcel 的热模块替换(HMR)文档外,没有太多资源深入探讨
HMR
究竟是如何工作的。然而,以下是我发现有帮助的几个资源: