Vite 源码(七)Vite 的热更新原理

2,660 阅读14分钟

前置

经过前面几篇铺垫之后,接下来看下 Vite 的热更新原理,先看下前置流程图

接下来会详细分析下上图的过程

demo

假设main.ts文件如下

import a from './a'
console.log(a)
if(import.meta.hot){
    import.meta.hot.accept('./a', a => {
        console.log('hmr', a)
    })
}

请求 main.ts

请求main.ts时,如果main.ts中存在import.meta.hot.accept。经importAnalysisPlugin编译后,会向这个文件中添加一段代码

import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext(url/* 从项目根路径查找的当前文件的绝对路径 */);

当客户端执行main.ts时,调用/@vite/clientcreateHotContext函数

监听服务器返回的消息

/@vite/client路径解析之后对应源码的位置就是/src/client/client.ts

const socketProtocol =
  __HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
// 创建 WebSocket 对象
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
const base = __BASE__ || '/'

// Listen for messages
socket.addEventListener('message', async ({ data }) => {})

首先会创建一个WebSocket对象,并注册监听事件,也就是说当服务器通过WebSocket返回信息时,会被这个事件捕获。

还会定义一些变量,这些变量如下

/**
 * 存储的是被请求文件的 import.meta.hot.accept
 * key: 当前文件地址
 * value: { id: 文件地址, callbacks: [{ deps: 被监听的文件路径数组, fn: 定义的回调函数 }] }
 */
const hotModulesMap = new Map<string, HotModule>()
/**
 * 存储的是所有被请求文件的 import.meta.hot.dispose
 * key: 当前文件地址
 * value: 定义的回调函数
 */
const disposeMap = new Map<string, (data: any) => void | Promise<void>>()
/**
 * 存储的是所有被请求文件的 import.meta.hot.prune
 * key: 当前文件地址
 * value: 定义的回调函数
 */
const pruneMap = new Map<string, (data: any) => void | Promise<void>>()
/**
 * 存储的是所有被请求文件的 import.meta.hot.data
 * 对象在同一个更新模块的不同实例之间持久化。它可以用于将信息从模块的前一个版本传递到下一个版本。
 * key: 当前文件地址
 * value: 是一个对象,持久化的信息
 */
const dataMap = new Map<string, any>()
/**
 * 存储的是被请求文件监听的 hmr 钩子
 * key: 事件名称
 * value: 事件对应回调函数的数组
 */
const customListenersMap = new Map<string, ((data: any) => void)[]>()
/**
 * 存储的是被请求文件监听的 hmr 钩子
 * key: 当前文件地址
 * value: 是一个 Map,Map 内的 key 是事件名,valye 是一个回调函数数组
 */
const ctxToListenersMap = new Map<string, Map<string, ((data: any) => void)[]>>()

export const createHotContext = (ownerPath: string) => {}

customListenersMapctxToListenersMap在最后介绍自定义钩子时会介绍

创建 import.meta.hot 对象

继续向下,导出 createHotContext 函数

上面说过,对于存在import.meta.hot.accept的模块会执行这个函数,并传入当前文件的绝对路径。

import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext(url/* 从项目根路径查找的当前文件的绝对路径 */);

createHotContext函数如下

// ownerPath 当前文件的路径
export const createHotContext = (ownerPath: string) => {
    // 如果 dataMap 中没有当前路径,则添加到 dataMap 中
    if (!dataMap.has(ownerPath)) {
        dataMap.set(ownerPath, {})
    }

    // when a file is hot updated, a new context is created
    // clear its stale callbacks
    const mod = hotModulesMap.get(ownerPath)
    if (mod) {
        // 清空 cb
        mod.callbacks = []
    }

    // 清除过时的自定义事件监听器(最后介绍自定义钩子时会介绍)
  	// ...

    const newListeners = new Map()
    ctxToListenersMap.set(ownerPath, newListeners)

    function acceptDeps(){}

    const hot = {
        get data() {},
        accept(deps: any, callback?: any) {},
        acceptDeps() {},
        dispose(cb: (data: any) => void) {},
        prune(cb: (data: any) => void) {},
        // TODO
        decline() {},
        invalidate() {},
        on: (event: string, cb: (data: any) => void) => {},
    }

    return hot
}

这个函数最主要的作用就是定义一个hot对象,并返回这个对象。返回的对象会赋值给模块的import.meta.hot

总结下createHotContext函数的作用:

  • 创建持久化数据对象,并添加到dataMap
  • 清空hotModulesMap内当前路径绑定的依赖回调(accept函数的参数)
  • 清除过时的自定义事件监听器
  • 给当前路径创建自定义事件回调的容器,并放到ctxToListenersMap

执行import.meta.hot.accept方法

在这里主要分析下accept方法的作用,其他方法都比较简单这里就不赘述了,可以直接去源码中看。

accept(deps: any, callback?: any) {
    if (typeof deps === 'function' || !deps) {
        // self-accept: hot.accept(() => {})
        acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
    } else if (typeof deps === 'string') {
        // explicit deps
        acceptDeps([deps], ([mod]) => callback && callback(mod))
    } else if (Array.isArray(deps)) {
        acceptDeps(deps, callback)
    } else {
        throw new Error(`invalid hot.accept() usage.`)
    }
},

代码中,对import.meta.hot.accept不同的参数形式做了不同处理,分别是

  • hot.accept(() => {})接收自身
  • hot.accept('./a', () => {})接受一个直接依赖项的更新
  • hot.accept(['./a', './b'], () => {}) 接受多个直接依赖项的更新

最后都是调用acceptDeps方法,参数为接收文件的数组、对应文件更新时的回调

function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
    const mod: HotModule = hotModulesMap.get(ownerPath) || {
        id: ownerPath,
        callbacks: [],
    }
    mod.callbacks.push({
        deps,
        fn: callback,
    })
    // 设置 hotModulesMap
    hotModulesMap.set(ownerPath, mod)
}

acceptDeps方法的作用就是将文件更新时的回调函数添加到hotModulesMap中,存储的结构已经在上面说过

到此前置工作就已经做完了,接下来就是文件更新流程

更新

假设main.ts代码如下

import a from './a'
console.log(a)
if(import.meta.hot){
    import.meta.hot.accept('./a', a => {
        console.log('hmr', a)
    })
}

当更新a.ts的内容时,会被chokidar注册的监听捕获

// file:被修改文件的绝对路径
watcher.on('change', async (file) => {
    file = normalizePath(file)
    // 清空被修改文件对应的ModuleNode对象的 transformResult 属性
    // 这个属性存储的是编译后的代码内容
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
        try {
            await handleHMRUpdate(file, server)
        } catch (err) {}
    }
})

当文件修改被chokidar注册的监听器捕获之后,根据被修改文件的路径清空对应的ModuleNode中缓存的源码。然后调用handleHMRUpdate函数,开始热更新流程

export async function handleHMRUpdate(
    file: string,
    server: ViteDevServer
): Promise<any> {
    const { ws, config, moduleGraph } = server
    // 获取 file 相对于根路径的相对路径
    const shortFile = getShortName(file, config.root)
    // 如果当前文件是配置文件则为 true
    const isConfig = file === config.configFile
    // 如果当前文件名是自定义的插件则为 true
    const isConfigDependency = config.configFileDependencies.some(
        (name) => file === path.resolve(name)
    )
    //如果是环境变量文件,则为 true
    const isEnv = config.inlineConfig.envFile !== false && file.endsWith('.env')
    // 环境变量文件、自定义插件、配置文件修改会重启服务
    if (isConfig || isConfigDependency || isEnv) {
        await restartServer(server)
        return
    }


    // /xxx/node_modules/vite/dist/client
    // 如果是客户端使用的热更新文件,重新加载页面
    if (file.startsWith(normalizedClientDir)) {
        ws.send({
            type: 'full-reload',
            path: '*',
        })
        return
    }
    // 根据文件绝对路径获取 ModuleNode 对象(Set)
    // 是一个数组,因为会出现单个文件可能映射到多个服务模块,比如 Vue单文件组件
    const mods = moduleGraph.getModulesByFile(file)

    const timestamp = Date.now()
    const hmrContext: HmrContext = {
        file,
        timestamp,
        modules: mods ? [...mods] : [],
        read: () => readModifiedFile(file),
        server,
    }
    // 调用所有插件定义的 handleHotUpdate 钩子函数
    for (const plugin of config.plugins) {
        if (plugin.handleHotUpdate) {
            const filteredModules = await plugin.handleHotUpdate(hmrContext)
            if (filteredModules) {
                hmrContext.modules = filteredModules
                break
            }
        }
    }

    if (!hmrContext.modules.length) {
        // 如果是 html 文件 重新加载页面
        if (file.endsWith('.html')) {

            ws.send({
                type: 'full-reload',
                path: config.server.middlewareMode
                    ? '*'
                    : '/' + normalizePath(path.relative(config.root, file)),
            })
        } else {}
        return
    }
    updateModules(shortFile, hmrContext.modules, timestamp, server)
}

整体流程如下

  • 配置文件更新、.env更新、自定义插件或引入配置文件的自定义文件更新都会重起服务器
  • 客户端使用的热更新文件更新、index.html更新,重新加载页面
  • 调用所有插件定义的handleHotUpdate钩子函数,具体功能可参考文档
    • 过滤和缩小受影响的模块列表,使 HMR 更准确。
    • 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理
  • 如果是其他文件更新,调用updateModules函数

先大体看下updateModules函数做了什么事

function updateModules(
    file: string,
    modules: ModuleNode[],
    timestamp: number,
    { config, ws }: ViteDevServer
) {
    const updates: Update[] = []
    const invalidatedModules = new Set<ModuleNode>()
    // 是否需要重新加载
    let needFullReload = false

    // 遍历该文件编译后的所有文件
    for (const mod of modules) {}

    if (needFullReload) {
        ws.send({
            type: 'full-reload',
        })
    } else {
        ws.send({
            type: 'update',
            updates,
        })
    }
}

首先定义一个updates数组,用于存储要更新的模块;定义一个needFullReload变量,如果为true则重新加载整个页面。然后遍历传入的modules。最后根据needFullReload的值判断是重新加载整个页面还是更新某些模块updates

由此可以看出循环的作用就是根据修改模块获取整个更新链路。

// 遍历该文件编译后的所有文件
for (const mod of modules) {
    // 沿着引用路线向上查找,设置时间戳、清空 transformResult
    invalidate(mod, timestamp, invalidatedModules)
    // 如果需要重新加载,不需要再遍历其他的了
    if (needFullReload) {
        continue
    }

    const boundaries = new Set<{
        boundary: ModuleNode
        acceptedVia: ModuleNode
    }>()
    // 查找引用模块,判断是否需要重载页面
    const hasDeadEnd = propagateUpdate(mod, boundaries)
    if (hasDeadEnd) {
        // 如果 hasDeadEnd 为 true,则全部更新。
        needFullReload = true
        continue
    }

    updates.push(
        ...[...boundaries].map(({ boundary, acceptedVia }) => ({
            type: `${boundary.type}-update` as Update['type'], // 更新类型
            timestamp, // 时间戳
            path: boundary.url, // 依赖该文件的文件
            acceptedPath: acceptedVia.url, // 当前文件
        }))
    )
}

首先调用invalidate,这个函数的作用就是一层一层向上查找,并修改lastHMRTimestamp和清空transformResult;如果上层模块不接受当前模块的热更新;则继续调用invalidate,并传入上层模块对应的ModuleNode对象。检测上上层是否接受上层模块热更新;并修改上层模块对应的ModuleNode对象的lastHMRTimestamptransformResult对象。如果上上层也没有接受,再往上找。

这样做的目的是,对于上层模块来说,如果没有监听子模块更新,当子模块更新时,上层模块也需要重新加载。此时需要更新时间戳和清空缓存的代码,防止再次返回缓存的代码。

如果监听了子模块更新,就不需要更新自身了,而是可以通过监听的回调重新执行子模块导出的内容。所以就不需要更新时间戳和清空代码了。

function invalidate(mod: ModuleNode, timestamp: number, seen: Set<ModuleNode>) {
    // 防止死循环
    if (seen.has(mod)) {
        return
    }
    seen.add(mod)
    // 设置修改时间
    mod.lastHMRTimestamp = timestamp
    mod.transformResult = null
    // 遍历导入该文件的所有文件
    mod.importers.forEach((importer) => {
        // 如果上层文件的 acceptedHmrDeps 上不包含当前文件,说明上层文件没有定义接受当前文件更新的回调
        // 则再次对上层文件调用 invalidate 方法
        if (!importer.acceptedHmrDeps.has(mod)) {
            invalidate(importer, timestamp, seen)
        }
    })
}

上述操作做完之后,继续向下执行定义一个boundaries变量,并调用propagateUpdate函数,参数是当前模块对应的ModuleNode对象和boundaries变量

function propagateUpdate(
    node: ModuleNode,
    boundaries: Set<{
        boundary: ModuleNode
        acceptedVia: ModuleNode
    }>,
    currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
    // 如果是自身监听,则添加到 boundaries 中,并返回 false
    if (node.isSelfAccepting) {
        boundaries.add({
            boundary: node,
            acceptedVia: node,
        })
        // ...

        return false
    }
    // 当前模块没有被任何模块导入,返回 true,即全部更新
    if (!node.importers.size) {
        return true
    }
    // 当前文件不是 css 文件,并且只有css文件导入了当前模块,返回 true
    if (!isCSSRequest(node.url) && [...node.importers].every((i) => isCSSRequest(i.url))) {
        return true
    }
    // 遍历导入此模块的所有模块对象,向上查找
    for (const importer of node.importers) {
        const subChain = currentChain.concat(importer)
        // 如果当前模块的上层模块接收当前模块的更新,则添加到 boundaries 中
        if (importer.acceptedHmrDeps.has(node)) {
            boundaries.add({
                boundary: importer, // 导入当前模块的模块对象
                acceptedVia: node, // 当前模块
            })
            continue
        }
        // 重复引入,如果不 return 出去,则会造成死循环
        if (currentChain.includes(importer)) {
            return true
        }
        // 递归调用 propagateUpdate,收集 boundaries,向上查找
        if (propagateUpdate(importer, boundaries, subChain)) {
            return true
        }
    }
    return false
}

这个函数的作用就是获取要更新的所有模块,并判断是否要重新加载整个页面。沿着导入链向上查找,直到找到接收自更新或者接收子模块更新的模块,将这个模块添加到boundaries中;并返回true,反之返回false

  • 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,其中模块 A 接收模块 B 更新;当修改模块 D 时,返回false,并将 模块A 收集到boundaries
  • 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,其中模块 C 接收模块 D 更新;当修改模块 D 时,返回false,并将 模块C 收集到boundaries
  • 假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D ,所有模块都不接受热更新;当修改模块 D 时,返回trueboundaries为空

回到updateModules函数,将收集到的模块数组和updates合并

updates.push(
    ...[...boundaries].map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as Update['type'], // 更新类型 js/css
        timestamp, // 时间戳
        path: boundary.url, // 导入该模块的模块
        acceptedPath: acceptedVia.url, // 当前模块
    }))
)

循环完成之后,将消息发送给客户端。根据needFullReload判断更新方式;如果propagateUpdate返回true,说明需要重新加载页面。反之就是更新模块。

if (needFullReload) {
    ws.send({
        type: 'full-reload',
    })
} else {
    ws.send({
        type: 'update',
        updates,
    })
}

总流程如下

客户端接收消息

先看下流程图

前面分析过,客户端注册了 WebSocket 监听

socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data))
})

当客户端接收到服务器发过来的消息后,调用handleMessage函数

async function handleMessage(payload: HMRPayload) {
    switch (payload.type) {
        case 'connected':
            console.log(`[vite] connected.`)
            setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
            break
        case 'update':
            // 调用 vite:beforeUpdate 事件的回调
            notifyListeners('vite:beforeUpdate', payload)
            // ...

            payload.updates.forEach((update) => {
                if (update.type === 'js-update') {
                    queueUpdate(fetchUpdate(update))
                } else {/* ... */}
            })
            break
        case 'custom': {
            // ...
            break
        }
        case 'full-reload':
            // 调用 vite:beforeFullReload 事件的回调
            notifyListeners('vite:beforeFullReload', payload)
            if (payload.path && payload.path.endsWith('.html')) {
                // if html file is edited, only reload the page if the browser is currently on that page.
                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
        case 'prune':
            // ...
            break
        case 'error': {/* ... */}
        default: {/* ... */}
    }
}

handleMessage根据服务器传入的type走不同逻辑,这里我们只看update逻辑的。

遍历需要更新的模块数组,如果是js-update类型,对这个模块执行queueUpdate(fetchUpdate(update))

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
    // path 接收热更新的模块
    // mod:{ id: 文件地址, callbacks: [{ deps: 被监听的文件路径数组, fn: 定义的回调函数 }] }
    const mod = hotModulesMap.get(path)
    if (!mod) {
        return
    }

    const moduleMap = new Map()
    // 如果是自身更新
    const isSelfUpdate = path === acceptedPath

    // make sure we only import each dep once
    const modulesToUpdate = new Set<string>()
    if (isSelfUpdate) {
        // self update - only update self
        modulesToUpdate.add(path)
    } else {
        // deps 中存储的是当前模块接受的直接依赖项
        // 这块代码逻辑是说,如果当前模块接收的依赖项中包含 acceptedPath 模块,则将这个路径添加到 modulesToUpdate 中
        for (const { deps } of mod.callbacks) {
            deps.forEach((dep) => {
                if (acceptedPath === dep) {
                    modulesToUpdate.add(dep)
                }
            })
        }
    }

    // 获取符合条件的回调
    // 过滤条件是如果 mod.callbacks.deps 中的元素在 modulesToUpdate 中存在,则返回 true
    const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
        return deps.some((dep) => modulesToUpdate.has(dep))
    })

    await Promise.all(
        Array.from(modulesToUpdate).map(async (dep) => {
            // 获取模块设置的副作用中的回调 import.meta.hot.dispose
            const disposer = disposeMap.get(dep)
            // import.meta.hot.data 对象在同一个更新模块的不同实例之间持久化。它可以用于将信息从模块的前一个版本传递到下一个版本。
            // 调用 disposer
            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 中
                moduleMap.set(dep, newMod)
            } catch (e) {}
        })
    )

    return () => {
        for (const { deps, fn } of qualifiedCallbacks) {
            // 调用 import.meta.hot.accept 中定义的回调函数,并将文件的导出内容传入回调函数中
            fn(deps.map((dep) => moduleMap.get(dep)))
        }
        const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
        console.log(`[vite] hot updated: ${loggedPath}`)
    }
}

fetchUpdate函数的作用就是收集需要更新的模块路径和import.meta.hot.accept中的回调函数,调用import.meta.hot.disposer的回调函数清空副作用;拼接更新模块的路径,会挂上import和时间戳;通过import()加载拼接后的路径。最后返回一个函数,这个函数的作用就是import.meta.hot.accept中的回调函数,并打印更新信息。

返回的这个函数被queueUpdate函数接收;queueUpdate函数内会收集fetchUpdate函数返回的函数;并在下一个任务队列中触发所有回调。

// fetchUpdate 函数返回的函数,函数内调用 import.meta.hot.accept 内定义的回调函数,并将请求文件的导出内容当作参数传入
async function queueUpdate(p: Promise<(() => void) | undefined>) {
    queued.push(p)
    if (!pending) {
        pending = true
        await Promise.resolve()
        pending = false
        const loading = [...queued]
        queued = []
        ;(await Promise.all(loading)).forEach((fn) => fn && fn())
    }
}

总结

Vite 的热更新原理可以大体总结为下面几步

前置

  • 服务器启动时创建 WebSocket 实例、通过 chokidar 监听文件修改
  • 对请求的 HTML 文件注入客户端热更新代码
  • 加载客户端热更新代码时,创建 WebSocket 实例,并注册监听
  • 当被请求文件中有import.meta.hot.accept时,向该文件注入import.meta.hot.accept定义

更新

  • 当文件更新时,触发文件修改的回调。
  • 如果是配置文件、自定义插件、.env文件修改直接重启服务器
  • 反之,根据模块路径向上查找;收集接受当前依赖项更新的模块,并判断是否是刷新页面
  • 如果是刷新页面,向客户端发送刷新页面的消息,反之发送更新消息,并将接受当前依赖项更新的模块一起发送给客户端
  • 客户端接收到之后,获取要更新的模块路径和热更新回调,通过import()请求要更新模块的路径,并在 URL 挂上import和时间戳;并在下一任务队列中触发热更新回调。

举例

假设有 A、B、C、D 四个模块,他们的引用关系是 A -> B -> C -> D

有模块接受依赖项更新
  • 其中模块 A 接收模块 B 更新;当修改模块 D 时,此时是局部更新,修改模块B、C、D的时间戳并清空源码缓存;将 模块A 收集到boundaries中。服务器返回的消息中,包含模块A的相关信息。客户端接收到消息后,查找模块A接收热更新的模块。也就是模块B。拼接模块B的路径并重新请求模块B。Vite会将模块B内的导入路径(模块C)挂上t参数,从而强制浏览器重新请求。模块B返回后,请求模块C,也会给导入路径(模块D)挂上t参数。最后调用模块A中的热更新回调。
  • 其中模块 C 接收模块 D 更新;当修改模块 D 时,此时是局部更新,修改模块D的时间戳并清空源码缓存;将 模块C 收集到boundaries中。服务器返回的消息中,包含模块C的相关信息。客户端接收到消息后,查找模块C接收热更新的模块。也就是模块D。拼接模块D的路径并重新请求模块D。模块D返回后,调用模块C中的热更新回调。
模块接收自更新
  • 假设模块 D 接收自更新;当修改模块 D 时,此时也是局部更新,并将 模块D 自身收集到boundaries中。服务器返回的消息中,包含模块D的相关信息。客户端接收到消息后,由于是接收自更新,所以查找模块D。拼接模块D的路径并重新请求模块D。模块D返回后,调用模块D中的热更新回调。
  • 假设模块A接收自更新;当修改模块 D 时,此时是局部更新,,修改模块B、C、D的时间戳并清空源码缓存;将 模块A 收集到boundaries中。服务器返回的消息中,包含模块A的相关信息。客户端接收到消息后,由于是接收自更新,所以查找模块A。拼接模块A的路径并重新请求模块A。也是会修改导入模块B、C、D的路径挂上t参数。模块A返回后,调用模块A中的热更新回调。
没有模块接收热更新

当修改模块D时,由于没有模块接收热更新,所以会直接像客户端发送页面重新加载的消息,客户端接收到之后,直接刷新页面。

自定义钩子

Vite 的 HMR 有4个自定义钩子,分别在不同时机自动触发:

  • 'vite:beforeUpdate' 当更新即将被应用时(例如,一个模块将被替换)
  • 'vite:beforeFullReload' 当完整的重载即将发生时
  • 'vite:beforePrune' 当不再需要的模块即将被剔除时
  • 'vite:error' 当发生错误时(例如,语法错误)

也可以通过handleHotUpdate钩子函数注册新的钩子函数。

handleHotUpdate({ server }) {
  server.ws.send({
    type: 'custom',
    event: 'special-update',
    data: {}
  })
  return []
}

怎么传入回调

通过import.meta.hot.on

const hot = {
    on: (event: string, cb: (data: any) => void) => {
      const addToMap = (map: Map<string, any[]>) => {
        const existing = map.get(event) || []
        existing.push(cb)
        map.set(event, existing)
      }
      addToMap(customListenersMap)
      addToMap(newListeners)
    }
}

当执行import.meta.hot.on时,调用了两次addToMap函数,第一次传入customListenersMap,第二次传入newListeners。并将传入的回调放到这两个变量里面。

customListenersMap是在执行/@vite/client模块创建的

/**
 * 存储的是被请求文件监听的 hmr 钩子
 * key: 事件名称
 * value: 事件对应回调函数的数组
 */
const customListenersMap = new Map<string, ((data: any) => void)[]>()

newListeners是在执行createHotContext函数创建的。

const newListeners = new Map()
ctxToListenersMap.set(ownerPath, newListeners)

也就是说newListeners最终存储在ctxToListenersMap

/**
 * 存储的是被请求文件监听的 hmr 钩子
 * key: 当前文件地址
 * value: 是一个 Map,Map 内的 key 是事件名,valye 是一个回调函数数组
 */
const ctxToListenersMap = new Map<
  string,
  Map<string, ((data: any) => void)[]>
>()

ctxToListenersMapcustomListenersMap的区别是:

  • customListenersMap存储的结构是:事件名:事件回调数组
  • ctxToListenersMap存储的结构是:文件名:Map<事件名,事件回调数组>

在执行createHotContext时,还有一段代码,用于清空过时的事件回调。因为如果当前文件重新请求,会重新创建一个新的Context上下文,之前的就没用了,需要清空。

// 获取当前模块监听的所有事件
const staleListeners = ctxToListenersMap.get(ownerPath)
if (staleListeners) {
  // 遍历当前模块监听的回调
  for (const [event, staleFns] of staleListeners) {
    // 将 staleFns 中所有回调从 customListenersMap 中清除
    const listeners = customListenersMap.get(event)
    if (listeners) {
      customListenersMap.set(
        event,
        listeners.filter((l) => !staleFns.includes(l))
      )
    }
  }
}

钩子调用时机

客户端监听到服务器消息后会调用handleMessage钩子函数;

async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
      case 'update':
      	notifyListeners('vite:beforeUpdate', payload)
      	// ...
      	break;
      case 'custom': {
        notifyListeners(payload.event as CustomEventName<any>, payload.data)
        break
      }
    case 'full-reload':
      notifyListeners('vite:beforeFullReload', payload)
      break;
  }

上面只是源码的一部分,从上面可以看到当服务器返回不同消息类型时,会调用不同的钩子函数

function notifyListeners(event: string, data: any): void {
  const cbs = customListenersMap.get(event)
  if (cbs) {
    cbs.forEach((cb) => cb(data))
  }
}

就是从customListenersMap中根据事件名获取所有回调,然后执行这些回调