本文使用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
的热更新大致逻辑,并没有逐行解析,因为它的热更新并非招牌,我们将在下一章讲到剩下最后一个坑------------预构建。