提示💡
幸福的三大要素是:有要做的事(something to do)、有要爱的人(someone to love)、有寄予希望的东西(something to hope for)
前言
用过Vite
进行项目开发的同学,肯定听说过,Vite
在开发环境和生产环境是两种不同的资源处理方式。
在开发环境,Vite
以原生ESM方式提供源码,让浏览器接管了打包程序的部分工作:Vite
只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
而在本地开发中,肯定会有本地代码的变更处理,如何最大限度的在不刷新整体页面的情况下,进行代码的替换呢。这就用到HMR[1]这一特性。而承载HMR
的部分就是,我们需要在开发阶段启动一个Dev Server
。体现在代码中就是我们在Vite
的配置文件- vite.config.ts
中会有一个单独的字段 - server
,更详细的解释可以参看vite_开发服务器选项[2]
提示💡
HMR
允许我们在不刷新页面的情况下更新代码,比如编辑组件标记或调整样式,这些更改会立即反映在浏览器中,从而实现更快的代码交互和更好的开发体验。
在生产环境中,Vite
利用Rollup
对代码进行打包处理,并配合着tree-shaking
/懒加载和chunk
分割的方式为浏览器提供最后的代码资源。体现在代码中就是我们在Vite
的配置文件- vite.config.ts
中会有一个单独的字段 - build
,更详细的解释可以参看vite_构建选项[3]
我们在之前的浅聊Vite中介绍过了,Vite
内部打包流程。
而今天我们来讲讲,在开发环境中,Vite
是如何实现HMR
的。
当然,针对不同的打包工具,可能有自己的实现原理。如果大家对其他工具的HMR
感兴趣。可以从下方链接中自行探索。
- webpack-hrm[4]
- rollup-plugin-hot[5]
当然,我们下面的内容,尽量从代码的顶层设计去探索,如果大家想看Vite -HMR
的具体实现可以找到对应的部分,自行探索。
- /@vite/client 源码[6] 下文会有对应介绍
- vite_hmr的核心部分[7]
- vite_hmr传播[8] 下文会有对应介绍
好了,天不早了,干点正事哇。
我们能所学到的知识点
- 模块替换
- HMR何时发生
- HMR 客户端
1. 模块替换
提示💡
模块替换
的基本原理是,在应用程序**「运行时动态替换模块」**。
大多数打包工具使用 ECMAScript
模块(ESM
)作为模块,因为它**「更容易分析导入和导出」**,这有助于确定一个模块的替换会如何影响其他相关模块。关于ESM
的介绍,可以看我们之前的文章~你真的了解ESM吗?
一个模块通常可以访问 HMR API
,以处理旧模块删除
和新模块新增
的情况。在 Vite
中,我们可以使用以下 API:
- import.meta.hot.accept()[9]
- import.meta.hot.dispose()[10]
- import.meta.hot.prune()[11]
- import.meta.hot.invalidate()[12]
从更高层次上看,我们就可以得到如下的模块处理流程。
还需要注意的是,我们需要使用这些 API
才能让 HMR
工作。例如,Vite
默认情况下会为 CSS
文件使用这些 API
,但对于像 Vue
这样的其他文件,我们可以使用一个 Vite
插件来使用这些 HMR API
。或者根据需要手动处理。否则,对文件的更新将导致默认情况下进行完整页面重新加载。
针对不同的语言环境,也是需要对应的插件进行这些api的调用处理。下面列举几个比较场景的插件实现
React
: Fast Refresh[13]和@vitejs/plugin-react[14]Vue
: @vue/runtime-core[15] 和 @vitejs/plugin-vue[16]Svelte
:svelte-hmr[17]和@vitejs/plugin-svelte[18]
在Vite
官网中,有这样的介绍,
上面是写插件需要注意的地方,而我们继续深入vite
中HRM
的对应API的工作原理。
accept()
import.meta.hot.accept()
当我们使用 import.meta.hot.accept()
添加一个回调时,该回调将负责**「用新模块替换旧模块」**。使用此 API 的模块也称为 已接受模块
。
已接受模块
创建了一个 HMR 边界
。一个 HMR 边界
包含模块本身以及所有递归导入的模块。接受模块
通常也是 HMR 边界
的 根
,因为边界通常是**「图形结构」**。
已接受模块
也可以根据 HMR回调
的位置缩小范围,如果accept
中只接受一个回调,此时模块被称为 自接受模块
。
import.meta.hot.accept
有两种函数签名:
import.meta.hot.accept(cb: Function)
- 接受来自自身的更改import.meta.hot.accept(deps: string | string[], cb: Function)
- 接受来自导入的模块的更改
如果使用第一种签名,就是自接受模块
。
自接受模块
export let data = [1, 2, 3] if (import.meta.hot) { import.meta.hot.accept((newModule) => { // 用新值替换旧值 data = newModule.data }) }
已接受模块
import { value } from './stuff.js' document.querySelector('#value').textContent = value if (import.meta.hot) { import.meta.hot.accept(['./stuff.js'], ([newModule]) => { // 用新值重新渲染 document.querySelector('#value').textContent = newModule.value }) }
dispose()
import.meta.hot.dispose()
当一个已接受模块
被替换为新模块
,或者被移除时
,我们可以使用 import.meta.hot.dispose()
进行清理。这允许我们清理掉旧模块创建的任何副作用
,例如删除事件监听器
、清除计时器
或重置状态
。
globalThis.__my_lib_data__ = {} if (import.meta.hot) { import.meta.hot.dispose(() => { // 重置全局状态 globalThis.__my_lib_data__ = {} }) }
prune()
import.meta.hot.prune()
当一个模块要从运行时**「完全移除时」,例如一个文件被删除,我们可以使用 import.meta.hot.prune()
进行「最终清理」**。这类似于 import.meta.hot.dispose()
,但只在模块被移除时调用一次。
Vite
通过导入分析阶段
来进行模块清理,因为我们能够知道**「一个模块不再被使用的唯一时机是当它不再被任何模块导入」**。
以下是 Vite
使用该 API 处理 CSS HMR
的示例:
// 导入用于更新/移除 HTML 中样式标签的工具 import { updateStyle, removeStyle } from '/@vite/client' updateStyle('/src/style.css', 'body { color: red; }') if (import.meta.hot) { // 空的回调表示我们接受了这个模块,但是我们可以啥都不做 // `updateStyle` 将自动删除旧的样式标签。 import.meta.hot.accept() // 当模块不再被使用时,移除样式 import.meta.hot.prune(() => { removeStyle('/src/style.css') }) }
invalidate()
import.meta.hot.invalidate()
与上述 API 不同,import.meta.hot.invalidate()
是一个**「操作」**,而不是一个生命周期钩子。我们通常会在 import.meta.hot.accept
中使用它,在运行时可能会意识到模块无法安全更新时,我们需要退出。
当调用这个方法时,Vite服务器
将被告知**「该模块已失效」**,就像该模块已被更新一样。HMR传播
将再次执行,以确定其导入者是否可以递归地接受此更改。
export let data = [1, 2, 3] if (import.meta.hot) { import.meta.hot.accept((newModule) => { // 如果 `data` 导出被删除或重命名 if (!(data in newModule)) { // 退出并使模块失效 import.meta.hot.invalidate() } }) }
上述就是针对涉及到HRM
的相关API的简单介绍。更具体的解释,可以参考vite_hmr[19]
2. HMR何时发生
既然,HMR API
赋予了我们替换
和删除
模块的能力,光有能力是不行的,我们需要了解它们何时才会起作用。其实,HMR
通常发生在**「编辑文件之后」**,但是之后又发生了啥,我们不得而知,这就是我们这节需要了解的内容。
它的总体流程如下:
让我们来逐步揭开它神秘的面纱!
编辑文件
当我们编辑文件并保存时,HMR
就开始了。文件系统监视器(例如 chokidar[20])会检测到更改并将编辑后的**「文件路径」**传递到下一步。
处理编辑后的模块
Vite 开发服务器
得知了编辑后的文件路径。然后**「使用文件路径来找到模块图中相关的模块」**。
❝
文件
和模块
是两个不同的概念,一个文件可能对应一个或多个模块。
例如,一个Vue
文件可以编译成一个JavaScript模块
和一个相关的CSS模块
。❞
然后,这些模块被传递给 Vite 插件
的 handleHotUpdate()
钩子进行进一步处理。它们可以选择过滤
或扩展
模块数组。最终的模块将传递到下一步。
过滤模块数组
在上一节介绍HMR API
时,就介绍过handleHotUpdate
,为了节省时间,我们再次将其搬过来。
function vuePlugin() { return { name: 'vue', handleHotUpdate(ctx) { if (ctx.file.endsWith('.vue')) { const oldContent = cache.get(ctx.file) const newContent = await ctx.read() // 如果编辑文件时只有样式发生了变化,我们可以过滤掉 JS 模块,并仅触发 CSS 模块的 HMR。 if (isOnlyStyleChanged(oldContent, newContent)) { return ctx.modules.filter(m => m.url.endsWith('.css')) } } } } }
上面只是一个简单示例,像我们比较熟悉的vite-vue
其实处理HMR
的逻辑差不多,只不过新增了一些额外的校验和处理。
更详细的代码是可以实现,可以参考github_vite-plugin-vue[21]
扩展模块数组
function globalCssPlugin() { return { name: 'global-css', handleHotUpdate(ctx) { if (ctx.file.endsWith('.css')) { // 如果编辑了 CSS 文件,我们还会触发此特殊的 `virtual:global-css` 模块的 HMR,该模块需要重新转换。 const mod = ctx.server.moduleGraph.getModuleById('virtual:global-css') if (mod) { return ctx.modules.concat(mod) } } } } }
模块失效
在 HMR传播
之前,我们需要将最终更新的模块数组及其导入者递归失效
。每个模块的**「转换代码都将被移除,并附加一个失效时间戳」**。时间戳将用于在客户端的下一个请求中获取新模块。
HMR 传播
现在,最终的更新模块数组将通过 HMR 传播
。这是HMR
是否起作用的核心步骤,如果传播过程有数据丢失,那么HMR
就会达不到我们想要的预期,也就是部分模块没及时更新或者更新失败了。
❝
HMR 传播
就是以更新的模块
作为起点,向四周扩散,最后找到与该模块相关的模块信息,并且形成一个**「无形」**的环。或者给它起一个更高大上的名字 -HMR 边界
如果所有更新的模块都在一个边界内,
Vite 开发服务器
将通知HMR 客户端
通知接受的模块执行HMR
。如果有些模块不在边界内,则会触发完整的页面重新加载。
❞
案例分析
为了更好地理解它是如何工作的,让我们来看几个例子。
-
app.jsx
是一个接受模块
,也就意味着,在其内部触发了import.meta.hot.accept()
-
与
app.jsx
相关的文件有stuff.js
和utils.js
,也就意味着,它们三个会形成一个HMR 边界
情况 1
如果更新 stuff.js
,传播将**「递归查找」**其导入者以找到一个接受的模块
。在这种情况下,我们将发现 app.jsx
是一个接受的模块。但在结束传播之前,我们需要确定 app.jsx
是否可以接受来自 stuff.js
的更改。这取决于 import.meta.hot.accept()
的调用方式。
-
情况 1(a):如果
app.jsx
是自接受
的,或者它接受来自stuff.js
的更改,我们可以在这里停止传播,因为没有其他来自stuff.js
的导入者。然后,HMR 客户端
将通知app.jsx
执行HMR
。 -
情况 1(b):如果
app.jsx
不接受这个更改,我们将继续向上传播以找到一个接受的模块。但由于没有其他接受的模块,我们将到达项目的**「根节点」** -index.html
文件。此时将触发整个项目的重新加载。
情况 2:
如果更新 main.js
或 other.js
,传播将再次递归查找其**「导入者」。然而,没有接受的模块,我们将到达项目的「根节点」** - index.html
文件。因此,将触发完整的页面重新加载。
情况 3:
如果更新 app.jsx
,我们立即发现它是一个接受的模块。然而,一些模块可能无法更新其自身的更改。我们可以通过检查它们是否是自接受的模块来确定它们是否可以更新自身。
-
情况 3(a):如果
app.jsx
是自接受的,我们可以在这里停止,并让HMR 客户端
通知它执行 HMR。 -
情况 3(b):如果
app.jsx
不是自接受的,我们将继续向上传播以找到一个接受的模块。但由于它们都没有,我们将到达项目的**「根节点」** -index.html
文件,将触发完整的页面重新加载。
情况 4:
如果更新 utils.js
,传播将再次递归查找其导入者。首先,我们将找到 app.jsx
作为接受的模块,并在那里停止传播(例如情况 1(a)
)。然后,我们也会递归查找 other.js
及其导入者,但没有接受的模块,我们将到达项目的**「根节点」** - index.html
文件。
❝
最后,
HMR传播
的结果是是否需要进行完整页面重新加载,或者是否应该在客户端应用 HMR 更新。❞
3. HMR 客户端
在 Vite
应用中,我们可能会注意到 HTML
中添加了一个特殊的脚本<script type="module" src="/@vite/client"></script>
,请求 /@vite/client
。这个脚本包含了 HMR 客户端
!
我们可以在Chrome-devtool-sources
中进行查看
❝
HMR 客户端
负责:
与
Vite 开发服务器
建立WebSocket
连接。监听来自服务器的
HMR 载荷
。在运行时提供和触发
HMR API
。将任何事件发送回
Vite 开发服务器
。❞
从更广泛的角度来看,HMR 客户端
帮助将 Vite 开发服务器
和 HMR API
粘合在一起。
客户端初始化
在 HMR 客户端
能够从 Vite 开发服务器
接收任何消息之前,它首先需要建立与其的连接,通常是通过 WebSockets[22]。
下面是一个设置 WebSocket
连接并处理 HMR 传播
结果的示例:
/@vite/client
const ws = new WebSocket('ws://localhost:5173') ws.addEventListener('message', ({ data }) => { const payload = JSON.parse(data) switch (payload.type) { case '...': // 处理载荷... } }) // 将任何事件发送回 Vite 开发服务器 ws.send('...')
除此之外,HMR 客户端
还初始化了一些处理 HMR
所需的状态,并导出了几个 API,例如 createHotContext()
,供使用 HMR API
的模块使用。例如:
app.jsx
// 由 Vite 的导入分析插件注入 import { createHotContext } from '/@vite/client' import.meta.hot = createHotContext('/src/app.jsx') export default function App() { return <div>Hello Front789</div> } // 由 `@vitejs/plugin-react` 注入 if (import.meta.hot) { // ... }
传递给 createHotContext()
的 URL 字符串(也称为 owner 路径
)有助于**「标识哪个模块能够接受更改」**。在createHotContext
将注册的 HMR 回调分配单例类型,而该类型用于存储owner 路径
到接受回调、处理回调和修剪回调之间的关联信息。const ownerPathToAcceptCallbacks = new Map<string, Function[]>()
这基本上就是模块如何与 HMR 客户端交互并执行 HMR 更改的方式。
处理来自服务器的信息
建立 WebSocket
连接后,我们可以开始处理来自 Vite 开发服务器
的信息。
/@vite/client
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 } // 处理其他载荷类型... } })
上面的示例处理了 HMR 传播
的结果,根据 full-reload
和 update
信息类型触发完整页面重新加载或 HMR 更新
。当模块不再使用时,它还处理修剪。
还有更多类型的信息类型需要处理
-
connected
:当建立WebSocket
连接时发送。 -
error
:当服务器端出现错误时发送,Vite
可以在浏览器中显示错误覆盖层。 -
custom
:由Vite
插件发送,通知客户端任何事件。对于客户端和服务器之间的通信非常有用。
接下来,让我们看看 HMR 更新
实际上是如何工作的。
HMR 更新
❝
HMR 传播
期间找到的每个HMR 边界
通常对应一个HMR 更新
。❞
在 Vite
中,更新采用这种签名:
interface Update { // 更新的类型 type: 'js-update' | 'css-update' // 接受模块(HMR 边界根)的 URL 路径 path: string // 被接受的 URL 路径(通常与上面的路径相同) acceptedPath: string // 更新发生的时间戳 timestamp: number }
在 Vite
中,它被区分为 JS 更新
或 CSS 更新
,其中 CSS 更新
被特别处理为在更新时简单地交换 HTML
中的链接标签。
对于 JS 更新
,我们需要找到相应的模块,以调用其 import.meta.hot.accept()
回调,以便它可以对自身应用 HMR。由于在 createHotContext()
中我们已经**「将路径注册为第一个参数」**,因此我们可以通过更新的路径轻松找到匹配的模块。有了更新的时间戳,我们还可以获取模块的新版本以传递给 import.meta.hot.accept()
。
下面是一个实现的示例:
/@vite/client
// 由 `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)
上面的实现对于第一个函数签名(自接受模块
)的情况处理良好,但对于第二个函数签名则不适用。第二个函数签名的**「回调函数只有在依赖项发生更改时才需要被调用」**。为了解决这个问题,我们可以将每个回调函数绑定到一组依赖项。
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
,而 path
将是 /src/app.jsx
。这样,我们可以通知拥有者路径
接受路径(acceptedPath
)已经更新,而拥有者可以处理其更改。我们可以调整 HMR 处理程序如下:
/@vite/client
// 由 `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) { // 确保只执行可以处理 `acceptedPath` 的回调函数 if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) { cb.fn(newModule) } } }
但我们还没有完成!在导入新模块之前,我们还需要确保正确处理旧模块,使用 import.meta.hot.dispose()
。
/@vite/client
// 由 `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) // 如果有的话调用 dispose 回调 ownerPathToDisposeCallbacks.get(update.path)?.() const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`) for (const cb of acceptCbs) { // 确保只执行可以处理 `acceptedPath` 的回调函数 if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) { cb.fn(newModule) } } }
上面的代码基本上实现了大部分的 HMR 客户端
!
HMR 修剪
我们之前聊过,在 导入分析
阶段,Vite
内部处理了 HMR 修剪
。当一个模块不再被任何其他模块导入时,Vite 开发服务器
将向 HMR 客户端
发送一个 { type: 'prune', paths: string[] }
载荷,其中它将独立地在运行时修剪模块。
/@vite/client
// 由 `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 作废
与其他 HMR API
不同,import.meta.hot.invalidate()
是可以在 import.meta.hot.accept()
中调用的动作,以退出 HMR
。在 /@vite/client
中,只需发送一个 WebSocket
消息到 Vite 开发服务器
:
/@vite/client
// `ownerPath` 来自于 `createHotContext()` function handleInvalidate(ownerPath: string) { ws.send( JSON.stringify({ type: 'custom', event: 'vite:invalidate', data: { path: ownerPath } }) ) }
当 Vite 服务器
接收到此消息时,它将从其导入者再次执行 HMR 传播
,结果(完整重新加载或 HMR 更新)将发送回 HMR 客户端
。
HMR 事件
虽然不是 HMR
必需的,但 HMR 客户端
还可以在运行时发出事件,当收到特定信息时。import.meta.hot.on
和 import.meta.hot.off
可以用于监听和取消监听这些事件。
if (import.meta.hot) { import.meta.hot.on('vite:invalidate', () => { // ... }) }
发出和跟踪这些事件与上面处理 HMR 回调的方式非常相似。
/@vite/client(URL)
+ const eventNameToCallbacks = new Map<string, Set<Function>>() // `ownerPath` 来自于 `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 数据
最后,HMR 客户端
还提供了一种存储数据以在 HMR API 之间共享的方法,即 import.meta.hot.data
。这些数据也可以传递给 import.meta.hot.dispose()
和 import.meta.hot.prune()
的 HMR 回调函数。
保留数据也与我们跟踪 HMR 回调的方式类似。
以 HMR 修剪
代码为例:
/@vite/client
// 由 `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) } }