Vite是基于ESM的构建工具,预编译和按需编译机制是其在项目启动以及快速更新的核心。除了这两个机制外,还存在一个主流构建工具都存在一个机制即HMR。HMR是Hot Module Replacement的简写,意思是模块热替换,即允许在运行时更新各种模块,而无需进行完全刷新。HMR大大提高了开发阶段的更新的响应速度,避免全量更新,在提高效率的同时大大提高了开发体验。
在正式去学习HMR机制之前,先聊聊另外一个概念Live Reload。Live Reload表示实时重加载,实际上这个概念跟HMR很相近。HMR是在运行时更新存在改变的模块,Live Reload也是在运行时更新模块,但是它全量更新的。Live Reload和HMR都是前端工程化时代下的产物,不同于原始阶段手动刷新更改后的页面这种方式,Live Reload和HMR都实现页面的自动更新。
事物的发展总是循序渐进的,技术也是如此,从原始的手动刷新 、Live Reload机制的全量自动更新、HMR机制的局部自动更新,整个发展主要脉络是清晰的。
不论是否了解Vite背后原理,你应该知道其背后是基于WebSocket的,实际上目前主流构建工具的HMR的实现都是基于WebSocket的。WebSocket协议是服务器端与客户端通信的协议之一,不同于HTTP协议的半双工,WebSocket协议是全双工的,它允许客户端和服务器端可以同时通信,这服务器端可以主动向客户端发送请求。现代浏览器基于WebSocket协议实现了相关功能,提供了WebSocket对象。在Node端也有相关的WebSocket库, 例如ws等。
构建工具的核心都是在本地基于Node的http模块创建一个本地开发服务器,提供开发阶段所有资源的相关处理等工作。HMR机制是基于WebSocket的,必然也是需要客户端和服务端的。通过源码梳理,Vite中HMR机制对应的WebSocket的服务端是在预编译期间,准确的说实在创建开发服务器时创建的,而WebSocket客户端则是在按需编译期间,准确的说是在开发服务器启动之后Vite注入到HTML页面中的@vite/client文件中执行的。
在之前Vite预编译文章中,知道创建开发服务器逻辑是调用createServer方法来实现的,而其中涉及到WebSocket Server的创建逻辑如下:
const ws = createWebSocketServer(httpServer, config, httpsOptions)
而createWebSocketServer的逻辑也比较清晰,具体逻辑如下:
export function createWebSocketServer(
server: Server | null,
config: ResolvedConfig,
httpsOptions?: HttpsServerOptions
): WebSocketServer {
let wss: WebSocket
let httpsServer: Server | undefined = undefined
const hmr = isObject(config.server.hmr) && config.server.hmr
const wsServer = (hmr && hmr.server) || server
if (wsServer) {
wss = new WebSocket({ noServer: true })
// 协议更改支持
wsServer.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
wss.emit('connection', ws, req)
})
}
})
} else {
// 创建开发服务器
// vite dev server in middleware mode
wss = new WebSocket(websocketServerOptions)
}
wss.on('connection', (socket) => {
// 建立连接
})
wss.on('error', (e: Error & { code: string }) => {
// 错误处理
})
return {
on: wss.on.bind(wss),
off: wss.off.bind(wss),
send(payload: HMRPayload) {
// 相关逻辑
},
close() {
// 相关逻辑
}
}
}
上面的逻辑实际上总结下来就是基于开发服务器创建WebSocket Server,然后监听相关事件并返回相关实例方法。实际上Vite中是基于ws第三库来建立WebSocket Server,ws的具体使用可以查看ws使用文档。
在之前 Vite按需编译文章中,知道当请求index.html时Vite会在页面中插入@vite/client文件请求,而该文件的主要逻辑之一就是创建WebSocket客户端。
// 根据开发服务器地址使用WebSocket对象创建客户端
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')
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
socket.addEventListener('close', async ({ wasClean }) => {
// 相关逻辑
})
当创建了对应的WebSocket服务器和客户端之后,当页面加载之后就会建立连接。当模块被修改WebSocket服务器端就会通过WebSocket连接通知浏览器热更新,这部分逻辑是在开发服务器定义的。
Vite内部使用chokidar库来实现文件的监听,当文件被修改就会被感知从而来执行相关逻辑,具体逻辑如下:
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) => {})
watcher.on('add', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
watcher.on('unlink', (file) => {
handleFileAddUnlink(normalizePath(file), server, true)
})
使用chokidar创建监听器对象,然后监听change、add、unlink事件对不同动作执行不同逻辑:
文件内容修改后回调处理逻辑主要有两点:
// 依赖图谱逻辑处理
moduleGraph.onFileChange(file)
// 处理更新
handleHMRUpdate(file, server)
在开发服务器启动过程中的会创建一个moduleGraph对象,该对象内部定义多个moduleMap,例如:
在对应map对象中对保存项目中所有模块相关的图谱信息,而这个图谱内容是在模块按需编译时创建。这里暂不关心依赖图谱的具体处理逻辑。
handleHMRUpdate负责处理具体的更新逻辑,实际上该方法的主要逻辑如下几点:
所有需要更新的操作都是通过WebSocket连接发送相关类型和数据到浏览器端:
ws.send({ type: 'full-reload' })
ws.send({ type: 'update', updates })
然后由浏览器端根据对应的type类型做相关处理。相关类型有:
在客户端@vite/client文件中,对于full-reload的逻辑主要就是调用location.reload来实现整体重新加载。而对于update的处理逻辑:
文件新增和文件删除都是调用handleFileAddUnlink方法来实现的,而该方法的逻辑主要是操作服务器对象的_globImporters属性:
// 从依赖图谱中获取新增或删除文件地址对应的相关模块
const modules = [...(server.moduleGraph.getModulesByFile(file) ?? [])]
// 删除操作
if (isUnlink && file in server._globImporters) {
delete server._globImporters[file];
} else {
// 新增操作
for (const i in server._globImporters) {
const { module, importGlobs } = server._globImporters[i];
for (const { base, pattern } of importGlobs) {
if (micromatch_1.isMatch(file, pattern) ||
micromatch_1.isMatch(path__default.relative(base, file), pattern)) {
modules.push(module);
server.moduleGraph.onFileChange(module.file);
break;
}
}
}
}
// 使用WebSocket连接发送给客户端相关数据,这边逻辑与文件修改时相同
对于新增或删除的文件要判断是否是通过import.meta.glob方式来加载的,_globImporters属性实际上就是存储了import.meta.glob方式来加载的相关模块信息,具体相关信息暂不关心。
本文梳理了Vite中HMR主要的处理逻辑,可以知道下面流程:
当修改文件后,监听器监听到对应文件改变就会触发change事件,其回调函数会被执行:
当新增或删除文件后,监听器就会触发add、unlink事件,之后通知客户端更新的逻辑与新增操作时并没有任何区别,只是要处理模块不同而已。