本文使用vite 5.2.6版本
在上文中,我们了解了模块依赖图。并了解了它的转译模块功能,接下来,我们来看看它所服务的另一个功能------热更新。
ws
说到热更新,首先得需要一个webSocket来进行支持,所以我们先看看Vite里面的webSocket是如何被创建出来的。
在_createServer中,使用createWebSocketServer创建了webSocket。
首先,会尝试获取config.server.hmr中的配置
hmr.server,如果这个配置存在,Vite将会通过所提供的的服务器来处理HMR连接,否则就使用默认的httpServer来当做wsServer。hmr.port,热更新端口,如果没有被定义,那么使用24678。
一般情况下,我们已经有了个webSocket所依托的wsServer了,那么接下来,就使用ws这个包,创建一个 webSocketServer。但是,所采用的却是noServer模式。
typescript
wss = new WebSocketServerRaw({ noServer: true })
这样子webSocketServer和wsServer进行了解耦,ws实例就不依赖于具体的 http 服务器实例。
然后使用hmrServerWsListener,将wsServer与创建出来的wss进行绑定。
typescript
hmrServerWsListener = (req, socket, head) => {
if (
req.headers["sec-websocket-protocol"] === HMR_HEADER &&
req.url === hmrBase
) {
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
wss.emit("connection", ws, req)
})
}
}
wsServer.on('upgrade', hmrServerWsListener)
在hmrServerWsListener中,会通过校验请求头的sec-websocket-protocol字段是否是vite-hmr,来确认当前的请求是否是客户端建立 WebSocket 连接的请求。
如果是的话,会调用wss.handleUpgrade()方法,将 HTTP 连接升级为 WebSocket 连接,并通过传递请求对象req、socket和请求头head,以及一个回调函数。
在回调函数中触发wss的connection事件,将 WebSocket 连接对象ws和原始的 HTTP 请求对象req 传递进去,完成 WebSocket 服务器和客户端的绑定,从而建立起 WebSocket 连接。
最后返回一个包装的WebSocketServer对象。
hot
上文中我们已经得到了一个WebSocketServer对象,在之前的版本中,Vite会直接将它当做热更新对象,但当前版本中,热更新通道并非只能选择一个,我们甚至可以定义自己的热更新逻辑。
因此在这里,我们需要将默认创建的WebSocketServer加入到HMRChannel之中。
typescript
const hot = createHMRBroadcaster()
.addChannel(ws)
通过createHMRBroadcaster闭包创建出一个对象,使用这个对象的addChannel方法,将热更新通道加入到内部数组------channels中。
而它的返回值hot对象,有着WebSocketServer上所有的方法,所有方法实现逻辑就是使用channels.forEach遍历执行已存通道上的所有对应方法。
拿listen进行举例:
typescript
listen() {
channels.forEach((channel) => channel.listen())
}
同时,我们可以通过config.server.hmr.channels自定义我们自己的热更新通道。它们同样会被addChannel加入到channels中。
typescript
if (typeof config.server.hmr === 'object' && config.server.hmr.channels) {
config.server.hmr.channels.forEach((channel) => hot.addChannel(channel))
}
最后,这个hot会被挂载到server上,从而后续可以从server拿到。
handleHMRUpdate
我们思考一下,最基本的热更新需要实现什么功能?
那就是根据文件的增加、删除、修改让浏览器的页面发对应的变化。
那么什么东西可以监听文件的增加、删除、修改呢?
答案是chokidar。我们在chokidar那一节讲过,它留下了完美的接口,让我们来根据文件的变动进行对应的修改。
我们回忆一下,它都做了什么:
typescript
const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
// 略 这里处理了publicFile 使它比模块依赖图的etag优先
await handleFileAddUnlink(file, server, isUnlink)
await onHMRUpdate(file, true)
}
watcher.on('change', async (file) => {
// 使用文件从模块依赖图对应模块获取对应的模块,做失效化处理
moduleGraph.onFileChange(file)
await onHMRUpdate(file, false)
})
watcher.on('add', (file) => {
onFileAddUnlink(file, false)
})
watcher.on('unlink', (file) => {
onFileAddUnlink(file, true)
})
其中onFileChange的作用是将对应模块做失效标记,并且使用null清空模块的transformResult。
这里需要注意,我们在模块依赖图讲过,文件是可以分成多个模块的------比如vue文件,就可以分出style、js等,所以尽管这里文件只有一个,但这里获取的模块可能是复数个。这里会清空所有模块的transformResult。
从而使得获取模块transformResult的getCachedTransformResult返回一个无效值。既然是无效值,那么就会重新走模块转译逻辑,这个我们在上一节讲的比较清楚。
从源码可以看到,不管是增加、删除还是修改,都是针对模块依赖图进行修改,然后调用了onHMRUpdate方法。而它实际上是handleHMRUpdate的包装,如果没有手动禁止serverConfig.hmr,那么就会调用handleHMRUpdate。
那么我们看看handleHMRUpdate做了什么。
- 这个函数会判断是否是
env文件或者vite配置文件被修改,如果是的话,就重启本地服务器。 - 如果是文件新增或者删除,那么就直接
return。因为如果可达的模块被新增或者删除,必定伴随着模块的修改,因此新增、删除文件的逻辑被合并到模块修改后的重新转译里面了。之后的逻辑都是修改逻辑。 - 如果被修改的是注入的
client文件,那么重新刷新页面------此文件不允许被修改。 - 根据文件地址,从模块依赖图的获取模块映射的集合------注意当前这些模块的
transformResult是null。并被标记为失效模块。 - 然后将这些模块组成一个上下文对象,放到插件流水线中,触发
handleHotUpdate钩子。 - 如果模块走完插件流水线后,处理结果为空,并且是
html,那么就刷新页面 - 其他情况,调用
updateModules
可以看出 handleHMRUpdate 是针对不同的更新场景做了分流、合并,当调用updateModules的时候,只有一种操作------更新。
updateModules
那么目光来到updateModules这边。updateModules支持多个模块进行热更新,因此会遍历模块进行处理。
针对每一个模块,首先它会通过propagateUpdate收集热更新的边界。
如果最后propagateUpdate返回true,那么就会直接刷新整个页面,结束函数执行。
否则将propagateUpdate填充的boundaries数组复制到待更新数组updates之中。
最后遍历结束,如果updates存在有效长度,那么就通过server.hot将updates发送给客户端。
因此这么看起来,整个核心逻辑就是propagateUpdate,它是如何确定更新边界、并且为什么返回true和false。
propagateUpdate
首先,这函数会检查过有没有处理过当前模块,如果处理过会返回false,否则就将当前模块标记为已处理。
如果当前模块接受自身更新,那么就把当前模块推入boundaries数组。
typescript
if (node.isSelfAccepting) { // 如果当前节点可以接受自身的更新
boundaries.push({ // 将当前节点添加到 boundaries 数组中
boundary: node,
acceptedVia: node,
isWithinCircularImport: isNodeWithinCircularImports(node, currentChain), // 是否在循环依赖
})
同时,检查它的引用者是不是css文件,如果是的话且没处理过,那么将使用propagateUpdate递归它的引用者。因为在PostCSS等插件中,可以将任何文件注册为css文件的依赖项,因此依赖项变动,css文件也需要进行更新。最后返回false。
typescript
for (const importer of node.importers) { // 遍历当前节点的引用数组
if (isCSSRequest(importer.url) && !currentChain.includes(importer)) { // 如果引用是 CSS 请求并且不在当前链中
propagateUpdate( // 略,递归引用者)
}
}
如果当前模块不接受自身更新,那么就找它的引用者,如果没有引用者,那么就返回true直接刷新。
typescript
if (!node.importers.size) {
// 如果当前节点没有导入者
return true // 返回 true,表示存在死锁
}
如果它的所有引用者都是css并且当前模块不是css,那么显然无法很优雅知道需要更新多少模块------因为这种文件一般是通过PostCSS注册的。Vite不能去完全适配PostCSS的逻辑,所有干脆刷新得了。因此返回true。
typescript
if (
!isCSSRequest(node.url) && // 如果不是 CSS 请求
[...node.importers].every((i) => isCSSRequest(i.url)) // 并且所有引用者都是 CSS 请求
) {
return true
}
如果引用者没有被处理过,那么就使用propagateUpdate递归引用者,如果某个递归返回了true,并且不在当前导入链中,那么它最终也返回true。
typescript
if (
!currentChain.includes(importer) && // 如果当前链中不包含引用者
propagateUpdate(importer, traversedModules, boundaries, subChain) // 递归调用 propagateUpdate
) {
return true
}
其他情况返回false。
我们可以简单总结一下propagateUpdate:
- 如果当前模块接受自更新,那么当前模块就被推入
boundaries - 如果当前模块接受自更新,且引用者是
css。那么递归它的引用者。 - 如果当前模块不接受自更新,那么就将它的引入者推入
boundaries - 如果当前模块不接受自更新,并且它的引入者都是
css,刷新页面。 - 如果当前模块不接受自更新还没有引入者,刷新页面。
最后形成的boundaries会被包装成updates数组发送给客户端。
typescript
// 发送 updates
hot.send({
type: "update",
updates,
})
// 发送 刷新页面
hot.send({
type: "full-reload",
triggeredBy: path.resolve(config.root, file),
})
所以我们看看send的逻辑。
typescript
send(...args: any[]) {
let payload: HMRPayload
if (typeof args[0] === 'string') { // 如果第一个参数的类型是字符串
payload = {
type: 'custom', // 类型为自定义
event: args[0], // 事件名称为第一个参数
data: args[1], // 数据为第二个参数
}
} else {
payload = args[0] // 直接使用第一个参数作为 payload
}
//略 错误处理
const stringified = JSON.stringify(payload)
wss.clients.forEach((client) => { // 遍历所有 WebSocket 客户端
if (client.readyState === 1) { // 如果客户端的 readyState 为 1(即连接已打开)
client.send(stringified) // 向客户端发送序列化后的消息
}
})
}
这个函数根据参数的类型创建一个消息对象,并将其发送给客户端。
如果消息类型是 'error',并且当前没有客户端连接,那么它会将这条消息缓存起来,而不是发送出去。
最终,它会将消息序列化为 JSON 字符串,并发送给所有已连接的客户端。
那么我们看看客户端是怎么接受的。
client
其实我们在很早就接触到了client,在第一篇文章,我们就提到了@vite/client,其实它就是热更新的客户端模块。
而它在devHtmlHook中,被挂载到html上。
typescript
// 略 CLIENT_PUBLIC_PATH就是/@vite/client
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: path.posix.join(base, CLIENT_PUBLIC_PATH),
},
injectTo: 'head-prepend',
},
],
}
我们来看看注入的是个什么东西。
首先,调用了setupWebSocket来启动一个WebSocket。并传递vite-hmr,与后台建立链接。
typescript
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
然后,监听message事件,这个交给handleMessage来处理,handleMessage是客户端热更新的入口方法。
typescript
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data)) // 处理接收到的消息
})
我们看看handleMessage做了什么。
首先,根据传过来的type,响应不同的行为,如果type是connected。那么会在一个具体的时间重复发送ping。
typescript
case 'connected':
console.debug(`[vite] connected.`)
setInterval(() => {
if (socket.readyState === socket.OPEN) {
socket.send('{"type":"ping"}')
}
}, __HMR_TIMEOUT__)
break
其中__HMR_TIMEOUT__由server.hmr.timeout注入替代,默认为30s。
我们记得上文的full-reload吗?这里也有对应的处理逻辑。
typescript
case 'full-reload':
if (payload.path && payload.path.endsWith('.html')) {
// 如果编辑了 html 文件,则只在浏览器当前位于该页面时重新加载页面。
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)
) {
pageReload()
}
return
} else {
pageReload()
}
break
上面的逻辑表示,通过读取payload.path,识别引起刷新页面操作的是否是html的变化。
如果是,还需要判断当前所在页面是否是那个html。或者那个html是否是首页。
如果有一个,判断通过,则调用pageReload。
其它情况,直接调用pageReload.
而pageReload实现比较简单,就是一个50ms防抖的location.reload()。
那么我们来看看文件更新,也就是case 'update'的情况。
typescript
case 'update':
// 如果这是第一次更新并且存在错误遮罩,则意味着页面打开时存在服务器编译错误,并且整个模块脚本加载失败
// (因为其中一个嵌套的导入文件是 500)。在这种情况下,普通的更新不起作用,需要完全重新加载。
if (isFirstUpdate && hasErrorOverlay()) {
window.location.reload()
return
} else {
clearErrorOverlay()
isFirstUpdate = false
}
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
if (update.type === 'js-update') {
return hmrClient.queueUpdate(update)
}
// css-update
// 当使用 <link> 引用的 css 文件被更新时才会发送此消息
const { path, timestamp } = update
const searchUrl = cleanUrl(path)
// 这里不能使用带有 `[href*=]` 的 querySelector,因为链接可能使用相对路径,
// 所以我们需要使用 link.href 来获取完整的 URL 进行检查。
const el = Array.from(
document.querySelectorAll<HTMLLinkElement>('link'),
).find(
(e) =>
!outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl),
)
if (!el) {
return
}
const newPath = `${base}${searchUrl.slice(1)}${
searchUrl.includes('?') ? '&' : '?'
}t=${timestamp}`
// 不要直接交换现有标签的 href,而是创建一个新的 link 标签。
// 一旦新样式表加载完成,我们将删除现有的 link 标签。
// 这样做可以消除直接交换标签 href 时出现的样式未加载导致内容闪烁,
// 因为新样式表尚未加载完成。
return new Promise((resolve) => {
const newLinkTag = el.cloneNode() as HTMLLinkElement
newLinkTag.href = new URL(newPath, el.href).href
const removeOldEl = () => {
el.remove()
console.debug(`[vite] css hot updated: ${searchUrl}`)
resolve()
}
newLinkTag.addEventListener('load', removeOldEl)
newLinkTag.addEventListener('error', removeOldEl)
outdatedLinkTags.add(el)
el.after(newLinkTag)
})
}),
)
break
首先,它检查是否是第一次更新且页面存在错误遮罩。
如果是,则意味着页面是以服务器编译错误的情况下打开的,并且整个模块脚本加载失败。
在这种情况下,为了确保正确加载,直接执行了 window.location.reload() 。
如果不是。它会遍历 payload.updates 数组------还记得上文,我们寻找热更新边界,找出需要重新加载的模块,然后将它们填充进updates数组吗,这个数组包含了被清空transformResult的模块。
针对数组每个模块,它会调用 hmrClient.queueUpdate(update) 将更新排队以应用到客户端代码中。
对于css更新,它会在页面中查找与更新相关的 <link> 元素,并创建一个新的 <link> ,来加载新的样式表。一旦新的样式表加载完成,就会删除旧的 <link> 元素,以避免出现样式闪烁。
这里我们发现使用了hmrClient,它是什么?
HMRClient
hmrClient是模块替换的核心功能,使用HMRClient在client代码加载的时候使用new进行初始化。
typescript
const hmrClient = new HMRClient(
console,
{
isReady: () => socket && socket.readyState === 1,
send: (message) => socket.send(message),
},
importUpdatedModule
)
它的构造函数接受三个参数,最重要的就是第三个参数------模块替换逻辑importUpdatedModule。
typescript
async function importUpdatedModule({
acceptedPath,
timestamp,
explicitImportRequired,
isWithinCircularImport,
}) {
const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`);
// 异步导入更新后的模块
const importPromise = import(
base +
acceptedPathWithoutQuery.slice(1) + // 删除路径中的第一个字符(通常是斜杠)
`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}` // 构建带时间戳的导入路径
);
return await importPromise; // 返回导入的 Promise 对象
}
可以看出来,importUpdatedModule是处理新模块的主要逻辑,它将时间戳拼到模块后缀,以此来保证获取的最新的模块,并返回导入的Promise对象。
而queueUpdate做了什么呢?
typescript
public async queueUpdate(payload: Update): Promise<void> {
// 将更新任务添加到更新队列中
this.updateQueue.push(this.fetchUpdate(payload));
// 如果没有挂起的更新队列,则执行以下操作
if (!this.pendingUpdateQueue) {
this.pendingUpdateQueue = true; // 标记为存在挂起的更新队列
await Promise.resolve(); // 等待,收集更新
this.pendingUpdateQueue = false; // 标记为没有挂起的更新队列
// 将更新队列中的任务进行加载
const loading = [...this.updateQueue];
this.updateQueue = []; // 清空更新队列
(await Promise.all(loading)).forEach((fn) => fn && fn()); // 执行更新任务
}
}
这个方法主要是对多个热更新进行缓冲,以确保它们按照发送的顺序执行。
首先它会将当前模块使用fetchUpdate包装,然后推入updateQueue,之后检查队列是否挂起,如果没有,那么标记为挂起,并使用await Promise.resolve(),来等待其它queueUpdate的执行,从而让updateQueue收集到本次推入的所有模块。
我们注意到hmrClient.queueUpdate(update)并没有使用await改为同步,这个技巧在模块依赖图用过不少次。
因此多个模块的情况下,第一个queueUpdate会在await Promise.resolve()之前暂停执行,然后执行剩余的queueUpdate,由于pendingUpdateQueue被第一个标记为true,所以剩余的queueUpdate有效代码只剩下updateQueue.push。
当后续queueUpdate执行完毕,回到第一个queueUpdate继续执行,会将推入队列的所有模块,使用Promise.all同步加载。
那么剩下一个问题,queueUpdate使用队列执行fetchUpdate,那么fetchUpdate是什么。
typescript
private async fetchUpdate(update: Update): Promise<(() => void) | undefined> {
const { path, acceptedPath } = update; // 获取更新路径和接受的路径
const mod = this.hotModulesMap.get(path); // 获取与更新路径对应的模块
if (!mod) {
// 如果模块不存在,可能是因为在代码分割项目中,热更新的模块尚未加载
return;
}
let fetchedModule: ModuleNamespace | undefined;
const isSelfUpdate = path === acceptedPath; // 判断是否为自身更新
// 查找 import.meta.hot.accept 中绑定的更新回调函数
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
deps.includes(acceptedPath),
);
// 如果是自身更新或者有合格的回调函数存在,则执行以下逻辑
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const disposer = this.disposeMap.get(acceptedPath); // 获取清理副作用
if (disposer) await disposer(this.dataMap.get(acceptedPath));
try {
fetchedModule = await this.importUpdatedModule(update); // 导入更新后的模块
} catch (e) {
//略 如果导入失败,则记录警告信息
}
}
// 返回一个回调函数
return () => {
// 对合格的回调函数进行遍历调用
for (const { deps, fn } of qualifiedCallbacks) {
fn(
deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)),
);
}
};
}
fetchUpdate首先会获取path和acceptedPath------------一些情况下,这两个参数并不相同,比如某些模块不接受自更新,因此path是这个模块的父模块。
如果path所对应的模块并没有在hotModulesMap注册,那么无事发生。返回。
如果存在,那么查找path对应的模块是否接受自身更新,或者在accept注册过回调函数。
如果有,那么就先尝试清理旧模块的副作用。
然后使用importUpdatedModule引入新的模块------还记得importUpdatedModule吗,在new HMRClient的时候传进来的第三个参数。
最后返回一个依次执行所有回调函数的函数,这些回调会执行importUpdatedModule的结果。
所以updateQueue队列里面存的就是这些函数。
也就是说是,updateQueue队列的函数并非是清理旧模块、引用新模块,这个操作在fetchUpdate包装完当前模块的时候,就已经执行完毕了,它实际上是import.meta.hot.accept注册的回调函数。
同时我们注意到,这在这里使用hotModulesMap、disposeMap等集合。
实际上它们就是Vite的热更新api。accept和dispose所形成的的集合,这些api给第三方框架使用,以此来实现第三方框架的热更新。
importUpdatedModule重新引入新的模块后,会被中间件拦截,然后根据模块依赖图查找它们的transformResult,因为改动的文件对应的模块被清除了transformResult,因此重新走模块转译逻辑,最后通过中间件返回转译后的代码。
返回后的代码会被import.meta.hot.accept注册的回调函数所处理,比如给对应的元素重新赋值等等,至于详细怎么处理,这是第三方框架的事情了。
结束
我们这次讲了Vite的热更新大致逻辑,并没有逐行解析,因为它的热更新并非招牌,我们将在下一章讲到剩下最后一个坑------------预构建。