Vite 熱更新(HMR)原理了解一下
前言
用過(guò)Vite進(jìn)行項(xiàng)目開(kāi)發(fā)的同學(xué),肯定聽(tīng)說(shuō)過(guò),Vite在開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境是兩種不同的資源處理方式。
在開(kāi)發(fā)環(huán)境,Vite以原生ESM方式提供源碼,讓瀏覽器接管了打包程序的部分工作:Vite 只需要在瀏覽器請(qǐng)求源碼時(shí)進(jìn)行轉(zhuǎn)換并按需提供源碼。根據(jù)情景動(dòng)態(tài)導(dǎo)入代碼,即只在當(dāng)前屏幕上實(shí)際使用時(shí)才會(huì)被處理。
圖片
而在本地開(kāi)發(fā)中,肯定會(huì)有本地代碼的變更處理,如何最大限度的在不刷新整體頁(yè)面的情況下,進(jìn)行代碼的替換呢。這就用到HMR[1]這一特性。而承載HMR的部分就是,我們需要在開(kāi)發(fā)階段啟動(dòng)一個(gè)Dev Server。體現(xiàn)在代碼中就是我們?cè)赩ite的配置文件- vite.config.ts中會(huì)有一個(gè)單獨(dú)的字段 - server,更詳細(xì)的解釋可以參看vite_開(kāi)發(fā)服務(wù)器選項(xiàng)[2]
HMR 允許我們?cè)诓凰⑿马?yè)面的情況下更新代碼,比如編輯組件標(biāo)記或調(diào)整樣式,這些更改會(huì)立即反映在瀏覽器中,從而實(shí)現(xiàn)更快的代碼交互和更好的開(kāi)發(fā)體驗(yàn)。
在生產(chǎn)環(huán)境中,Vite利用Rollup對(duì)代碼進(jìn)行打包處理,并配合著tree-shaking/懶加載和chunk分割的方式為瀏覽器提供最后的代碼資源。體現(xiàn)在代碼中就是我們?cè)赩ite的配置文件- vite.config.ts中會(huì)有一個(gè)單獨(dú)的字段 - build,更詳細(xì)的解釋可以參看vite_構(gòu)建選項(xiàng)[3]
我們?cè)谥暗臏\聊Vite中介紹過(guò)了,Vite內(nèi)部打包流程。
圖片
而今天我們來(lái)講講,在開(kāi)發(fā)環(huán)境中,Vite是如何實(shí)現(xiàn)HMR的。
當(dāng)然,針對(duì)不同的打包工具,可能有自己的實(shí)現(xiàn)原理。如果大家對(duì)其他工具的HMR感興趣??梢詮南路芥溄又凶孕刑剿?。
- webpack-hrm[4]
- rollup-plugin-hot[5]
當(dāng)然,我們下面的內(nèi)容,盡量從代碼的頂層設(shè)計(jì)去探索,如果大家想看Vite -HMR的具體實(shí)現(xiàn)可以找到對(duì)應(yīng)的部分,自行探索。
- /@vite/client 源碼[6] 下文會(huì)有對(duì)應(yīng)介紹
- vite_hmr的核心部分[7]
- vite_hmr傳播[8] 下文會(huì)有對(duì)應(yīng)介紹
好了,天不早了,干點(diǎn)正事哇。
我們能所學(xué)到的知識(shí)點(diǎn)
- 模塊替換
- HMR何時(shí)發(fā)生
- HMR 客戶(hù)端
1. 模塊替換
模塊替換的基本原理是,在應(yīng)用程序「運(yùn)行時(shí)動(dòng)態(tài)替換模塊」。
大多數(shù)打包工具使用 ECMAScript 模塊(ESM)作為模塊,因?yàn)樗父菀追治鰧?dǎo)入和導(dǎo)出」,這有助于確定一個(gè)模塊的替換會(huì)如何影響其他相關(guān)模塊。關(guān)于ESM的介紹,可以看我們之前的文章~你真的了解ESM嗎?
一個(gè)模塊通??梢栽L問(wèn) 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 默認(rèn)情況下會(huì)為 CSS 文件使用這些 API,但對(duì)于像 Vue 這樣的其他文件,我們可以使用一個(gè) Vite 插件來(lái)使用這些 HMR API?;蛘吒鶕?jù)需要手動(dòng)處理。否則,對(duì)文件的更新將導(dǎo)致默認(rèn)情況下進(jìn)行完整頁(yè)面重新加載。
針對(duì)不同的語(yǔ)言環(huán)境,也是需要對(duì)應(yīng)的插件進(jìn)行這些api的調(diào)用處理。下面列舉幾個(gè)比較場(chǎng)景的插件實(shí)現(xiàn)
- 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官網(wǎng)中,有這樣的介紹,
圖片
而handleHotUpdate用于處理HRM更新。
我們從vite-vue中就可以看到對(duì)應(yīng)的處理過(guò)程。
?
上面是寫(xiě)插件需要注意的地方,而我們繼續(xù)深入vite中HRM的對(duì)應(yīng)API的工作原理。
accept()
import.meta.hot.accept()
當(dāng)我們使用 import.meta.hot.accept() 添加一個(gè)回調(diào)時(shí),該回調(diào)將負(fù)責(zé)「用新模塊替換舊模塊」。使用此 API 的模塊也稱(chēng)為 已接受模塊。
已接受模塊創(chuàng)建了一個(gè) HMR 邊界。一個(gè) HMR 邊界包含模塊本身以及所有遞歸導(dǎo)入的模塊。接受模塊通常也是 HMR 邊界的 根,因?yàn)檫吔缤ǔJ恰笀D形結(jié)構(gòu)」。
圖片
已接受模塊也可以根據(jù) HMR回調(diào)的位置縮小范圍,如果accept中只接受一個(gè)回調(diào),此時(shí)模塊被稱(chēng)為 自接受模塊。
import.meta.hot.accept 有兩種函數(shù)簽名:
- import.meta.hot.accept(cb: Function) - 接受來(lái)自自身的更改
- import.meta.hot.accept(deps: string | string[], cb: Function) - 接受來(lái)自導(dǎo)入的模塊的更改
如果使用第一種簽名,就是自接受模塊。
自接受模塊
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()
當(dāng)一個(gè)已接受模塊被替換為新模塊,或者被移除時(shí),我們可以使用 import.meta.hot.dispose() 進(jìn)行清理。這允許我們清理掉舊模塊創(chuàng)建的任何副作用,例如刪除事件監(jiān)聽(tīng)器、清除計(jì)時(shí)器或重置狀態(tài)。
globalThis.__my_lib_data__ = {}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
// 重置全局狀態(tài)
globalThis.__my_lib_data__ = {}
})
}
prune()
import.meta.hot.prune()
當(dāng)一個(gè)模塊要從運(yùn)行時(shí)「完全移除時(shí)」,例如一個(gè)文件被刪除,我們可以使用 import.meta.hot.prune() 進(jìn)行「最終清理」。這類(lèi)似于 import.meta.hot.dispose(),但只在模塊被移除時(shí)調(diào)用一次。
Vite 通過(guò)導(dǎo)入分析階段來(lái)進(jìn)行模塊清理,因?yàn)槲覀兡軌蛑馈敢粋€(gè)模塊不再被使用的唯一時(shí)機(jī)是當(dāng)它不再被任何模塊導(dǎo)入」。
以下是 Vite 使用該 API 處理 CSS HMR 的示例:
// 導(dǎo)入用于更新/移除 HTML 中樣式標(biāo)簽的工具
import { updateStyle, removeStyle } from '/@vite/client'
updateStyle('/src/style.css', 'body { color: red; }')
if (import.meta.hot) {
// 空的回調(diào)表示我們接受了這個(gè)模塊,但是我們可以啥都不做
// `updateStyle` 將自動(dòng)刪除舊的樣式標(biāo)簽。
import.meta.hot.accept()
// 當(dāng)模塊不再被使用時(shí),移除樣式
import.meta.hot.prune(() => {
removeStyle('/src/style.css')
})
}
invalidate()
import.meta.hot.invalidate()
與上述 API 不同,import.meta.hot.invalidate() 是一個(gè)「操作」,而不是一個(gè)生命周期鉤子。我們通常會(huì)在 import.meta.hot.accept 中使用它,在運(yùn)行時(shí)可能會(huì)意識(shí)到模塊無(wú)法安全更新時(shí),我們需要退出。
當(dāng)調(diào)用這個(gè)方法時(shí),Vite服務(wù)器將被告知「該模塊已失效」,就像該模塊已被更新一樣。HMR傳播將再次執(zhí)行,以確定其導(dǎo)入者是否可以遞歸地接受此更改。
export let data = [1, 2, 3]
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 如果 `data` 導(dǎo)出被刪除或重命名
if (!(data in newModule)) {
// 退出并使模塊失效
import.meta.hot.invalidate()
}
})
}
上述就是針對(duì)涉及到HRM的相關(guān)API的簡(jiǎn)單介紹。更具體的解釋?zhuān)梢詤⒖紇ite_hmr[19]
2. HMR何時(shí)發(fā)生
既然,HMR API賦予了我們替換和刪除模塊的能力,光有能力是不行的,我們需要了解它們何時(shí)才會(huì)起作用。其實(shí),HMR 通常發(fā)生在「編輯文件之后」,但是之后又發(fā)生了啥,我們不得而知,這就是我們這節(jié)需要了解的內(nèi)容。
它的總體流程如下:
圖片
讓我們來(lái)逐步揭開(kāi)它神秘的面紗!
編輯文件
當(dāng)我們編輯文件并保存時(shí),HMR 就開(kāi)始了。文件系統(tǒng)監(jiān)視器(例如 chokidar[20])會(huì)檢測(cè)到更改并將編輯后的「文件路徑」傳遞到下一步。
處理編輯后的模塊
Vite 開(kāi)發(fā)服務(wù)器得知了編輯后的文件路徑。然后「使用文件路徑來(lái)找到模塊圖中相關(guān)的模塊」。
文件和模塊是兩個(gè)不同的概念,一個(gè)文件可能對(duì)應(yīng)一個(gè)或多個(gè)模塊。例如,一個(gè) Vue 文件可以編譯成一個(gè) JavaScript模塊和一個(gè)相關(guān)的 CSS模塊。
然后,這些模塊被傳遞給 Vite 插件的 handleHotUpdate() 鉤子進(jìn)行進(jìn)一步處理。它們可以選擇過(guò)濾或擴(kuò)展模塊數(shù)組。最終的模塊將傳遞到下一步。
過(guò)濾模塊數(shù)組
在上一節(jié)介紹HMR API時(shí),就介紹過(guò)handleHotUpdate,為了節(jié)省時(shí)間,我們?cè)俅螌⑵浒徇^(guò)來(lái)。
圖片
function vuePlugin() {
return {
name: 'vue',
handleHotUpdate(ctx) {
if (ctx.file.endsWith('.vue')) {
const oldContent = cache.get(ctx.file)
const newContent = await ctx.read()
// 如果編輯文件時(shí)只有樣式發(fā)生了變化,我們可以過(guò)濾掉 JS 模塊,并僅觸發(fā) CSS 模塊的 HMR。
if (isOnlyStyleChanged(oldContent, newContent)) {
return ctx.modules.filter(m => m.url.endsWith('.css'))
}
}
}
}
}
上面只是一個(gè)簡(jiǎn)單示例,像我們比較熟悉的vite-vue其實(shí)處理HMR的邏輯差不多,只不過(guò)新增了一些額外的校驗(yàn)和處理。
圖片
更詳細(xì)的代碼是可以實(shí)現(xiàn),可以參考github_vite-plugin-vue[21]
擴(kuò)展模塊數(shù)組
function globalCssPlugin() {
return {
name: 'global-css',
handleHotUpdate(ctx) {
if (ctx.file.endsWith('.css')) {
// 如果編輯了 CSS 文件,我們還會(huì)觸發(fā)此特殊的 `virtual:global-css` 模塊的 HMR,該模塊需要重新轉(zhuǎn)換。
const mod = ctx.server.moduleGraph.getModuleById('virtual:global-css')
if (mod) {
return ctx.modules.concat(mod)
}
}
}
}
}
模塊失效
在 HMR傳播之前,我們需要將最終更新的模塊數(shù)組及其導(dǎo)入者遞歸失效。每個(gè)模塊的「轉(zhuǎn)換代碼都將被移除,并附加一個(gè)失效時(shí)間戳」。時(shí)間戳將用于在客戶(hù)端的下一個(gè)請(qǐng)求中獲取新模塊。
HMR 傳播
現(xiàn)在,最終的更新模塊數(shù)組將通過(guò) HMR 傳播。這是HMR是否起作用的核心步驟,如果傳播過(guò)程有數(shù)據(jù)丟失,那么HMR就會(huì)達(dá)不到我們想要的預(yù)期,也就是部分模塊沒(méi)及時(shí)更新或者更新失敗了。
HMR 傳播就是以更新的模塊作為起點(diǎn),向四周擴(kuò)散,最后找到與該模塊相關(guān)的模塊信息,并且形成一個(gè)「無(wú)形」的環(huán)?;蛘呓o它起一個(gè)更高大上的名字 - HMR 邊界
- 如果所有更新的模塊都在一個(gè)邊界內(nèi),Vite 開(kāi)發(fā)服務(wù)器將通知 HMR 客戶(hù)端通知接受的模塊執(zhí)行 HMR。
- 如果有些模塊不在邊界內(nèi),則會(huì)觸發(fā)完整的頁(yè)面重新加載。
案例分析
為了更好地理解它是如何工作的,讓我們來(lái)看幾個(gè)例子。
圖片
- app.jsx 是一個(gè)接受模塊,也就意味著,在其內(nèi)部觸發(fā)了import.meta.hot.accept()
- 與app.jsx相關(guān)的文件有stuff.js和utils.js,也就意味著,它們?nèi)齻€(gè)會(huì)形成一個(gè)HMR 邊界
情況 1
如果更新 stuff.js,傳播將「遞歸查找」其導(dǎo)入者以找到一個(gè)接受的模塊。在這種情況下,我們將發(fā)現(xiàn) app.jsx 是一個(gè)接受的模塊。但在結(jié)束傳播之前,我們需要確定 app.jsx 是否可以接受來(lái)自 stuff.js 的更改。這取決于 import.meta.hot.accept() 的調(diào)用方式。
- 情況 1(a):如果 app.jsx 是自接受的,或者它接受來(lái)自 stuff.js 的更改,我們可以在這里停止傳播,因?yàn)闆](méi)有其他來(lái)自 stuff.js 的導(dǎo)入者。然后,HMR 客戶(hù)端將通知 app.jsx 執(zhí)行 HMR。
- 情況 1(b):如果 app.jsx 不接受這個(gè)更改,我們將繼續(xù)向上傳播以找到一個(gè)接受的模塊。但由于沒(méi)有其他接受的模塊,我們將到達(dá)項(xiàng)目的「根節(jié)點(diǎn)」 - index.html 文件。此時(shí)將觸發(fā)整個(gè)項(xiàng)目的重新加載。
情況 2:
如果更新 main.js 或 other.js,傳播將再次遞歸查找其「導(dǎo)入者」。然而,沒(méi)有接受的模塊,我們將到達(dá)項(xiàng)目的「根節(jié)點(diǎn)」 - index.html 文件。因此,將觸發(fā)完整的頁(yè)面重新加載。
情況 3:
如果更新 app.jsx,我們立即發(fā)現(xiàn)它是一個(gè)接受的模塊。然而,一些模塊可能無(wú)法更新其自身的更改。我們可以通過(guò)檢查它們是否是自接受的模塊來(lái)確定它們是否可以更新自身。
- 情況 3(a):如果 app.jsx 是自接受的,我們可以在這里停止,并讓 HMR 客戶(hù)端通知它執(zhí)行 HMR。
- 情況 3(b):如果 app.jsx不是自接受的,我們將繼續(xù)向上傳播以找到一個(gè)接受的模塊。但由于它們都沒(méi)有,我們將到達(dá)項(xiàng)目的「根節(jié)點(diǎn)」 - index.html 文件,將觸發(fā)完整的頁(yè)面重新加載。
情況 4:
如果更新 utils.js,傳播將再次遞歸查找其導(dǎo)入者。首先,我們將找到 app.jsx 作為接受的模塊,并在那里停止傳播(例如情況 1(a))。然后,我們也會(huì)遞歸查找 other.js 及其導(dǎo)入者,但沒(méi)有接受的模塊,我們將到達(dá)項(xiàng)目的「根節(jié)點(diǎn)」 - index.html 文件。
最后,HMR傳播的結(jié)果是是否需要進(jìn)行完整頁(yè)面重新加載,或者是否應(yīng)該在客戶(hù)端應(yīng)用 HMR 更新。
3. HMR 客戶(hù)端
在 Vite 應(yīng)用中,我們可能會(huì)注意到 HTML 中添加了一個(gè)特殊的腳本<script type="module" src="/@vite/client"></script>,請(qǐng)求 /@vite/client。這個(gè)腳本包含了 HMR 客戶(hù)端!
我們可以在Chrome-devtool-sources中進(jìn)行查看
圖片
HMR 客戶(hù)端負(fù)責(zé):
- 與 Vite 開(kāi)發(fā)服務(wù)器建立 WebSocket 連接。
- 監(jiān)聽(tīng)來(lái)自服務(wù)器的 HMR 載荷。
- 在運(yùn)行時(shí)提供和觸發(fā) HMR API。
- 將任何事件發(fā)送回 Vite 開(kāi)發(fā)服務(wù)器。
從更廣泛的角度來(lái)看,HMR 客戶(hù)端幫助將 Vite 開(kāi)發(fā)服務(wù)器和 HMR API 粘合在一起。
圖片
客戶(hù)端初始化
在 HMR 客戶(hù)端能夠從 Vite 開(kāi)發(fā)服務(wù)器接收任何消息之前,它首先需要建立與其的連接,通常是通過(guò) WebSockets[22]。
下面是一個(gè)設(shè)置 WebSocket 連接并處理 HMR 傳播結(jié)果的示例:
/@vite/client
const ws = new WebSocket('ws://localhost:5173')
ws.addEventListener('message', ({ data }) => {
const payload = JSON.parse(data)
switch (payload.type) {
case '...':
// 處理載荷...
}
})
// 將任何事件發(fā)送回 Vite 開(kāi)發(fā)服務(wù)器
ws.send('...')
除此之外,HMR 客戶(hù)端還初始化了一些處理 HMR 所需的狀態(tài),并導(dǎo)出了幾個(gè) API,例如 createHotContext(),供使用 HMR API 的模塊使用。例如:
app.jsx
// 由 Vite 的導(dǎo)入分析插件注入
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 字符串(也稱(chēng)為 owner 路徑)有助于「標(biāo)識(shí)哪個(gè)模塊能夠接受更改」。在createHotContext 將注冊(cè)的 HMR 回調(diào)分配單例類(lèi)型,而該類(lèi)型用于存儲(chǔ)owner 路徑到接受回調(diào)、處理回調(diào)和修剪回調(diào)之間的關(guān)聯(lián)信息。const ownerPathToAcceptCallbacks = new Map<string, Function[]>()
這基本上就是模塊如何與 HMR 客戶(hù)端交互并執(zhí)行 HMR 更改的方式。
處理來(lái)自服務(wù)器的信息
建立 WebSocket 連接后,我們可以開(kāi)始處理來(lái)自 Vite 開(kāi)發(fā)服務(wù)器的信息。
/@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
}
// 處理其他載荷類(lèi)型...
}
})
上面的示例處理了 HMR 傳播的結(jié)果,根據(jù) full-reload 和 update 信息類(lèi)型觸發(fā)完整頁(yè)面重新加載或 HMR 更新。當(dāng)模塊不再使用時(shí),它還處理修剪。
還有更多類(lèi)型的信息類(lèi)型需要處理
- connected:當(dāng)建立 WebSocket 連接時(shí)發(fā)送。
- error:當(dāng)服務(wù)器端出現(xiàn)錯(cuò)誤時(shí)發(fā)送,Vite 可以在瀏覽器中顯示錯(cuò)誤覆蓋層。
- custom:由 Vite 插件發(fā)送,通知客戶(hù)端任何事件。對(duì)于客戶(hù)端和服務(wù)器之間的通信非常有用。
接下來(lái),讓我們看看 HMR 更新實(shí)際上是如何工作的。
HMR 更新
HMR 傳播期間找到的每個(gè) HMR 邊界通常對(duì)應(yīng)一個(gè) HMR 更新。
在 Vite 中,更新采用這種簽名:
interface Update {
// 更新的類(lèi)型
type: 'js-update' | 'css-update'
// 接受模塊(HMR 邊界根)的 URL 路徑
path: string
// 被接受的 URL 路徑(通常與上面的路徑相同)
acceptedPath: string
// 更新發(fā)生的時(shí)間戳
timestamp: number
}
在 Vite 中,它被區(qū)分為 JS 更新 或 CSS 更新,其中 CSS 更新被特別處理為在更新時(shí)簡(jiǎn)單地交換 HTML 中的鏈接標(biāo)簽。
對(duì)于 JS 更新,我們需要找到相應(yīng)的模塊,以調(diào)用其 import.meta.hot.accept() 回調(diào),以便它可以對(duì)自身應(yīng)用 HMR。由于在 createHotContext() 中我們已經(jīng)「將路徑注冊(cè)為第一個(gè)參數(shù)」,因此我們可以通過(guò)更新的路徑輕松找到匹配的模塊。有了更新的時(shí)間戳,我們還可以獲取模塊的新版本以傳遞給 import.meta.hot.accept()。
下面是一個(gè)實(shí)現(xiàn)的示例:
/@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)
}
}
之前我們就介紹過(guò),import.meta.hot.accept() 有兩個(gè)函數(shù)簽名
- import.meta.hot.accept(cb: Function)
- import.meta.hot.accept(deps: string | string[], cb: Function)
上面的實(shí)現(xiàn)對(duì)于第一個(gè)函數(shù)簽名(自接受模塊)的情況處理良好,但對(duì)于第二個(gè)函數(shù)簽名則不適用。第二個(gè)函數(shù)簽名的「回調(diào)函數(shù)只有在依賴(lài)項(xiàng)發(fā)生更改時(shí)才需要被調(diào)用」。為了解決這個(gè)問(wèn)題,我們可以將每個(gè)回調(diào)函數(shù)綁定到一組依賴(lài)項(xiàng)。
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 來(lái)匹配依賴(lài)關(guān)系并觸發(fā)正確的回調(diào)函數(shù)。
例如,如果更新了 stuff.js,那么 acceptedPath 將是 /src/stuff.js,而 path 將是 /src/app.jsx。這樣,我們可以通知擁有者路徑接受路徑(acceptedPath)已經(jīng)更新,而擁有者可以處理其更改。我們可以調(diào)整 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) {
// 確保只執(zhí)行可以處理 `acceptedPath` 的回調(diào)函數(shù)
if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
cb.fn(newModule)
}
}
}
但我們還沒(méi)有完成!在導(dǎo)入新模塊之前,我們還需要確保正確處理舊模塊,使用 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)
// 如果有的話(huà)調(diào)用 dispose 回調(diào)
ownerPathToDisposeCallbacks.get(update.path)?.()
const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)
for (const cb of acceptCbs) {
// 確保只執(zhí)行可以處理 `acceptedPath` 的回調(diào)函數(shù)
if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
cb.fn(newModule)
}
}
}
上面的代碼基本上實(shí)現(xiàn)了大部分的 HMR 客戶(hù)端!
HMR 修剪
我們之前聊過(guò),在 導(dǎo)入分析 階段,Vite 內(nèi)部處理了 HMR 修剪。當(dāng)一個(gè)模塊不再被任何其他模塊導(dǎo)入時(shí),Vite 開(kāi)發(fā)服務(wù)器將向 HMR 客戶(hù)端發(fā)送一個(gè) { type: 'prune', paths: string[] } 載荷,其中它將獨(dú)立地在運(yùn)行時(shí)修剪模塊。
/@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() 中調(diào)用的動(dòng)作,以退出 HMR。在 /@vite/client 中,只需發(fā)送一個(gè) WebSocket 消息到 Vite 開(kāi)發(fā)服務(wù)器:
/@vite/client
// `ownerPath` 來(lái)自于 `createHotContext()`
function handleInvalidate(ownerPath: string) {
ws.send(
JSON.stringify({
type: 'custom',
event: 'vite:invalidate',
data: { path: ownerPath }
})
)
}
當(dāng) Vite 服務(wù)器接收到此消息時(shí),它將從其導(dǎo)入者再次執(zhí)行 HMR 傳播,結(jié)果(完整重新加載或 HMR 更新)將發(fā)送回 HMR 客戶(hù)端。
HMR 事件
雖然不是 HMR 必需的,但 HMR 客戶(hù)端還可以在運(yùn)行時(shí)發(fā)出事件,當(dāng)收到特定信息時(shí)。import.meta.hot.on 和 import.meta.hot.off 可以用于監(jiān)聽(tīng)和取消監(jiān)聽(tīng)這些事件。
if (import.meta.hot) {
import.meta.hot.on('vite:invalidate', () => {
// ...
})
}
發(fā)出和跟蹤這些事件與上面處理 HMR 回調(diào)的方式非常相似。
/@vite/client(URL)
+ const eventNameToCallbacks = new Map<string, Set<Function>>()
// `ownerPath` 來(lái)自于 `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 數(shù)據(jù)
最后,HMR 客戶(hù)端還提供了一種存儲(chǔ)數(shù)據(jù)以在 HMR API 之間共享的方法,即 import.meta.hot.data。這些數(shù)據(jù)也可以傳遞給 import.meta.hot.dispose() 和 import.meta.hot.prune() 的 HMR 回調(diào)函數(shù)。
保留數(shù)據(jù)也與我們跟蹤 HMR 回調(diào)的方式類(lèi)似。
以 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)
}
}