这是我参与8月更文挑战的第20天,活动详情查看:8月更文挑战
前言
上篇主要介绍了 client 下 env、overlay 两个文件, client 最主要的处理逻辑 client/client.ts 中,本篇继续分析。
client/client.ts
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
const base = __BASE__ || '/'
首先,创建了一个 WebSocket 的客户端,并且定义了 ws 的协议、命名空间。
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
然后通过监听 message 事件,把客户端收到的事件传入 handleMessage 进行处理。
socket.addEventListener('close', async ({ wasClean }) => {
if (wasClean) return
console.log(`[vite] server connection lost. polling for restart...`)
await waitForSuccessfulPing()
location.reload()
})
通过监听 close 事件,调用 waitForSuccessfulPing,然后刷新页面。
展开一下 waitForSuccessfulPing 的实现。
async function waitForSuccessfulPing(ms = 1000) {
while (true) {
try {
await fetch(`${base}__vite_ping`)
break
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
}
}
可以看到 waitForSuccessfulPing 通过请求 /__vite_ping 来检查当前 WebSocket 是否还处于连接状态,也就是 ws 应用中的心跳检测。
当请求失败时,会在 1000 ms 后进行重试,这个方法通过 while 和 catch 部分的 setTimeout 实现了无限请求直到某个条件触发停止请求的功能。在实现其他类似功能的时候可以借鉴。
当服务端发来 WebSocket 消息时,可以看到会触发 handleMessage 方法,具体介绍一下这个方法。
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
case 'connected':
console.log(`[vite] connected.`)
setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
break
// ...
}
}
首先检查消息类型是否是 connected 状态,如果是 connected 状态,则开启定时发送 ping 的操作。这次 send 操作的目的就是 心跳检测。
继续看其他的消息类型。
case 'update':
notifyListeners('vite:beforeUpdate', payload)
if (isFirstUpdate && hasErrorOverlay()) {
window.location.reload()
return
} else {
clearErrorOverlay()
isFirstUpdate = false
}
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
let { path, timestamp } = update
path = path.replace(/\?.*/, '')
const el = (
[].slice.call(
document.querySelectorAll(`link`)
) as HTMLLinkElement[]
).find((e) => e.href.includes(path))
if (el) {
const newPath = `${base}${path.slice(1)}${
path.includes('?') ? '&' : '?'
}t=${timestamp}`
el.href = new URL(newPath, el.href).href
}
console.log(`[vite] css hot updated: ${path}`)
}
})
break
如果收到的是 update 类型时,首先通过 notifyListeners 触发 vite:beforeUpdate 监听事件。
然后如果是首次更新且当前有异常报错时,执行刷新页面的操作。
这一步的目的是,清除上一次的报错信息,重新执行当前的代码逻辑。
否则,请求报错信息,并更新是否是首次更新的变量标记。
然后遍历需要更新的 updates,分别进行 queueUpdate 或重新拼接 css 的 link 标签,重新请求 最新的 css 资源。
这段代码包含了两个方法,分别是 notifyListeners、queueUpdate。
function notifyListeners(event: string, data: any): void {
const cbs = customListenersMap.get(event)
if (cbs) {
cbs.forEach((cb) => cb(data))
}
}
可以看到,notifyListeners 其实就是一个简单的发布订阅模式的实现,通过 customListenersMap 存储事先绑定好的事件及回调,当调用 notifyListeners 时进行批量通知。
另外有意思的是,可以看下 notifyListeners 的 Typescript 函数签名。
function notifyListeners(
event: 'vite:beforeUpdate',
payload: UpdatePayload
): void
function notifyListeners(event: 'vite:beforePrune', payload: PrunePayload): void
function notifyListeners(
event: 'vite:beforeFullReload',
payload: FullReloadPayload
): void
function notifyListeners(event: 'vite:error', payload: ErrorPayload): void
function notifyListeners<T extends string>(
event: CustomEventName<T>,
data: any
): void
可以看到,notifyListeners 可以接收不同类型的 event 和对应的 payload,具体的对应关系也可以直接通过函数签名看到。
接下来说说 queueUpdate(fetchUpdate(update))。
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
const mod = hotModulesMap.get(path)
if (!mod) {
return
}
const moduleMap = new Map()
const isSelfUpdate = path === acceptedPath
const modulesToUpdate = new Set<string>()
if (isSelfUpdate) {
modulesToUpdate.add(path)
} else {
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
// ...
}
fetchUpdate 方法中,首先查看 hotModulesMap 中是否存储了当前更新文件的路径,如果不存在则直接结束。
由于文件的更新一定是由一个源头的文件更改,进而引起引用了该文件的其他文件发生的变动,所以这里先判断源头文件是否和当前文件相同,如果相同时,则将当前文件添加到 modulesToUpdate。否则查看依赖了源头文件的所有文件,即 mod.callbacks 每一项中的 deps,如果符合需要更新的条件,则也添加到 modulesToUpdate。
最后遍历 mod.callbacks 来将已经添加到 modulesToUpdate 中的文件进行筛选,最后赋值给 qualifiedCallbacks。
值得一提的是,其实这两个过程是可以合二为一的,在一次循环中实现,但是为了代码更加清晰易懂,被分成了两部分。这一点其实在开发的过程中也是可以借鉴的,绝大多数的应用和逻辑对性能并没有那么高要求。
相反,如果牺牲一点性能而换来代码更好的可读性也许更好。
接着看 fetchUpdate 的后续代码。
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
// ...
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(
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}`)
}
}
通过 动态引入 的方式,请求 Vite 的开发服务器,来获取对应的最新文件资源。
- 获取失败时,执行请求失败的打印操作。
- 获取成功时,存入
moduleMap。
最后返回了一个函数,执行这个函数会将模块的最新内容,即 moduleMap 存储的内容,传入 callback 的 fn 回调函数中,然后打印当前 Vite 已经更新的文件路径。
下面接着说其他的 handleMessage 事件类型处理。
case 'full-reload':
notifyListeners('vite:beforeFullReload', payload)
if (payload.path && payload.path.endsWith('.html')) {
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
关于 full-reload 事件,会验证是否传入的文件是否是 index.html 或其他 非 html 页面 ,是则执行刷新页面。
再说说 error 事件。
case 'error': {
notifyListeners('vite:error', payload)
const err = payload.err
if (enableOverlay) {
createErrorOverlay(err)
} else {
console.error(
`[vite] Internal Server Error\n${err.message}\n${err.stack}`
)
}
break
}
还记得上篇提到的 ErrorOverlay 组件吗,当触发 error 事件时,会通过 createErrorOverlay 创建一个 ErrorOverlay 组件。
具体的实现就是向 body 中添加一个 ErrorOverlay 的实例:document.body.appendChild(new ErrorOverlay(err))。
其他的事件都是 notifyListeners 类似的处理,这里不再赘述。
还有还有关于样式的处理,主要是通过 new CSSStyleSheet,并调用对应的 replaceSync 更新样式内容,及 removeChild 移除样式代码。
在 client.ts 的结尾,有一段代码:
function injectQuery(url: string, queryToInject: string): string {
if (!url.startsWith('.') && !url.startsWith('/')) {
return url
}
const pathname = url.replace(/#.*$/, '').replace(/\?.*$/, '')
const { search, hash } = new URL(url, 'http://vitejs.dev')
return `${pathname}?${queryToInject}${search ? `&` + search.slice(1) : ''}${
hash || ''
}
}
这段代码的作用是,传入 url 和需要添加到 url 上的参数,最后返回拼接后的 url 结果。
可以看到,首先判断是否是以 . 或 / 开头,直接符合这两种情况任一种,才需要进行注入。
其他情况时,通过正则把 # 的 hash 参数,以及 ? 后的 url 参数剔除。
然后传入 new URL(),这一步的目的是利用浏览器提供的 URL api,来将 url 中存在的 hash、? 后的参数 提取出来,最后再拼接上开发者想要拼上的参数。
这块可以借鉴的思路在于,可以利用现成或浏览器已存在的 api 去避免手动实现类似的功能。
到此,关于 Vite 源码中 client 的部分已经分析完了。