在上一篇中咱们次要是理解了 HMR 的简略概念以及相干 API,也手动实现了文件的 HMR。
接下来咱们来梳理一下在 vite 中,当咱们对文件代码做出扭转时,整个 HMR 的流程是怎么的。
这里可能还会有疑难,就是咱们在上篇文章中都是手动对每个文件增加了
import.meta.hot.accept()
然而在咱们理论开发的我的项目中,其实咱们是没有在代码中手动增加热更新相干的代码的,然而他还是会进行 hmr,其实是插件帮咱们注入了 hmr 相干的操作。咱们在下篇文章中会解析插件(@vite/react-plugin)
hmr
其实整体分为两个局部:
- 在服务端监听到模块改变,对模块进行相应的解决,将解决的后果发送给客户端(浏览器)进行热更新。
- 客户端收到服务端发送的信息,进行解决,解析出对应须要热更新的模块,从新
import
最新模块,实现hmr
整个过程中的通信都是通过 websocket
来实现的。
服务端
首先咱们启动服务之后,批改
render.ts
文件来触发hmr
chokidar 监听文件
vite
中是通过 chokidar
来监听文件的
这里的
moduleGraph
其实是收集的各个模块间的信息。
也是hmr
流程中比拟要害的信息,不过这里不开展讲。后续再看这一块
当初咱们只须要晓得这里寄存的是所有模块的依赖关系。比方a
文件import
什么文件,以及a
文件被什么文件所依赖
// packages/vite/src/node/server/index.ts
import chokidar from 'chokidar'
// 监听根目录下的文件
const watcher = chokidar.watch(path.resolve(root))
// 批改文件
watcher.on('change', async (file) => {file = normalizePath(file)
moduleGraph.onFileChange(file)
await handleHMRUpdate(file, server)
})
// 新增文件
watcher.on('add', (file) => {handleFileAddUnlink(normalizePath(file), server)
})
// 删除文件
watcher.on('unlink', (file) => {handleFileAddUnlink(normalizePath(file), server, true)
})
handleHMRUpdate
当文件扭转之后(change
)事件。会进入到 handleHMRUpdate
函数中。
async function handleHMRUpdate(file, server) {
// 1. 这一部分是对配置文件和环境变量相干的文件进行解决
const {ws, config, moduleGraph} = server
const shortFile = getShortName(file, config.root)
const fileName = path.basename(file)
const isConfig = file === config.configFile
const isConfigDependency = config.configFileDependencies.some((name) => file === name)
const isEnv = config.inlineConfig.envFile !== false && (fileName === '.env' || fileName.startsWith('.env.'))
if (isConfig || isConfigDependency || isEnv) {
// auto restart server
debugHmr(`[config change] ${colors.dim(shortFile)}`)
config.logger.info(colors.green(`${path.relative(process.cwd(), file)} changed, restarting server...`), {clear: true, timestamp: true})
try {
// 服务器重新启动
await server.restart()} catch (e) {config.logger.error(colors.red(e))
}
return
}
debugHmr(`[file change] ${colors.dim(shortFile)}`)
// 客户端注入的文件 (vite/dist/client/client.mjs) 更改,间接刷新页面
if (file.startsWith(normalizedClientDir)) {
ws.send({
type: 'full-reload',
path: '*',
})
return
}
// ====================================================
// 2. 对一般文件进行解决
const mods = moduleGraph.getModulesByFile(file)
const timestamp = Date.now()
// 初始化 hmr 的 context(咱们在 handleHotUpdate 中拿的参数)const hmrContext = {
file,
timestamp,
modules: mods ? [...mods] : [],
read: () => readModifiedFile(file),
server,
}
// 执行插件中 handleHotUpdate 中的钩子,失去须要更新的模块
for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {const filteredModules = await hook(hmrContext)
if (filteredModules) {hmrContext.modules = filteredModules}
}
if (!hmrContext.modules.length) {
// html 文件不能 hmr,间接刷新页面
if (file.endsWith('.html')) {config.logger.info(colors.green(`page reload `) + colors.dim(shortFile), {
clear: true,
timestamp: true,
})
ws.send({
type: 'full-reload',
path: config.server.middlewareMode ? '*' : '/' + normalizePath(path.relative(config.root, file)),
})
} else {
// loaded but not in the module graph, probably not js
debugHmr(`[no modules matched] ${colors.dim(shortFile)}`)
}
return
}
// 这里执行次要的模块更新逻辑
updateModules(shortFile, hmrContext.modules, timestamp, server)
}
:::danger
总结:
- 对配置文件、环境变量相干的文件,会间接重启服务器
- 客户端注入的文件 (
vite/dist/client/client.mjs
) 更改,间接full-reload
,刷新页面 - 之后会执行插件中的所有
handleHotUpdate
钩子,失去须要解决的模块 - 执行
updateModules
来进行模块更新(hmr
的次要逻辑)
:::
updateModules
/**
* file: 文件门路(`src/render.ts`)
* module: 更新的模块汇合(具体长啥样看上面截图)module[]
* timestamp: 工夫戳
* server: 服务端相干配置
*/
function updateModules(file, modules, timestamp, { config, ws}) {
// 更新模块的汇合
const updates = []
// 有效模块汇合(?)const invalidatedModules = new Set()
// 是否须要刷新页面
let needFullReload = false
// 对更新的模块进行遍历
for (const mod of modules) {
// 检测模块是否有效(即不须要更新)invalidate(mod, timestamp, invalidatedModules)
if (needFullReload) {continue}
const boundaries = new Set()
// 查找更新的边界
const hasDeadEnd = propagateUpdate(mod, boundaries)
// 如果为 true 就刷新页面
if (hasDeadEnd) {
needFullReload = true
continue
}
// 记录更新信息
updates.push(...[...boundaries].map(({boundary, acceptedVia}) => ({type: `${boundary.type}-update`,
timestamp,
path: normalizeHmrUrl(boundary.url),
explicitImportRequired: boundary.type === 'js' ? isExplicitImportRequired(acceptedVia.url) : undefined,
acceptedPath: normalizeHmrUrl(acceptedVia.url),
}))
)
}
if (needFullReload) {config.logger.info(colors.green(`page reload `) + colors.dim(file), {
clear: true,
timestamp: true,
})
ws.send({type: 'full-reload',})
return
}
if (updates.length === 0) {debugHmr(colors.yellow(`no update happened `) + colors.dim(file))
return
}
// 打印信息
config.logger.info(updates.map(({ path}) => colors.green(`hmr update `) + colors.dim(path)).join('\n'), {clear: true, timestamp: true})
// updates:{
// type: "js-update",
// timestamp: 1665641766748,
// path: "/src/main.ts",
// explicitImportRequired: false,
// acceptedPath: "/src/render.ts",
// }
ws.send({
type: 'update',
updates,
})
}
modules:
propagateUpdate
这外面须要留神的是 isSelfAccepting
是否接管本身的更新。这里指的是文件中有 import.meta.hot.accept()
的模块,accept
中不能有依赖的参数,比方accept('xxx',()=>{})
function propagateUpdate(node, boundaries, currentChain = [node]) {
// 是否接管本身的更新,如果接管就将自身这个模块放到热更新的边界汇合中去
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
})
// 如果该模块中援用了 css,则将 css 全副退出到边界汇合中
for (const importer of node.importers) {if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {propagateUpdate(importer, boundaries, currentChain.concat(importer))
}
}
return false
}
if (node.acceptedHmrExports) {
boundaries.add({
boundary: node,
acceptedVia: node,
})
} else {//。。。}
// 不承受本身更新的,查找援用它的模块是否接管更新
for (const importer of node.importers) {const subChain = currentChain.concat(importer)
// importer 如果将该模块列为 acceptedHmrDeps,在将 importer 列入更新边界中
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
})
continue
}
//。。。if (currentChain.includes(importer)) {
// 循环依赖间接终止,返回 true 刷新页面
return true
}
if (propagateUpdate(importer, boundaries, subChain)) {return true}
}
// 返回 false 进行 hmr
return false
}
这里有一个问题就是,如果咱们在
render.ts
中设置了acceptI()
, 那么main
中的accept
中即时依赖了render.ts
, 那么main
模块也不会呈现在hmr
的边界汇合中。
也就是一旦这个模块成为了isSelfAccepting
,那么它更新的边界就是它自身,只有当该文件isSelfAccepting===false
的时候才会去遍历他的前置依赖者(也就是 import 该模块的父级模块)是否依赖了该模块的更新(就像main
中依赖了render
一样)
invalidate
invalidate
函数次要做以下几件事:
- 更新了模块的最初热更工夫
- 并将代码转换相干的后果(
transformResult
、ssrTransformResult
)置空 - 最初遍历模块的援用者(
importers
,也可叫作前置依赖,具体指哪些模块援用了该模块)
// 查看是否为有效模块,并且更新 mod 的信息
function invalidate(mod, timestamp, seen) {
// 防止出现循环依赖
if (seen.has(mod)) {return}
seen.add(mod)
// 更新模块上次 hmr 的工夫
mod.lastHMRTimestamp = timestamp
// 置空一系列信息
mod.transformResult = null
mod.ssrModule = null
mod.ssrError = null
mod.ssrTransformResult = null
// 查看援用该模块的文件中是否接管该模块的 hmr---accptedHmrDeps
mod.importers.forEach((importer) => {
// 如果援用该模块的文件的 acceptedHmrDeps(可承受的更新依赖模块)中不蕴含本次文件变动
// 的模块,就证实该 importer 的不须要更新
// 就持续对该 importer 进行检测,清空前置依赖的一些援用,更新信息
if (!importer.acceptedHmrDeps.has(mod)) {invalidate(importer, timestamp, seen)
}
})
}
// 其实在咱们的案例中来解释 invalidate 函数做了什么事:// 咱们是变动的 render.ts,此时 main 是援用了 render.ts 的,所以这个 importer 就是 main 文件。// 那咱们在 main 的 import.meta.hot.accept 外面是依赖了 render 的,所以 importer.acceptedHmrDeps
// 就有 render.ts,所以 main 是一个无效的更新模块,即须要 hmr
// 假如咱们的 main 不依赖 render,那么 importer.acceptedHmrDeps.has(mod)就是 false,就会对 main 进行
// invalidate,清空 importer 的援用信息更新 mod 对应的相干信息
:::danger
总结:
次要就是寻找模块更新的边界。
:::
到这里服务端的工作就实现了。
服务端会将信息发送给客户端,最终发送的信息大略是这样的:
{
"type": "js-update",
"timestamp": 1665641766748,
"path": "/src/main.ts",
"explicitImportRequired": false,
"acceptedPath": "/src/render.ts"
}
客户端
当客户端接管到服务端发送过去的 ws
信息之后,也会进行相干的解决。
而客户端解决信息的代码,也是 vite
注入的,大略长这样:
这里大略就是创立了一个 websocket
服务器,而后监听一些事件。咱们这里重点关注的是 handleMessage
, 这个函数会在服务端发送过去信息的时候触发,用来解决hmr
的信息
handleMessage
async function handleMessage(payload) {switch (payload.type) {
case 'connected':
console.debug(`[vite] connected.`)
sendMessageBuffer()
// ws 心跳检测,保障 ws 服务处于连贯中
setInterval(() => {if (socket.readyState === socket.OPEN) {socket.send('{"type":"ping"}')
}
}, __HMR_TIMEOUT__)
break
case 'update':
// 触发 vite 插件中的对应名称的钩子
notifyListeners('vite:beforeUpdate', payload)
//。。。payload.updates.forEach((update) => {
// 对 js 文件进行解决
if (update.type === 'js-update') {
// 次要逻辑在这里!!!!queueUpdate(fetchUpdate(update))
} else {
// css-update
//。。。console.debug(`[vite] css hot updated: ${searchUrl}`)
}
})
break
case 'custom': {notifyListeners(payload.event, payload.data)
break
}
case 'full-reload':
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 = decodeURI(location.pathname)
const payloadPath = base + payload.path.slice(1)
if (pagePath === payloadPath || payload.path === '/index.html' || (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)) {location.reload()
}
return
} else {location.reload()
}
break
case 'prune':
//。。。。break
case 'error': {
//。。。break
}
default: {
const check = payload
return check
}
}
}
:::danger
总结:
对服务端发送来的不同类型的音讯进行解决。
- 对于不同类型可能须要触发不同的 vite 插件中的钩子。
- 咱们次要关注
queueUpdate(fetchUpdate(update))
:::
queueUpdate
这个办法次要是进行更新工作的调度。保障触发程序。
参数 p
就是 fetchUpdate(updatre)
中的返回后果,咱们把留神里放到这个函数中去。
// 将由同一 src 门路更改触发的多个热更新放入队列中,以便依照发送顺序调用它们。//(否则,因为 http 申请往返,程序可能不统一)async function queueUpdate(p) {queued.push(p)
// p: 执行的就是上面的办法:
// () => {// for (const { deps, fn} of qualifiedCallbacks) {// fn(deps.map((dep) => moduleMap.get(dep)));
// }
// };
if (!pending) {
pending = true
await Promise.resolve()
pending = false
const loading = [...queued]
queued = []
;(await Promise.all(loading)).forEach((fn) => fn && fn())
}
}
fetchUpdate
/**
* 这个参数就是最终服务端发送的 update 信息
**/
async function fetchUpdate({path, acceptedPath, timestamp, explicitImportRequired}) {const mod = hotModulesMap.get(path)
// 获取到的 mod 的格局
// {
// "id": "/src/render.ts",
// "callbacks": [
// {
// "deps": [
// "/src/render.ts"
// ],
// "fn": ([mod]) => deps && deps(mod)
// }
// ]
// }
if (!mod) {return}
const moduleMap = new Map()
const isSelfUpdate = path === acceptedPath
// 过滤出来对应的依赖门路上面的 callback
const filtercb = ({deps}) => deps.includes(acceptedPath)
const qualifiedCallbacks = mod.callbacks.filter(filtercb)
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const dep = acceptedPath
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) + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` :''}`
)
moduleMap.set(dep, newMod)
} catch (e) {warnFailedFetch(e, dep)
}
}
return () => {for (const { deps, fn} of qualifiedCallbacks) {// moduleMap: { key: 'src/render',value: Module}
// moduleMap.get(dep)获取到的是一个模块
// fn([Module]) ===> ([mod]) => deps && deps(mod)
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.debug(`[vite] hot updated: ${loggedPath}`)
}
}
:::danger
总结:
const newMod = await import(/* @vite-ignore */ `path + import&t=${timestamp}${query}`)
在从新导入模块后,会发送新的申请,来申请最新的模块内容
:::
最初的疑难
咱们在 fetchUpdate
中通过 hotModulesMap.get
获取到的mod
,格局是这样的
{
"id": "/src/render.ts",
"callbacks": [
{
"deps": ["/src/render.ts"],
"fn": ([mod]) => deps && deps(mod)
}
]
}
其实会有个疑难,这个数据里的 fn 是哪里来的?hotModulesMap
又是哪来的?
这里咱们就须要持续往下看!
咱们先来看下咱们在更新模块内容之后,申请回来的文件长什么样?(咱们这里还是更改的render.ts
)
import {createHotContext as __vite__createHotContext} from '/@vite/client'
import.meta.hot = __vite__createHotContext('/src/render.ts')
export const render = () => {const app = document.querySelector('#app')
app.innerHTML = `
<h1>Helloss Vite12d</h1>
<p id="p">\u662F\u662Ffff\u6492\u53D1\u987A\u4E30\u662Fdfd\uFF01sooo\uFF1F</p>
`
}
export const other = () => {const p = document.querySelector('#p')
p.innerHTML = `
<p>other</p>
`
}
if (import.meta.hot) {
import.meta.hot.data.count = 1
import.meta.hot.accept((mod) => mod?.render())
}
vite:import-analysis
插件进行注入的
能够看到,在咱们文件的头部是被注入了 createHotContext
的,并且重写了咱们的 import.meta.hot
中的内容为 createHotContext
的返回值。
也就是说咱们在 21 行应用的 accept 办法是被 createHotContext
重写过的,那咱们就来看看 createHotContext
做了什么?
createHotContext
其实这个办法的次要工作就是:重写客户端的 import.meta.hot
中的一系列办法。
const hotModulesMap = new Map<string, HotModule>()
// 简化后的代码
/**
* ownerPath: 以后文件的相对路径 "/src/render.ts"
**/
export function createHotContext(ownerPath: string): ViteHotContext {const mod = hotModulesMap.get(ownerPath)
if (mod) {mod.callbacks = []
}
// 2. 再来看这个函数
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
// 先查找缓存,如果没缓存,就新建模块相干信息
const mod: HotModule = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: [],}
// 批改模块的相干信息
mod.callbacks.push({
deps,
fn: callback,
})
// 将模块信息放入 hotModuleMap 中
hotModulesMap.set(ownerPath, mod)
}
// 1. 先来看最终的返回值,就是对上一篇中提到的 hmr 中的一些 API 进行重写
const hot: ViteHotContext = {get data() {return dataMap.get(ownerPath)
},
// 咱们重点关注这里,重写 accept 办法
accept(deps?: any, callback?: any) {// 第一种状况就是,import.meta.accetpt(mod=>{}) 这样写
if (typeof deps === 'function' || !deps) {// 咱们下面的问题,fn 就是在这里注入的,这里的 deps 其实就是咱们传入的回调 mod=>{}
acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
} else if (typeof deps === 'string') {// 第二种状况是,import.meta.accetpt('xxx', mod=>{})
acceptDeps([deps], ([mod]) => callback && callback(mod))
} else if (Array.isArray(deps)) {// 第三种状况是,import.meta.accetpt(['xxx','xxxx'], mod=>{})
acceptDeps(deps, callback)
} else {throw new Error(`invalid hot.accept() usage.`)
}
},
// export names (first arg) are irrelevant on the client side, they're
// extracted in the server for propagation
acceptExports(_: string | readonly string[], callback?: any) {acceptDeps([ownerPath], callback && (([mod]) => callback(mod)))
},
dispose(cb) {disposeMap.set(ownerPath, cb)
},
// @ts-expect-error untyped
prune(cb: (data: any) => void) {pruneMap.set(ownerPath, cb)
},
decline() {},
// tell the server to re-perform hmr propagation from this module as root
invalidate() {notifyListeners('vite:invalidate', { path: ownerPath})
this.send('vite:invalidate', { path: ownerPath})
},
// custom events
on(event, cb) {const addToMap = (map: Map<string, any[]>) => {const existing = map.get(event) || []
existing.push(cb)
map.set(event, existing)
}
addToMap(customListenersMap)
addToMap(newListeners)
},
send(event, data) {messageBuffer.push(JSON.stringify({ type: 'custom', event, data}))
sendMessageBuffer()},
}
return hot
}
对于下面这个的 createHotContext
函数咱们分为两局部来看:
-
先看返回值
hot
, 这里总结来说就是重写上一篇中咱们提到过的hmr
相干的API
。咱们重点关注accept
函数,这里对accept
函数的三种不同应用形式进行解决:- 第一种状况就是:
import.meta.accetpt(mod=>{})
- 第二种状况是:
import.meta.accetpt('xxx', mod=>{})
- 第三种状况是:
import.meta.accetpt(['xxx','xxxx'], mod=>{})
- 第一种状况就是:
- 而后再看在返回值里重写
accept
办法时,用到了一个新的办法acceptDeps
。这个办法其实就是更新hotModulesMap
信息的。
参数:
deps
: 第一种状况下,其实就是传入的自身的门路,其余状况下是在accpet
中被动申明的依赖-
callback
:- 第一种状况下,是从新注入的
([mod]) => deps && deps(mod)
,这里的deps
其实就是咱们传入的回调mod=>{}
- 第二种状况下,也是从新注入的
([mod]) => callback && callback(mod)
, 这里的callback
是咱们在 accept 中传入的第二个参数import.meta.accetpt('xxx', mod=>{})
- 第三种状况就没怎么解决,将两个参数顺次传入就好了
咱们下面的疑难 ❓:
这个 fn 是哪里来的?其实就是在从新 accpet 时acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
中注入的 - 第一种状况下,是从新注入的
而后咱们最初再来看一下在 queueUpdat
中调度工作最初执行的办法
// qualifiedCallbacks: {
// "deps": [
// "/src/render.ts"
// ],
// "fn": ([mod]) => deps && deps(mod)
// }
for (const { deps, fn} of qualifiedCallbacks) {fn(deps.map((dep) => moduleMap.get(dep)))
}
fn
: 就是咱们下面解析的acceptDeps
中的第二个参数callback
moduleMap.get(dep)
: 获取到的是一个模块的信息Module
- 所以最终
fn([mod])
===>([mod]) => cb(mod)
(cb
就是咱们在accept
中传入的)
在哪里注入的 createContext
// vite/src/node/plugins/importAnalysis.ts
if (hasHMR && !ssr) {debugHmr(`${isSelfAccepting ? `[self-accepts]` : isPartiallySelfAccepting ? `[accepts-exports]` : acceptedUrls.size ? `[accepts-deps]` : `[detected api usage]`} ${prettyImporter}`)
// inject hot context
str().prepend(`import { createHotContext as __vite__createHotContext} from "${clientPublicPath}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify(normalizeHmrUrl(importerModule.url))});`
)
}