前置
经过前面几篇铺垫之后,接下来看下 Vite 的热更新原理,先看下前置流程图
接下来会详细分析下上图的过程
demo
假设main.ts
文件如下
import a from './a'
console.log(a)
if(import.meta.hot){
import.meta.hot.accept('./a', a => {
console.log('hmr', a)
})
}
请求 main.ts
请求main.ts
时,如果main.ts
中存在import.meta.hot.accept
。经importAnalysisPlugin
编译后,会向这个文件中添加一段代码
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext(url/* 从项目根路径查找的当前文件的绝对路径 */);
当客户端执行main.ts
时,调用/@vite/client
的createHotContext
函数
监听服务器返回的消息
/@vite/client
路径解析之后对应源码的位置就是/src/client/client.ts
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
// 创建 WebSocket 对象
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
const base = __BASE__ || '/'
// Listen for messages
socket.addEventListener('message', async ({ data }) => {})
首先会创建一个WebSocket
对象,并注册监听事件,也就是说当服务器通过WebSocket
返回信息时,会被这个事件捕获。
还会定义一些变量,这些变量如下
/**
* 存储的是被请求文件的 import.meta.hot.accept
* key: 当前文件地址
* value: { id: 文件地址, callbacks: [{ deps: 被监听的文件路径数组, fn: 定义的回调函数 }] }
*/
const hotModulesMap = new Map<string, HotModule>()
/**
* 存储的是所有被请求文件的 import.meta.hot.dispose
* key: 当前文件地址
* value: 定义的回调函数
*/
const disposeMap = new Map<string, (data: any) => void | Promise<void>>()
/**
* 存储的是所有被请求文件的 import.meta.hot.prune
* key: 当前文件地址
* value: 定义的回调函数
*/
const pruneMap = new Map<string, (data: any) => void | Promise<void>>()
/**
* 存储的是所有被请求文件的 import.meta.hot.data
* 对象在同一个更新模块的不同实例之间持久化。它可以用于将信息从模块的前一个版本传递到下一个版本。
* key: 当前文件地址
* value: 是一个对象,持久化的信息
*/
const dataMap = new Map<string, any>()
/**
* 存储的是被请求文件监听的 hmr 钩子
* key: 事件名称
* value: 事件对应回调函数的数组
*/
const customListenersMap = new Map<string, ((data: any) => void)[]>()
/**
* 存储的是被请求文件监听的 hmr 钩子
* key: 当前文件地址
* value: 是一个 Map,Map 内的 key 是事件名,valye 是一个回调函数数组
*/
const ctxToListenersMap = new Map<string, Map<string, ((data: any) => void)[]>>()
export const createHotContext = (ownerPath: string) => {}
customListenersMap
和ctxToListenersMap
在最后介绍自定义钩子时会介绍
创建 import.meta.hot 对象
继续向下,导出 createHotContext
函数
上面说过,对于存在import.meta.hot.accept
的模块会执行这个函数,并传入当前文件的绝对路径。
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext(url/* 从项目根路径查找的当前文件的绝对路径 */);
createHotContext
函数如下
// ownerPath 当前文件的路径
export const createHotContext = (ownerPath: string) => {
// 如果 dataMap 中没有当前路径,则添加到 dataMap 中
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) {
// 清空 cb
mod.callbacks = []
}
// 清除过时的自定义事件监听器(最后介绍自定义钩子时会介绍)
// ...
const newListeners = new Map()
ctxToListenersMap.set(ownerPath, newListeners)
function acceptDeps(){}
const hot = {
get data() {},
accept(deps: any, callback?: any) {},
acceptDeps() {},
dispose(cb: (data: any) => void) {},
prune(cb: (data: any) => void) {},
// TODO
decline() {},
invalidate() {},
on: (event: string, cb: (data: any) => void) => {},
}
return hot
}
这个函数最主要的作用就是定义一个hot
对象,并返回这个对象。返回的对象会赋值给模块的import.meta.hot
总结下createHotContext
函数的作用:
- 创建持久化数据对象,并添加到
dataMap
中 - 清空
hotModulesMap
内当前路径绑定的依赖回调(accept
函数的参数) - 清除过时的自定义事件监听器
- 给当前路径创建自定义事件回调的容器,并放到
ctxToListenersMap
中
执行import.meta.hot.accept
方法
在这里主要分析下accept
方法的作用,其他方法都比较简单这里就不赘述了,可以直接去源码中看。
accept(deps: any, callback?: any) {
if (typeof deps === 'function' || !deps) {
// self-accept: hot.accept(() => {})
acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
} else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback && callback(mod))
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback)
} else {
throw new Error(`invalid hot.accept() usage.`)
}
},
代码中,对import.meta.hot.accept
不同的参数形式做了不同处理,分别是
hot.accept(() => {})
接收自身hot.accept('./a', () => {})
接受一个直接依赖项的更新hot.accept(['./a', './b'], () => {})
接受多个直接依赖项的更新
最后都是调用acceptDeps
方法,参数为接收文件的数组、对应文件更新时的回调
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
const mod: HotModule = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: [],
}
mod.callbacks.push({
deps,
fn: callback,
})
// 设置 hotModulesMap
hotModulesMap.set(ownerPath, mod)
}
acceptDeps
方法的作用就是将文件更新时的回调函数添加到hotModulesMap
中,存储的结构已经在上面说过
到此前置工作就已经做完了,接下来就是文件更新流程
更新
假设main.ts
代码如下
import a from './a'
console.log(a)
if(import.meta.hot){
import.meta.hot.accept('./a', a => {
console.log('hmr', a)
})
}
当更新a.ts
的内容时,会被chokidar
注册的监听捕获
// file:被修改文件的绝对路径
watcher.on('change', async (file) => {
file = normalizePath(file)
// 清空被修改文件对应的ModuleNode对象的 transformResult 属性
// 这个属性存储的是编译后的代码内容
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {}
}
})
当文件修改被chokidar
注册的监听器捕获之后,根据被修改文件的路径清空对应的ModuleNode中缓存的源码。然后调用handleHMRUpdate
函数,开始热更新流程
export async function handleHMRUpdate(
file: string,
server: ViteDevServer
): Promise<any> {
const { ws, config, moduleGraph } = server
// 获取 file 相对于根路径的相对路径
const shortFile = getShortName(file, config.root)
// 如果当前文件是配置文件则为 true
const isConfig = file === config.configFile
// 如果当前文件名是自定义的插件则为 true
const isConfigDependency = config.configFileDependencies.some(
(name) => file === path.resolve(name)
)
//如果是环境变量文件,则为 true
const isEnv = config.inlineConfig.envFile !== false && file.endsWith('.env')
// 环境变量文件、自定义插件、配置文件修改会重启服务
if (isConfig || isConfigDependency || isEnv) {
await restartServer(server)
return
}
// /xxx/node_modules/vite/dist/client
// 如果是客户端使用的热更新文件,重新加载页面
if (file.startsWith(normalizedClientDir)) {
ws.send({
type: 'full-reload',
path: '*',
})
return
}
// 根据文件绝对路径获取 ModuleNode 对象(Set)
// 是一个数组,因为会出现单个文件可能映射到多个服务模块,比如 Vue单文件组件
const mods = moduleGraph.getModulesByFile(file)
const timestamp = Date.now()
const hmrContext: HmrContext = {
file,
timestamp,
modules: mods ? [...mods] : [],
read: () => readModifiedFile(file),
server,
}
// 调用所有插件定义的 handleHotUpdate 钩子函数
for (const plugin of config.plugins) {
if (plugin.handleHotUpdate) {
const filteredModules = await plugin.handleHotUpdate(hmrContext)
if (filteredModules) {
hmrContext.modules = filteredModules
break
}
}
}
if (!hmrContext.modules.length) {
// 如果是 html 文件 重新加载页面
if (file.endsWith('.html')) {
ws.send({
type: 'full-reload',
path: config.server.middlewareMode
? '*'
: '/' + normalizePath(path.relative(config.root, file)),
})
} else {}
return
}
updateModules(shortFile, hmrContext.modules, timestamp, server)
}
整体流程如下
- 配置文件更新、
.env
更新、自定义插件或引入配置文件的自定义文件更新都会重起服务器 - 客户端使用的热更新文件更新、
index.html
更新,重新加载页面 - 调用所有插件定义的
handleHotUpdate
钩子函数,具体功能可参考文档- 过滤和缩小受影响的模块列表,使 HMR 更准确。
- 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理
- 如果是其他文件更新,调用
updateModules
函数
先大体看下updateModules
函数做了什么事
function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws }: ViteDevServer
) {
const updates: Update[] = []
const invalidatedModules = new Set<ModuleNode>()
// 是否需要重新加载
let needFullReload = false
// 遍历该文件编译后的所有文件
for (const mod of modules) {}
if (needFullReload) {
ws.send({
type: 'full-reload',
})
} else {
ws.send({
type: 'update',
updates,
})
}
}
首先定义一个updates
数组,用于存储要更新的模块;定义一个needFullReload
变量,如果为true
则重新加载整个页面。然后遍历传入的modules
。最后根据needFullReload
的值判断是重新加载整个页面还是更新某些模块updates
。
由此可以看出循环的作用就是根据修改模块获取整个更新链路。
// 遍历该文件编译后的所有文件
for (const mod of modules) {
// 沿着引用路线向上查找,设置时间戳、清空 transformResult
invalidate(mod, timestamp, invalidatedModules)
// 如果需要重新加载,不需要再遍历其他的了
if (needFullReload) {
continue
}
const boundaries = new Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>()
// 查找引用模块,判断是否需要重载页面
const hasDeadEnd = propagateUpdate(mod, boundaries)
if (hasDeadEnd) {
// 如果 hasDeadEnd 为 true,则全部更新。
needFullReload = true
continue
}
updates.push(
...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update` as Update['type'], // 更新类型
timestamp, // 时间戳
path: boundary.url, // 依赖该文件的文件
acceptedPath: acceptedVia.url, // 当前文件
}))
)
}
首先调用invalidate
,这个函数的作用就是一层一层向上查找,并修改lastHMRTimestamp
和清空transformResult
;如果上层模块不接受当前模块的热更新;则继续调用invalidate
,并传入上层模块对应的ModuleNode
对象。检测上上层是否接受上层模块热更新;并修改上层模块对应的ModuleNode
对象的lastHMRTimestamp
、transformResult
对象。如果上上层也没有接受,再往上找。
这样做的目的是,对于上层模块来说,如果没有监听子模块更新,当子模块更新时,上层模块也需要重新加载。此时需要更新时间戳和清空缓存的代码,防止再次返回缓存的代码。
如果监听了子模块更新,就不需要更新自身了,而是可以通过监听的回调重新执行子模块导出的内容。所以就不需要更新时间戳和清空代码了。
function invalidate(mod: ModuleNode, timestamp: number, seen: Set<ModuleNode>) {
// 防止死循环
if (seen.has(mod)) {
return
}
seen.add(mod)
// 设置修改时间
mod.lastHMRTimestamp = timestamp
mod.transformResult = null
// 遍历导入该文件的所有文件
mod.importers.forEach((importer) => {
// 如果上层文件的 acceptedHmrDeps 上不包含当前文件,说明上层文件没有定义接受当前文件更新的回调
// 则再次对上层文件调用 invalidate 方法
if (!importer.acceptedHmrDeps.has(mod)) {
invalidate(importer, timestamp, seen)
}
})
}
上述操作做完之后,继续向下执行定义一个boundaries
变量,并调用propagateUpdate
函数,参数是当前模块对应的ModuleNode
对象和boundaries
变量
function propagateUpdate(
node: ModuleNode,
boundaries: Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>,
currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
// 如果是自身监听,则添加到 boundaries 中,并返回 false
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
})
// ...
return false
}
// 当前模块没有被任何模块导入,返回 true,即全部更新
if (!node.importers.size) {
return true
}
// 当前文件不是 css 文件,并且只有css文件导入了当前模块,返回 true
if (!isCSSRequest(node.url) && [...node.importers].every((i) => isCSSRequest(i.url))) {
return true
}
// 遍历导入此模块的所有模块对象,向上查找
for (const importer of node.importers) {
const subChain = currentChain.concat(importer)
// 如果当前模块的上层模块接收当前模块的更新,则添加到 boundaries 中
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer, // 导入当前模块的模块对象
acceptedVia: node, // 当前模块
})
continue
}
// 重复引入,如果不 return 出去,则会造成死循环
if (currentChain.includes(importer)) {
return true
}
// 递归调用 propagateUpdate,收集 boundaries,向上查找
if (propagateUpdate(importer, boundaries, subChain)) {
return true
}
}
return false
}
这个函数的作用就是获取要更新的所有模块,并判断是否要重新加载整个页面。沿着导入链向上查找,直到找到接收自更新或者接收子模块更新的模块,将这个模块添加到boundaries
中;并返回true
,反之返回false
- 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,其中模块 A 接收模块 B 更新;当修改模块 D 时,返回
false
,并将 模块A 收集到boundaries
中 - 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,其中模块 C 接收模块 D 更新;当修改模块 D 时,返回
false
,并将 模块C 收集到boundaries
中 - 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,所有模块都不接受热更新;当修改模块 D 时,返回
true
,boundaries
为空
回到updateModules
函数,将收集到的模块数组和updates
合并
updates.push(
...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update` as Update['type'], // 更新类型 js/css
timestamp, // 时间戳
path: boundary.url, // 导入该模块的模块
acceptedPath: acceptedVia.url, // 当前模块
}))
)
循环完成之后,将消息发送给客户端。根据needFullReload
判断更新方式;如果propagateUpdate
返回true
,说明需要重新加载页面。反之就是更新模块。
if (needFullReload) {
ws.send({
type: 'full-reload',
})
} else {
ws.send({
type: 'update',
updates,
})
}
总流程如下
客户端接收消息
先看下流程图
前面分析过,客户端注册了 WebSocket 监听
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
当客户端接收到服务器发过来的消息后,调用handleMessage
函数
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
case 'connected':
console.log(`[vite] connected.`)
setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
break
case 'update':
// 调用 vite:beforeUpdate 事件的回调
notifyListeners('vite:beforeUpdate', payload)
// ...
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {/* ... */}
})
break
case 'custom': {
// ...
break
}
case 'full-reload':
// 调用 vite:beforeFullReload 事件的回调
notifyListeners('vite:beforeFullReload', payload)
if (payload.path && payload.path.endsWith('.html')) {
// if html file is edited, only reload the page if the browser is currently on that page.
const pagePath = location.pathname
const payloadPath = base + payload.path.slice(1)
if (pagePath === payloadPath || (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)) {
location.reload()
}
return
} else {
location.reload()
}
break
case 'prune':
// ...
break
case 'error': {/* ... */}
default: {/* ... */}
}
}
handleMessage
根据服务器传入的type
走不同逻辑,这里我们只看update
逻辑的。
遍历需要更新的模块数组,如果是js-update
类型,对这个模块执行queueUpdate(fetchUpdate(update))
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
// path 接收热更新的模块
// mod:{ id: 文件地址, callbacks: [{ deps: 被监听的文件路径数组, fn: 定义的回调函数 }] }
const mod = hotModulesMap.get(path)
if (!mod) {
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 {
// deps 中存储的是当前模块接受的直接依赖项
// 这块代码逻辑是说,如果当前模块接收的依赖项中包含 acceptedPath 模块,则将这个路径添加到 modulesToUpdate 中
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
// 获取符合条件的回调
// 过滤条件是如果 mod.callbacks.deps 中的元素在 modulesToUpdate 中存在,则返回 true
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
// 获取模块设置的副作用中的回调 import.meta.hot.dispose
const disposer = disposeMap.get(dep)
// import.meta.hot.data 对象在同一个更新模块的不同实例之间持久化。它可以用于将信息从模块的前一个版本传递到下一个版本。
// 调用 disposer
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
// 拼接路径,请求新的文件
const newMod = await import(
base + path.slice(1) + `?import&t=${timestamp}${query ? `&${query}` : ''}`
)
// 文件的导出内容添加到 moduleMap 中
moduleMap.set(dep, newMod)
} catch (e) {}
})
)
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
// 调用 import.meta.hot.accept 中定义的回调函数,并将文件的导出内容传入回调函数中
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.log(`[vite] hot updated: ${loggedPath}`)
}
}
fetchUpdate
函数的作用就是收集需要更新的模块路径和import.meta.hot.accept
中的回调函数,调用import.meta.hot.disposer
的回调函数清空副作用;拼接更新模块的路径,会挂上import
和时间戳;通过import()
加载拼接后的路径。最后返回一个函数,这个函数的作用就是import.meta.hot.accept
中的回调函数,并打印更新信息。
返回的这个函数被queueUpdate
函数接收;queueUpdate
函数内会收集fetchUpdate
函数返回的函数;并在下一个任务队列中触发所有回调。
// fetchUpdate 函数返回的函数,函数内调用 import.meta.hot.accept 内定义的回调函数,并将请求文件的导出内容当作参数传入
async function queueUpdate(p: Promise<(() => void) | undefined>) {
queued.push(p)
if (!pending) {
pending = true
await Promise.resolve()
pending = false
const loading = [...queued]
queued = []
;(await Promise.all(loading)).forEach((fn) => fn && fn())
}
}
总结
Vite 的热更新原理可以大体总结为下面几步
前置
- 服务器启动时创建 WebSocket 实例、通过 chokidar 监听文件修改
- 对请求的 HTML 文件注入客户端热更新代码
- 加载客户端热更新代码时,创建 WebSocket 实例,并注册监听
- 当被请求文件中有
import.meta.hot.accept
时,向该文件注入import.meta.hot.accept
定义
更新
- 当文件更新时,触发文件修改的回调。
- 如果是配置文件、自定义插件、
.env
文件修改直接重启服务器 - 反之,根据模块路径向上查找;收集接受当前依赖项更新的模块,并判断是否是刷新页面
- 如果是刷新页面,向客户端发送刷新页面的消息,反之发送更新消息,并将接受当前依赖项更新的模块一起发送给客户端
- 客户端接收到之后,获取要更新的模块路径和热更新回调,通过
import()
请求要更新模块的路径,并在 URL 挂上import
和时间戳;并在下一任务队列中触发热更新回调。
举例
假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D
有模块接受依赖项更新
- 其中模块 A 接收模块 B 更新;当修改模块 D 时,此时是局部更新,修改模块B、C、D的时间戳并清空源码缓存;将 模块A 收集到
boundaries
中。服务器返回的消息中,包含模块A的相关信息。客户端接收到消息后,查找模块A接收热更新的模块。也就是模块B。拼接模块B的路径并重新请求模块B。Vite会将模块B内的导入路径(模块C)挂上t
参数,从而强制浏览器重新请求。模块B返回后,请求模块C,也会给导入路径(模块D)挂上t
参数。最后调用模块A中的热更新回调。 - 其中模块 C 接收模块 D 更新;当修改模块 D 时,此时是局部更新,修改模块D的时间戳并清空源码缓存;将 模块C 收集到
boundaries
中。服务器返回的消息中,包含模块C的相关信息。客户端接收到消息后,查找模块C接收热更新的模块。也就是模块D。拼接模块D的路径并重新请求模块D。模块D返回后,调用模块C中的热更新回调。
模块接收自更新
- 假设模块 D 接收自更新;当修改模块 D 时,此时也是局部更新,并将 模块D 自身收集到
boundaries
中。服务器返回的消息中,包含模块D的相关信息。客户端接收到消息后,由于是接收自更新,所以查找模块D。拼接模块D的路径并重新请求模块D。模块D返回后,调用模块D中的热更新回调。 - 假设模块A接收自更新;当修改模块 D 时,此时是局部更新,,修改模块B、C、D的时间戳并清空源码缓存;将 模块A 收集到
boundaries
中。服务器返回的消息中,包含模块A的相关信息。客户端接收到消息后,由于是接收自更新,所以查找模块A。拼接模块A的路径并重新请求模块A。也是会修改导入模块B、C、D的路径挂上t
参数。模块A返回后,调用模块A中的热更新回调。
没有模块接收热更新
当修改模块D时,由于没有模块接收热更新,所以会直接像客户端发送页面重新加载的消息,客户端接收到之后,直接刷新页面。
自定义钩子
Vite 的 HMR 有4个自定义钩子,分别在不同时机自动触发:
'vite:beforeUpdate'
当更新即将被应用时(例如,一个模块将被替换)'vite:beforeFullReload'
当完整的重载即将发生时'vite:beforePrune'
当不再需要的模块即将被剔除时'vite:error'
当发生错误时(例如,语法错误)
也可以通过handleHotUpdate
钩子函数注册新的钩子函数。
handleHotUpdate({ server }) {
server.ws.send({
type: 'custom',
event: 'special-update',
data: {}
})
return []
}
怎么传入回调
通过import.meta.hot.on
const hot = {
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)
}
}
当执行import.meta.hot.on
时,调用了两次addToMap
函数,第一次传入customListenersMap
,第二次传入newListeners
。并将传入的回调放到这两个变量里面。
customListenersMap
是在执行/@vite/client
模块创建的
/**
* 存储的是被请求文件监听的 hmr 钩子
* key: 事件名称
* value: 事件对应回调函数的数组
*/
const customListenersMap = new Map<string, ((data: any) => void)[]>()
newListeners
是在执行createHotContext
函数创建的。
const newListeners = new Map()
ctxToListenersMap.set(ownerPath, newListeners)
也就是说newListeners
最终存储在ctxToListenersMap
中
/**
* 存储的是被请求文件监听的 hmr 钩子
* key: 当前文件地址
* value: 是一个 Map,Map 内的 key 是事件名,valye 是一个回调函数数组
*/
const ctxToListenersMap = new Map<
string,
Map<string, ((data: any) => void)[]>
>()
ctxToListenersMap
和customListenersMap
的区别是:
customListenersMap
存储的结构是:事件名:事件回调数组ctxToListenersMap
存储的结构是:文件名:Map<事件名,事件回调数组>
在执行createHotContext
时,还有一段代码,用于清空过时的事件回调。因为如果当前文件重新请求,会重新创建一个新的Context上下文,之前的就没用了,需要清空。
// 获取当前模块监听的所有事件
const staleListeners = ctxToListenersMap.get(ownerPath)
if (staleListeners) {
// 遍历当前模块监听的回调
for (const [event, staleFns] of staleListeners) {
// 将 staleFns 中所有回调从 customListenersMap 中清除
const listeners = customListenersMap.get(event)
if (listeners) {
customListenersMap.set(
event,
listeners.filter((l) => !staleFns.includes(l))
)
}
}
}
钩子调用时机
客户端监听到服务器消息后会调用handleMessage
钩子函数;
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
case 'update':
notifyListeners('vite:beforeUpdate', payload)
// ...
break;
case 'custom': {
notifyListeners(payload.event as CustomEventName<any>, payload.data)
break
}
case 'full-reload':
notifyListeners('vite:beforeFullReload', payload)
break;
}
上面只是源码的一部分,从上面可以看到当服务器返回不同消息类型时,会调用不同的钩子函数
function notifyListeners(event: string, data: any): void {
const cbs = customListenersMap.get(event)
if (cbs) {
cbs.forEach((cb) => cb(data))
}
}
就是从customListenersMap
中根据事件名获取所有回调,然后执行这些回调