大家好,我是码农小余。上一小节我们知道了当文件修改后,会触发文件监听实例 watcher 的 change 事件,更新模块信息和计算 HMR 边界。Websocket 会将计算得到的更新边界传给浏览器,浏览器拿到这些信息之后具体是怎么处理的呢?本文就来解开后续秘密。
本文的例子直接复用上一小节的即可,我就直接照搬过来了:
// bar.js
export const name = 'bar.js'
// foo.js
import { name } from './bar'
export function sayName () {
console.log(name);
return name
}
if (import.meta.hot) {
import.meta.hot.accept('./bar.js')
}
// main.js
import './style.css'
import { sayName } from './foo'
sayName()
if (import.meta.hot) {
import.meta.hot.accept()
}
例子跑起来,打开浏览器,我们将注意力集中到 index.html 上:
<!DOCTYPE html>
<html lang="en">
<head>
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js?t=1647056310749"></script>
</body>
</html>
可以看到有一个 vite 自身引入的脚本 /@vite/client
,我们先简单了解这个脚本的引入:
当我们访问 index.html 时,会经过 indexHtmlMiddleawre 的中间件,然后调用 transformIndexHtml 钩子去做 html 文件的处理,有一个内置的 html plugin,就会将 @vite/client 写入 head-prepend 的位置。
// 根据服务端的协议来决定 socket 服务
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
// 初始化 socket 链接
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
const base = __BASE__ || '/'
// 监听 socket 信息
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
// 处理后端返回的消息
async function handleMessage(payload: HMRPayload) {
// ...
}
引入 @vite/client 脚本后,会初始化 websocket 连接,收到数据时会触发 handleMessage 事件。
知道了 vite 客户端代码的注入和 websocket 初始化之后,从浏览器上看下我们的入口文件 main.js:
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/main.js");
import '/style.css'
const fooModule = await import('/foo.js')
console.log(fooModule)
if (import.meta.hot) {
import.meta.hot.accept()
}
当我们在模块中使用了 HMR 时,vite 会自动帮你引入 createHotContext 函数并生成 import.meta.hot。接下来就看看 createHotContext 函数做了哪些事,函数位于 packages/vite/src/client/client.ts:
// 存储 accept 模块
const hotModulesMap = new Map<string, HotModule>()
// 存储 dispose 模块
const disposeMap = new Map<string, (data: any) => void | Promise<void>>()
// 存储 prune 模块
const pruneMap = new Map<string, (data: any) => void | Promise<void>>()
// 存储模块的 data 数据
const dataMap = new Map<string, any>()
// 存储模块的自定义事件
const customListenersMap = new Map<string, ((data: any) => void)[]>()
// 存储模块的默认事件
const ctxToListenersMap = new Map<
string,
Map<string, ((data: any) => void)[]>
>()
/**
* 创建热更上下文
* @param {string} ownerPath 模块路径
*/
export const createHotContext = (ownerPath: string) => {
if (!dataMap.has(ownerPath)) {
dataMap.set(ownerPath, {})
}
// when a file is hot updated, a new context is created
// clear its stale callbacks
const mod = hotModulesMap.get(ownerPath)
if (mod) {
mod.callbacks = []
}
// 清除过时的自定义事件监听器
const staleListeners = ctxToListenersMap.get(ownerPath)
if (staleListeners) {
for (const [event, staleFns] of staleListeners) {
const listeners = customListenersMap.get(event)
if (listeners) {
customListenersMap.set(
event,
listeners.filter((l) => !staleFns.includes(l))
)
}
}
}
const newListeners = new Map()
ctxToListenersMap.set(ownerPath, newListeners)
/**
* 收集热更模块,客户端接收到依赖的处理函数,import.meta.hot.accept
* @param {string[]} deps 依赖的模块
* @param {HotCallback['fn']} callback 回调函数
*/
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
const mod: HotModule = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: []
}
// 定义的依赖和回调存到 mod.callbacks,并更新 hotModulesMap
mod.callbacks.push({
deps,
fn: callback
})
hotModulesMap.set(ownerPath, mod)
}
// HMR 客户端 API,等于 import.meta.hot
const hot = {
get data() {
return dataMap.get(ownerPath)
},
accept(deps: any, callback?: any) {
// 对应 import.meta.hot.accept(() => {}) 的用法
// self-accept: hot.accept(() => {})
if (typeof deps === 'function' || !deps) {
acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
// 对应 import.meta.hot.accept('./bar.js', () => {}) 的用法
} else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback && callback(mod))
// 对应 import.meta.hot.accept(['./bar.js', './foo.js'], () => {}) 的用法
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback)
} else {
throw new Error(`invalid hot.accept() usage.`)
}
},
// 即将废弃的 API,有⚠️信息,开发友好
acceptDeps() {
throw new Error(
`hot.acceptDeps() is deprecated. ` +
`Use hot.accept() with the same signature instead.`
)
},
// 清除任何更新导致的持久副作用
dispose(cb: (data: any) => void) {
disposeMap.set(ownerPath, cb)
},
// 模块不再需要
prune(cb: (data: any) => void) {
pruneMap.set(ownerPath, cb)
},
// TODO
// eslint-disable-next-line @typescript-eslint/no-empty-function
decline() {},
// 调用 import.meta.hot.invalidate 等于 location.reload,刷新页面
invalidate() {
// TODO should tell the server to re-perform hmr propagation
// from this module as root
location.reload()
},
// 自定义事件
on: (event: string, cb: (data: any) => void) => {
const addToMap = (map: Map<string, any[]>) => {
const existing = map.get(event) || []
existing.push(cb)
map.set(event, existing)
}
addToMap(customListenersMap)
addToMap(newListeners)
}
}
return hot
}
createHotContext 是客户端的热更新初始化流程,将所有热更模块信息收集起来放到模块全局变量中。对于本例子而言,style.css 在内置插件 vite:css-post 会默认插入热更代码,main.js 和 foo.js 都是我们手动写入的。最终写入的模块变量如下图所示:
websocket 连接并收集完模块的热更信息之后,我们修改一下文件之后,上篇我们已经知道服务端的处理方式。在客户端我们通过 websocket 的 message 钩子接收到消息:
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
// ...
// 更新事件
case 'update':
notifyListeners('vite:beforeUpdate', payload)
// if this is the first update and there's already an error overlay, it
// means the page opened with existing server compile error and the whole
// module script failed to load (since one of the nested imports is 500).
// in this case a normal update won't work and a full reload is needed.
if (isFirstUpdate && hasErrorOverlay()) {
window.location.reload()
return
} else {
// 清除错误层
clearErrorOverlay()
isFirstUpdate = false
}
// 遍历server发回的更新列表
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
// css-update
// ..
}
console.log(`[vite] css hot updated: ${searchUrl}`)
}
})
break
// ...
}
}
可以看到,我们接收到的消息 type: update,进入 update 分支。首先是通过 notifyListeners 调用全部 beforeUpdate 事件。接着判断是否有错误遮罩层,如果有并且是首次更新就直接刷新页面了,否则清除错误遮罩然后依次更新模块。本文的例子修改的是 main.js,所以模块的更新的类型是 js-update,我们看看 fetchUpdate 函数:
/**
* 加载更新
*/
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
const mod = hotModulesMap.get(path)
if (!mod) {
// In a code-splitting project,
// it is common that the hot-updating module is not loaded yet.
// https://github.com/vitejs/vite/issues/721
return
}
const moduleMap = new Map()
// 自我更新
const isSelfUpdate = path === acceptedPath
// make sure we only import each dep once
// 生成需要更新的模块集合
const modulesToUpdate = new Set<string>()
// 更新自己就把自己加入到集合
if (isSelfUpdate) {
// self update - only update self
modulesToUpdate.add(path)
} else {
// dep update
// 依赖更新
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
// determine the qualified callbacks before we re-import the modules
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
// 销毁定义了 dispose 的实例
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
const newMod = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?import&t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.log(`[vite] hot updated: ${loggedPath}`)
}
}
根据 path 从 hotModulesMap 中获取模块信息,如果模块不存在就直接终止,存在则判断是否“自我接受”,是的话就把自己加入到待更新集合,否则就去遍历全部 deps 加入到更新集合并重新获取回调函数。之后遍历待更新队列 modulesToUpdate,如果模块有 dispose 函数的定义就清除副作用。再就是来到最核心的地方,通过动态 import 去加载最新的资源并更新模块信息,保证最后的回调拿到的模块是最新的。
从上图可以看到,每次修改文件都会用最新的时间戳去请求资源。请求发出之后,又会回到 vite 服务端的 transformMiddleware,这部分流程在模块图中已经分析过了,忘记的童鞋可以回头看下模块之间的依赖关系是一个图流程。
fetchUpdate 最后返回一个函数,通过 queueUpdate 保证回调函数的执行顺序跟 http 请求的一致:
let pending = false
let queued: Promise<(() => void) | undefined>[] = []
/**
* 缓冲由同一个 src 更改触发的多个热更新,以便按照它们发送的相同顺序调用它们。
* 否则顺序可能因为 http 请求往返而不一致
*/
async function queueUpdate(p: Promise<(() => void) | undefined>) {
// 先将全部回调函数推到 queued
queued.push(p)
if (!pending) {
pending = true
await Promise.resolve()
pending = false
const loading = [...queued]
queued = []
;(await Promise.all(loading)).forEach((fn) => fn && fn())
}
}
最后用一张图做整个 HMR 回顾:
至此,整个热更新的流程我们就清楚了。上篇主要讲服务端的处理;下篇讲客户端的处理,中间也接触了中间件和插件的 transformIndexHtml 钩子以及内置的 html 插件,但都是一笔带过,其实像 vite:build-html 插件还是比较复杂的,感兴趣的童鞋可以去调试一些插件源码。下一小节我们就去了解核心模块的最后一节——预编译。