从零读 Vite 4.0 源码(四)———— vite 热重载
模块热替换
模块热替换是指在运行时实现代码的动态更新,无需刷新整个页面或重新加载整个应用。 vite 也为我们提供了开箱即用的模块热替换功能,本节主要是介绍 vite 模块热替换的实现原理。
HMR API
vite 就是利用这套 ESM 的 HMR API 来实现模块热替换的。所以我们先来了解一下 HMR API。 注意:手动 HMR API 主要用于框架和工具作者。作为最终用户,HMR 可能已经在特定于框架的启动器模板中为你处理过了。
Vite 通过特殊的 import.meta.hot 对象暴露手动 HMR API。 请大家先仔细阅读官网:https://cn.vitejs.dev/guide/api-hmr.html 先来学习 HMR API的使用。
客户端与服务端之间的通信
服务端监听到文件变化后,需要向客户端发送 HMR 消息,客户端收到消息后,需要通知对应的模块进行热更新。 因此我们需要在服务端和客户端之间建立通信。在 vite 中,我们使用 socket.io 来建立通信。
先来看一下服务端通信部分的实现:
import { createServer as createHttpsServer } from 'node:https'
import { WebSocketServer } from 'ws'
const wsHttpServer = createHttpsServer(httpsOptions)
const wss = new WebSocketServer({ server: wsHttpServer })
wss.on('connection', (socket) => {
socket.on('message', (raw) => {
let parsed: any
try {
parsed = JSON.parse(String(raw))
} catch {}
// 触发对应的事件监听
listeners.forEach((listener) => listener(parsed.data, client))
})
socket.send(JSON.stringify({ type: 'connected' }))
})
每当文件发生变化的时候,服务器发送 message 给客户端(浏览器),通知其热更:
watcher.on('change', async (file) => {
// 生成热更信息,以下仅是示例,具体逻辑在下文详述
ws.send({
type: 'update',
updates: [{
type: 'js-update',
timestamp: '106989894',
path: file,
explicitImportRequired: undefined,
acceptedPath: file,
}],
})
})
客户端(这里指浏览器)接收服务端的信息:
const socket = new WebSocket(`wss://localhost:5173/`, 'vite-hmr');
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data);
switch (payload.type) {
case 'connected':
console.debug(`[vite] connected.`)
case 'update':
await Promise.all(
// 处理服务端发送的热更事件
payload.updates.map(async (update) => {
if (update.type === 'js-update') {
// queueUpdate 的作用是:当同一个 src 文件内容多次变化时,
// 会触发多个 hotUpdate 事件,需要保证执行的顺序
// fetchUpdate 的作用是获取更新后的模块,之后会详细说明实现
return queueUpdate(fetchUpdate(update))
}
})
)
case 'full-reload':
location.reload()
}
})
在了解了服务端和客户端如何通信后,我们可以再来继续考虑,vite 处理热重载的思路会是什么样的?
服务端能够监听到文件的变化,同时可以得到各个模块的依赖关系(根据文件中声明的 import),因此是可以计算出来需要更新的模块有哪些。
客户端是无法独立计算出变更模块内容,因此需要依赖服务端发送过来的信息,来真正执行模块的更新。
基于以上的思路,我们需要搞明白的事情有以下几点:
- 服务器是如何生成各个模块的依赖关系的
- 服务端监听到文件变化后,是如何生成对应的热更信息的
- 客户端如何执行模块的更新
我们基于 js 文件的热更场景,来说明下具体实现:
服务器生成模块的依赖图
vite 的模块依赖分析图是基于 moduleGraph 这个类实现的。具体包括:
- 生成并维护各个模块本身的信息
- 生成各个模块的依赖关系
代码一览:
class ModuleGraph {
// 重要,生成初始模块信息信息,在 updateModuleInfo 中处理依赖时调用
async ensureEntryFromUrl() {}
// 重要,根据模块依赖信息更新依赖图,在 importAnalysisPlugin 中调用(详见下文)
async updateModuleInfo() {}
// 工具函数,根据 Url,id, file 获取模块信息
async getModuleByUrl() {}
getModuleById() {}
getModulesByFile() {}
// 清除当前变化的文件依赖的所有模块的缓存,在 watcher 的 change 事件被触发时调用
onFileChange() {}
// 文件发生变化时,清空缓存的模块信息
invalidateModule() {}
}
模块依赖关系的生成是基于 importAnalysisPlugin 这个插件来实现的, 除此之外,该插件还负责格式化源代码中的路径信息,并插入import.meta.hot 的实现代码。
// es-module-lexer 是一款 JS 模块语法词法分析器,性能非常好
import { init, parse as parseImports } from 'es-module-lexer'
import MagicString from 'magic-string'
export function importAnalysisPlugin(config) {
return {
name: 'vite:import-analysis',
async transform(source, importer, options) {
await init
let [imports, exports] = parseImports(source)
const importerModule = moduleGraph.getModuleById(importer)
// 收集依赖 的 Url 路径
const importedUrls = new Set()
// 为了方便理解,仅展示静态 import 的处理
await Promise.all(
imports.map(async (importSpecifier, index) => {
// eg: 'import { name } from 'mod'
const {
// slice(s, e) === 'mod'
s: start,
e: end,
// slice(ss, se) === 'import { name } from 'mod''
ss: expStart,
se: expEnd,
d: dynamicIndex,
// 'mod'
n: specifier,
a: assertIndex,
} = importSpecifier
if (specifier) {
const [url, resolvedId] = await normalizeUrl(specifier, start);
importedUrls.add(url);
const str = () => s || (s = new MagicString(source))
// 注入 import.meta.hot 实现代码
// createHotContext 具体的实现见下文
str().prepend(
`import { createHotContext as __vite__createHotContext } from "/@vite/client";` +
`import.meta.hot = __vite__createHotContext(${importerModule.url});`,
)
}
})
)
// 格式化路径,并重写 source
for (const { url, start, end } of imports) {
const [normalized] = await moduleGraph.resolveUrl(url);
str().overwrite(start, end, JSON.stringify(normalized), {
contentOnly: true,
});
}
// 更新 module graph 的模块信息,增加了依赖的模块信息
await moduleGraph.updateModuleInfo(
// 当前模块的信息
importerModule,
// 依赖的 Url 路径
importedUrls,
importedBindings,
// 格式化后的依赖的模块路径
normalizedAcceptedUrls,
// 是否依赖了其他模块
isSelfAccepting,
)
// 返回修改后的文件内容和sourcemap
return {
code: s.toString(),
map:s.generateMap({ hires: 'boundary', source: id })
}
}
}
}
服务端监听变化的文件并生成热更信息
前面提到过,服务端有一个 watcher 用来监听文件的变化,那么如何针对变化的文件生成对应的热更信息呢? 我们来具体看下实现:
watcher.on('change', async (file) => {
// 清除当前变化的文件依赖的所有模块的缓存
moduleGraph.onFileChange(file)
await onHMRUpdate(file, false)
})
onHMRUpdate 中核心的逻辑都在 handleHMRUpdate 这个函数中实现,而这个函数也是生成热重载信息的关键。 这里官网提出了一个概念: boundary, 我们先来看一下 boundary 的生成逻辑:
在文章的开头我们提到了 import.meta.hot.accept 语法,官网对 boundary 的定义是:
A module that "accepts" hot updates is considered an HMR boundary.
举例说明 boundary 是什么:
示例一:
// foo.js
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (newModule) {
// newModule is undefined when SyntaxError happened
console.log('updated: count is now ', newModule.count)
}
})
}
// 在这个例子中, boundary 是模块本身(foo)
示例二
// main.js
import { foo } from './foo.js'
foo()
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
// the callback receives the updated './foo.js' module
newFoo?.foo()
})
}
// 在这个例子中, boundary 是当前模块(main)
示例三
// main.js
import { foo } from './foo.js'
import { bar } from './bar.js'
foo()
bar()
if (import.meta.hot) {
import.meta.hot.accept(['./foo.js', './bar.js'], (newFoo, newBar) => {
newFoo?.foo()
newBar?.bar()
})
}
propagateUpdate 函数负责生成 boundary, 我们来看下具体实现:
function propagateUpdate(
node,
traversedModules,
boundaries,
currentChain = [node]
) {
// 避免循环引用导致的死循环,因此用 traversedModule 作为缓存
if (traversedModules.has(node)) {
return false
}
traversedModules.add(node)
// 示例一的情况:
if (node.isSelfAccepting) {
// boundary 是模块本身
// accept 的是模块本身
boundaries.push({ boundary: node, acceptedVia: node });
return false;
}
// importers 是 依赖了 node 的模块合集
// 在示例二中,若 node 是 foo, 则其 importers 是 ['main']
for (const importer of node.importers) {
// importer.acceptedHmrDeps 是指会触发热替换的模块
// 若 mod.importer.acceptedHmrDeps 不包含 mod (模块自身)时
// 表示该模块在发生变化时不会触发热模块替换。
// 继续上述例子, 则 acceptedHmrDeps 的值是 ['foo']
if (importer.acceptedHmrDeps.has(node)) {
// 继续上述例子, boundary 是 main
// acceptedVia 是 foo
boundaries.push({ boundary: importer, acceptedVia: node })
continue
}
// 如果发现循环引用,则不进一步处理,直接刷新页面处理
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true
}
// 如果没有找到当前 node 模块的 boundary, 则递归向上继续寻找
const subChain = currentChain.concat(importer)
if (propagateUpdate(importer, traversedModules, boundaries, subChain)) {
return true
}
}
return false;
}
接着我们继续查看 handleHMRUpdate 函数中,对生成的 boundary 信息的进一步处理:
async function handleHMRUpdate(file) {
const mods = moduleGraph.getModulesByFile(file);
const updates = [];
for (const mod of mods) {
const boundaries = []
const traversedModules = new Set()
// 关键: 注意 boundaries 数组
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
if (hasDeadEnd) {
needFullReload = true;
continue;
}
// 根据上一步收集的 boundaries 数组,生成热更信息
updates.push(
...boundaries.map(({ boundary, acceptedVia }) => ({
type: `js-update` as const,
timestamp: Date.now(),
path: boundary.url,
explicitImportRequired: false,
acceptedPath: acceptedVia.url,
})),
)
if (needFullReload) {
ws.send({
type: 'full-reload',
})
}
ws.send({
type: 'update',
updates,
})
}
}
客户端如何执行模块的更新
在上面的步骤中,有一步是向每个模块中注入一个函数 createHotContext, 用来实现 import.meta.hot API,这个函数就是在客户端上执行的。
我们先来看一下这几个 import.meta.hot API 是如何实现的。
const hotModulesMap = new Map()
// ownerPath: 模块对应的 url
function createHotContext(ownerPath) {
// hotModulesMap 是一个全局的对象,用来缓存每次需要热替换的模块
const mod = hotModulesMap.get(ownerPath)
if (mod) {
// 当模块需要热更时,需要清除旧的上下文
mod.callbacks = []
}
const hot = {
// 先只说明 accept 的实现方法
// 其他 API 的实现可参考:vite/packages/vite/src/client/client.ts
accept(deps, callback) {
if (typeof deps === 'function' || !deps) {
// 为示例一的情况,第一个参数是函数,表示对当前模块进行热替换
// self-accept: hot.accept(() => {})
acceptDeps([ownerPath], ([mod]) => deps?.(mod))
} else if (typeof deps === 'string') {
// 为示例二的情况
acceptDeps([deps], ([mod]) => callback?.(mod))
} else if (Array.isArray(deps)) {
// 为示例三的情况
acceptDeps(deps, callback)
} else {
throw new Error(`invalid hot.accept() usage.`)
}
},
}
// 核心函数 acceptDeps 的实现:
function acceptDeps(deps, callback) {
// 初始化 mod
const mod = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: [],
}
// 将热更相关信息存到 callbacks 对象中
mod.callbacks.push({
// deps 是需要热更的模块路径
deps,
// 热更的回调函数
fn: callback,
})
// 将当前模块的路径(类似 boundary) 和 需要热更的模块(类似 acceptUrl)缓存在hotModulesMap中
// 待收到热更事件时执行热更回调。
hotModulesMap.set(ownerPath, mod)
}
}
从以上代码可以看到,当加载用户代码对应的模块,执行 import.meta.hot API 时,会将所有热更信息缓存到对象中,待模块变更后,进行热更模块替换。
那么,接下来,我们来看下当模块变更,客户端收到服务端的热更事件后,是如何处理的呢? 具体的代码在 vite/packages/vite/src/client/client.ts 中,这部分代码最终会在客户端上执行。
我们来看一下在客户端上,接受到事件后都做了哪些处理:
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data)
switch (payload.type) {
case 'update':
await Promise.all(payload.updates.map(async update => {
if (update.type === 'js-update') {
// queueUpdate 的作用是:当同一个 src 文件内容多次变化时,
// 会触发多个 hotUpdate 事件,需要保证执行的顺序
// fetchUpdate 的作用是获取更新后的模块
return queueUpdate(fetchUpdate(update))
}
}))
}
})
核心是 fetchUpdate 函数来对服务端传来的数据进行处理。
function fetchUpdate({
// 对应 boundary.url
path,
// 对应 acceptedVia.url
acceptedPath,
// 服务端开始处理热重载的时间
timestamp,
// js下,此值为 undefined
explicitImportRequired,
}) {
// 对应上文示例一的情况
const isSelfUpdate = path === acceptedPath
let fetchedModule = undefined
// 当前的 boundary 可能定义了多个热重载回调 (触发了多次 import.meta.hot)
// 筛选出包含 acceptUrl 的回调函数
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
deps.includes(acceptedPath),
)
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
// 直接 re-import 最新的模块
// ?? 模块何时生成的
fetchedModule = await import(
// 路径简单处理:
`${acceptedPathWithoutQuery}t=${timestamp}${query ? `&${query}` : ''}`
)
}
const mod = hotModulesMap.get(path)
// 执行对应的热更回调,返回函数主要是便于 queueUpdate 函数处理
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
// 调用示例:
// fn([newModule])
// fn([newModule, undefined])
fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
}
}
}
至此,热重载的过程就结束了,可以看到,整个热重载的过程,是由服务端先通过 watcher 监听文件变化,并根据依赖图计算出 boundary 和 acceptViaUrl, 将其发送给客户端,由客户端来执行对应的热更回调函数,从而实现了热重载。
在上述代码中,我们看到有这么一段:
fetchedModule = await import(
// 路径简单处理:
`${acceptedPathWithoutQuery}t=${timestamp}${query ? `&${query}` : ''}`
)
当客户端执行 import 时,会向服务器发送请求,那么服务器是如何处理这个请求及 query, 来获得最新的模块内容呢,我们可以来看一下服务器这部分的实现:
对请求的响应是在一个中间件内实现的:
return async function viteTransformMiddleware(req, res, next) {
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url, false))?.transformResult?.etag === ifNoneMatch
) {
// 客户端每次发送请求,会带上一个条件,如 if-none-match 头部字段,包含之前收到的 Etag 值,
// 根据该值来校验是否使用缓存,如资源未发生变化,则直接返回 304
res.statusCode = 304
return res.end()
}
// resolve, load and transform using the plugin container
// 在转换的时候,会去掉时间戳,再来处理路径
const result = await transformRequest(url);
// 设置缓存相关请求头
res.setHeader('Etag', result.etag);
// 不要直接使用缓存,而是每次请求时都与服务器进行验证。
res.setHeader('Cache-Control', 'no-cache')
res.statusCode = 200
// 将转换后的内容返回
res.end(result.code)
}
疑问
- 是哪个插件加载的文件,如何处理的 query
- ?import 的用处