前言
在前端项目开发中,尤其是像webpack、vite等这种需要打包构建的项目在开发时,HMR对我们的开发提供了极大的便利,接下来让我们看下vite中的HMR具体是怎么实现的。
vite的HMR其中的一部分需要在自己的代码中调用vite提供的hot.accept()函数来实现,一般情况下我们在用 react 或 vue 开发时官方提供的插件会帮我门解决这件事,经过插件处理会自动注入需要调用的代码,本文基于vite、vue3、tsx的开发模式来分析vite在开发vue时是如何HMR的。
client.ts 的注入
在我们所写的项目中并未设计HMR相关处理逻辑,或者相关包的引入,可是当从ws发送消息时需要浏览器具有处理这些消息的能力,因此关于在浏览器中的HMR处理逻辑其实是vite自动帮我们实现的,具体做法是在index.html中注入了/@vite/client的请求链接,因此能自动加载这段逻辑: 【源码】
const devHtmlHook: IndexHtmlTransformHook = async (
html,
{ path: htmlPath, filename, server, originalUrl }
) => {
const { config, moduleGraph } = server!
const base = config.base || '/'
const s = new MagicString(html)
let inlineModuleIndex = -1
const filePath = cleanUrl(htmlPath)
...
html = s.toString()
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: path.posix.join(base, CLIENT_PUBLIC_PATH) // /@vite/client
},
injectTo: 'head-prepend'
}
]
}
}
从以上代码可以看出这个方法会返回需要要添加client文件的信息(注入为止、路径等)并根据这些信息对请求的html进行transform,下图为index.html转化前后的对比:
hot.accept对HMR模块的收集
先看一下hot.accept相关的源码:
const hot: ViteHotContext = {
...
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)) // callback的参数下面会说到
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback)
} else {
throw new Error(`invalid hot.accept() usage.`)
}
},
...
}
从源码中不难看出accept会对参数进行整理并最终调用acceptDeps,我们暂且分析最简单的情形:假如调用accept时只传入一个参数 callback,那么会把ownerPath和 callback 当作参数传入,ownerPath是调用accept文件的路径,接下来看看acceptDeps做了什么:
// hotModulesMap 调用accept文件的集合
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
const mod: HotModule = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: []
}
mod.callbacks.push({
deps,
fn: callback
})
hotModulesMap.set(ownerPath, mod) // 可以理解为mod的收集,每个调用accept的地方都会经过这个给记录下来
}
其实accept的作用就是在自己代码内在浏览器端运行时调用accept对热更新的mod进行收集。
插件实现accept的调用
在我们自己的代码中需要手动调用accept函数,如果在每个文件中都写入accept,会让热更新的用法看起来很麻烦,而且这些HMR的代码在生产环境时也是没用的,所以需要需要vite来做这件事,但是不同的框架又有不同的热更新逻辑,一次在框架解析的vite插件中实现这个功能,用vue3和tsx开发时,插件 @vitejs/plugin-vue-jsx 对 accept这段代码进行注入:
// plugin-vue-jsx 在vite的transform钩子上进行注册,在vite的tranform时调用 transform就是对代码进行转换,并输出转换后的代码
transform(code, id, opt) {
...
const filter = createFilter(include || /\.[jt]sx$/, exclude)
if (filter(id) || filter(filepath)) { // 只处理 jsx tsx文件
...
const result = babel.transformSync(code, {
babelrc: false,
ast: true,
plugins,
sourceMaps: needSourceMap,
sourceFileName: id,
configFile: false
}) // 获取到通过babel处理后的结构 babel解析的AST语法树
const hotComponents = [] // 存储需要HMR的组件
for (const node of result.ast.program.body) {
...
hotComponents.push({
local: node.declaration.name,
exported: 'default',
id: hash(id + 'def
ault')
})
} // 最终会根据解析的AST中各个node的条件进行筛选 并把有export 组件的模块添加到 hotComponents 中
if (hotComponents.length) {
...
if (needHmr && !ssr && !/\?vue&type=script/.test(id)) {
let code = result.code
let callbackCode = ``
for (const { local, exported, id } of hotComponents) {
code +=
`\n${local}.__hmrId = "${id}"` +
`\n__VUE_HMR_RUNTIME__.createRecord("${id}", ${local})`
callbackCode += `\n__VUE_HMR_RUNTIME__.reload("${id}", __${exported})`
}
code += `\nimport.meta.hot.accept(({${hotComponents
.map((c) => `${c.exported}: __${c.exported}`)
.join(',')}}) => {${callbackCode}\n})`
// 注入import.meta,hot.accep函数
result.code = code
}
if (ssr) {
// ssr 相关逻辑
}
}
}
}
在import.meta.hot.accept函数注入后会在浏览器加载这个文件时进行调用,执行accept并对HMR模块进行收集,下图为@vitejs/plugin-vue-jsx插件transform前后的文件对比:
用chokidar对文件的变化做监听
chokidar是一个而高效的文件监视库,vite在启动开发服务时,在createServer引用chokidar对项目文件具体监听:源码
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
...
// websocket相关
...
const watcher = chokidar.watch(path.resolve(root), {
ignored: [
'**/node_modules/**',
'**/.git/**',
...(Array.isArray(ignored) ? ignored : [ignored])
],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
}) as FSWatcher
watcher.on('change', async (file) => {
file = normalizePath(file)
if (file.endsWith('/package.json')) {
return invalidatePackageData(packageCache, file)
}
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
...
}
在启动devServer时不启动一个http服务供前端界面展示,还会启动一个ws服务,根据 chokidar 监听到的文件变化时向客户端发送一些HMR信息,接下来我们分析一下上文中提到的 handleHMRUpdate 并以js文件更新为例,看发送的HMR消息具体如何:源码
export async function handleHMRUpdate(
file: string,
server: ViteDevServer
): Promise<any> {
...
const mods = moduleGraph.getModulesByFile(file)
// check if any plugin wants to perform custom HMR handling
const timestamp = Date.now()
const hmrContext: HmrContext = {
file,
timestamp,
modules: mods ? [...mods] : [],
read: () => readModifiedFile(file),
server
}
// HMR钩子调用插件中 handleHotUpdate 函数
for (const plugin of config.plugins) {
if (plugin.handleHotUpdate) {
const filteredModules = await plugin.handleHotUpdate(hmrContext)
if (filteredModules) {
hmrContext.modules = filteredModules
}
}
}
...
updateModules(shortFile, hmrContext.modules, timestamp, server)
}
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) {
...
const boundaries = new Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>()
const hasDeadEnd = propagateUpdate(mod, boundaries)
if (hasDeadEnd) {
needFullReload = true
continue
}
updates.push(
...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update` as Update['type'],
timestamp,
path: boundary.url,
acceptedPath: acceptedVia.url
}))
)
}
... // websocket消息处理逻辑
}
在上面看到的 updateModules 会看到判断何时发送full reload消息,判断过程会分析引入依赖,这点需要单独拿出来看:
function propagateUpdate(
node: ModuleNode,
boundaries: Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>,
currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
if (node.isSelfAccepting) { // (js文件)如果该模块在accept函数调用时注册过 则直接加到边界数组中
// 每个模块的 isSelfAccepting 属性判断是在 vite:import-analysis 插件的 transform 中做的,这里不做具体分析
boundaries.add({
boundary: node,
acceptedVia: node
})
...
// CSS isSelfAccepting 相关逻辑处理
return false
}
if (!node.importers.size) { // 没被调用 且没在accept函数中注册
return true
}
// #3716, #3913
// For a non-CSS file, if all of its importers are CSS files (registered via
// PostCSS plugins) it should be considered a dead end and force full reload.
... //css 处理相关
for (const importer of node.importers) { // 找到当前mod的引用者,直到找到 引用者具有 accept 函数为止并返回false,否则一直递归,如果最终整个调用链上都没accept 那么返回true 即是 DeadEnd
const subChain = currentChain.concat(importer)
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node
})
continue
}
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true
}
if (propagateUpdate(importer, boundaries, subChain)) {
return true
}
}
return false
}
用ws向浏览器发送update消息
在开发环境createServer时会创建一个websocket连接,当chokidar监听到文件变化时会创建更新消息并把消息推入updates队列中并通过ws向客户端发送更新消息:
// 创建ws的过程
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
const ws = createWebSocketServer(httpServer, config, httpsOptions)
// 创建ws连接 HMR更新通信基于此
}
// 基于ws向客户端发送update消息
function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws }: ViteDevServer
) {
...
if (needFullReload) {
config.logger.info(colors.green(`page reload `) + colors.dim(file), {
clear: true,
timestamp: true
})
ws.send({ // 发送 full-reload 消息
type: 'full-reload'
})
} else {
config.logger.info(
updates
.map(({ path }) => colors.green(`hmr update `) + colors.dim(path))
.join('\n'),
{ clear: true, timestamp: true }
)
ws.send({ // 会将整个updates更新数组发送到浏览器
type: 'update',
updates
})
}
}
客户端处理接收的ws消息
在上文中我们已经讲过在第一次请求html时,会在html注入client.ts的请求链接,客户端处理HMR消息的逻辑也是存在在client.ts中:
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data)) // 监听ws消息,并交由handleMessage处理
})
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
case 'connected':
...
break
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
}
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
// css-update
// this is only sent when a css file referenced with <link> is updated
const { path, timestamp } = update
const searchUrl = cleanUrl(path)
// can't use querySelector with `[href*=]` here since the link may be
// using relative paths so we need to use link.href to grab the full
// URL for the include check.
const el = Array.from(
document.querySelectorAll<HTMLLinkElement>('link')
).find((e) => cleanUrl(e.href).includes(searchUrl))
if (el) {
const newPath = `${base}${searchUrl.slice(1)}${
searchUrl.includes('?') ? '&' : '?'
}t=${timestamp}`
el.href = new URL(newPath, el.href).href
}
console.log(`[vite] css hot updated: ${searchUrl}`)
}
})
break
case 'custom': {
...
break
}
case 'full-reload': // 直接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 ||
(pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
) {
location.reload()
}
return
} else {
location.reload()
}
break
case 'prune':
...
break
case 'error': {
...
break
}
default: {
const check: never = payload
return check
}
}
}
js update 的逻辑更新关键是调用 queueUpdate(fetchUpdate(update))
在fetchUpdate中值得一提的是如果在同一个ws消息中有多个相同的同一文件更新需求例如:
{
"type": "update",
"updates": [
{
"type": "js-update",
"timestamp": 1650278428261,
"path": "/src/App.tsx",
"acceptedPath": "/src/App.tsx"
},
{
"type": "js-update",
"timestamp": 1650278428261,
"path": "/src/App.tsx",
"acceptedPath": "/src/App.tsx"
}
]
}
两个更新相同,则根据浏览器机制这个文件只会发送一次异步模块调用的import在import之后会把结果放到一个Map中 key值为文件url唯一的,所以达到了去重的效果(后者覆盖前者),具体实现如下
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) => {
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}`)
}
}
queueUpdate配合fetchUpdate使用是为了确保来自同一url同时更新时按照异步加载的顺序来返回加载结果,中间调用了一个await Promise.resolve()通过js的事件循环机制控制,即在Promise.resolve()之前的主线程上 fetchUpdate返回的Promise有序地添加到queued这个队列中,最后通过
/**
* buffer multiple hot updates triggered by the same src change
* so that they are invoked in the same order they were sent.
* (otherwise the order may be inconsistent because of the http request round trip)
*/
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())
}
}