在上一篇中咱们次要是理解了 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.tsimport 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.tsif (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))});` )}