Vite热更新原理(HMR)

491 阅读15分钟

vite 的简单介绍

vite在开发环境和生产环境有两种不同的处理方式

开发环境,vite以原生ESM方式提供源码,让浏览器接管了打包程序的部分工作,vite只需要在浏览器请求源码时进行转换,并按需提供源码,根据所需资源动态导入代码,在浏览器当前页面被使用的才会被处理。

image.png

而在本地开发中,肯定会有本地代码在变化,如何最大限度的在不刷新页面的情况下,进行代码的替换,这个功能就是众所周知的热更新(HMR),我们需要在开发阶段启动一个Dev Server.体现在代码中就是我们在Vite配置文件-vite.config.ts中有一个字段server。

HNR允许我们在不刷新页面的情况下更新代码,比如编辑组件标记或调整样式,这些更改会立即反应在浏览器中,从而实现更快的代码交互和更好的开发体验

生产环境中,vite利用rollup对代码进行打包处理,并配合着tree-shaking/懒加载和chunk分割的方式为浏览器提供最后的代码资源。体现在代码中就是我们在vite配置文件-vite.config.ts会有一个build

vite内部打包流程:

image.png

今天学习到的是vite如何在开发环境中实现的hmr

vite对应的hmr源码

学到的知识

  • 模块替换
  • hmr何时发生
  • hmr客户端

1. 模块替换

模块替换的基本原理,在应用程序运行时动态替换模块

大多数打包工具使用ECMAScript模块ESM作为模块,因为更容易分析导入和导出,这有助于确定一个模块的替换会如何影响其他相关模块。

一个模块通常可以访问HMR API, 以处理旧模块删除新模块新增的情况,在vite中,我们可以使用api:

可以的带如下模块处理流程

image.png

值得思考的是,我们需要如何使用这些api才能让hmr工作,日例如,vite模块情况下会为css文件使用这些api,但是对应vue / react的文件,我们可以使用一个vite插件来使用这些hmr api 或者根据需要手动处理, 否则,这些文件的更新将默认导致浏览器刷新。

针对不同的语言环境,也是需要对应的插件进行这些api的调用处理。下面列举几个比较场景的插件实现

在vite官网中, image.png handleHotUpdate用于处理HMR更新 image.png 从vite-vue 中就可以看到对应的处理过程 image.png

如上是写插件的钩子,需要注意

accept()

import.meta.hot.accept()

使用import.meta.hot.accept()添加一个回调时,该回调将负责用新模块替换旧模块,使用此api的模块也叫已接受模块

已接受模块创建一个HMR边界, 一个HMR边界包含模块本身以及所有递归导入的模块,接受模块通常也是HMR边界,因为边界通常是图形结构

image.png 已接受模块也可以根据HMR回调的位置缩小范围,如果accept中接受一个回调,此时模块被称为自接受模块

import.meta,hot.accept有两种函数签名:

  • import.meta.hot.accept(cb: Function) - 接受来自自身的更改
  • import.meta,hot.accept(deps: string|string[], cb: Function)-接受来自导入的模块的更改

如果使用第一种,就是自接受模块

自接受模块
export let data = [1,2,3]

if(import.meta.hot){
// 用新值替换旧值
    import.meta.hot.accept(newModule => {
        data = newModule.data
    })
}
已接受模块
import {value} from './stuff.js'

document.querySelector('#value').textContent = value

if(import.meta.hot){
    import.meta.hot.accpet(['./stuff.js'], newModule => {
        // 新值重新渲染
        document.querySelector('#value').textContent = newModule.value
    })
}

dispose()

import.meta.hot.dispose()

当一个已接受模块替换为新模块,或者被移除,we can use import.meta.hot.dispose()进行清理,清理掉旧模块产生的负所用,如删除事件监听器,清除计时器, 重置状态.

globalThis.obj = {}

if(import.meta.hot) {
    // 重置全局状态
    import.meta.hot.dispose(() => {
        globalThis.obj = {}
    })
}

prune()

import.meta.hot.prune()

当一个模块要从运行时完全移除,例如一个文件被删除,we can use import.meta.hot.prune(), 进行修剪(最终处理),类似import.meta.hot.dispose(),但只在模块被移除时调用一次。

vite通过导入分析阶段来进行模块清理,因为我们能知道一个模块不再使用的唯一时机就是它不再被任何模块导入,(类似 v8 gc的黑白灰标记算法)

处理CSS

// 导入用于更新/移除 html中的样式标签工具
import {updateStyle, removeStyle} from '@vite/client'

updateStyle('/src/style.css', 'body{color: red}')

if(import.meta.hot){
    // 空的回调表示我们接受了这个模块,但可以杀都不做
    // updateStyle 将自动删除旧的样式标签
    import.meta.hot.accept()
    // 当模块不再被使用时,移除样式
    import.meta.hot.prune(() => {
        removeStyle('/src/style.css')
    })
}
invalidate()
import.meta.hot.invalidate()

和前面的api不同,import.meta.hot.invalidate是一个操作,而不是一个生命周期钩子,通常会在import.meta.hot.accept() 中去使用它,在运行时可能会意识到模块无法安全更新,我们需要退出

调用这个方法时,vite服务器将被告知模块已失效,就像该模块已经被更新一样,HMR传播再次执行,以确定其导入者是否可以递归地接受此更改

export let data = [1, 2, 3]

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 如果 `data` 导出被删除或重命名
    if (!(data in newModule)) {
      // 退出并使模块失效
      import.meta.hot.invalidate()
    }
  })
}

上述就是针对涉及到HRM的相关API的简单介绍。更具体的解释,可以参考vite_hmr

2.HMR何时发生

既然,HMR API赋予了我们替换删除模块的能力,光有能力是不行的,我们需要了解它们何时才会起作用。其实,HMR 通常发生在编辑文件之后,但是之后又发生了啥,我们不得而知,这就是我们这节需要了解的内容。

它的总体流程如下:

image.png

编辑文件

编辑文件保存时,hmr就开始了,文件稀土监视器(chokidar)会检测到更改并将编辑后的文件路径传递到下一步。

处理编辑后的模块

vite开发服务器得知了编辑后的文件路径,然后使用文件立即来找到模块图中的相关模块

文件模块是两个不同的概念,一个文件可能对应一个或多个模块 例如:一个vue文件可以编译成一个JavaScript模块和一个相关的CSS模块

然后,这些模块被传递给vite插件和handleHotUpdate()钩子进行下一步处理。它们可以选择过滤扩展模块数组,最终的模块将传递到下一步

过滤模块数组

vite的热更新函数handleHotUpdate,HMR API image.png

function vuePlugin(){
    return {
        name: 'vue-plugin',
        handleHotUpdate(ctx){
            if(ctx.file.endWith('.vue')){
                const oldContent = cache.get(ctx.file)
                const newContent = await ctx.read()
                // 如果编辑文件时只有样式发生变化,
                // 可以直接过滤出css模块,直接替换css模块
                if(isOnlyStyleChanged(oldContent, newContent)){
                    return ctx.modules.filter(f => f.url.endsWith('.css'))
                }
            }
        }
    }
}

如上是一个简单处理css模块的热更新,其实就是替换了css模块,然后浏览器会重新加载css

(vite-plugin-vue)

image.png

扩展模块数组

function globalCssPlugin(){
    return {
        name: "global-css",
        handleHotUpdate(ctx){
        // 如果编辑了 CSS 文件,
        // 我们还会触发此特殊的 `virtual:global-css` 模块的 HMR,
        // 该模块需要重新转换。
        if(ctx.file.ednsWith('.css')){
            const mod = ctx.server.moduleGraph.getModuleById('virtual:global-css')
            if(mod){
            return ctx.modules.concat(mod)
            }
        }
        }
    }
}

模块失效

HMR传播之前,我们需要将最终更新的模块数组及导入者递归失效。每个模块的转换代码都将被移除,并附加一个失效时间戳,时间戳将在客户端的下一个请求中获取新模块。

HMR传播

现在,最终的更新模块数组将通过HMR传播,这是HMR是否起作用的核心步骤,如果传播过程有数据丢失,那么HMR将会达不到我们想要的预期,也就是部分模块没及时更新或者更新失败了

HMR传播就是以更新的模块作为起点,向四周扩散,最后找到与模块相关的模块信息,并且形成一个无形的环,或者给它起一个更高大的名字-HMR边界

  • 如果所有更新的模块都在一个边界内,vite开发服务器将通知HMR客户端通知接受的模块执行HMR。
  • 如果有些模块不在边界内,则会触发完整的页面重新加载

案例分析

image.png

  1. APP.JSX是一个接受模块,也就是,在其内部触发了import.meta.hot.accept()
  2. app.jsx相关的文件有stuff.jsutils.js,也就意味着,他们会形成一个HMR边界
情况 1

如果更新 stuff.js,传播将递归查找其导入者以找到一个接受的模块。在这种情况下,我们将发现 app.jsx 是一个接受的模块。但在结束传播之前,我们需要确定 app.jsx 是否可以接受来自 stuff.js 的更改。这取决于 import.meta.hot.accept() 的调用方式。

  • 情况 1(a):如果 app.jsx自接受的,或者它接受来自 stuff.js 的更改,我们可以在这里停止传播,因为没有其他来自 stuff.js 的导入者。然后,HMR 客户端将通知 app.jsx 执行 HMR
  • 情况 1(b):如果 app.jsx 不接受这个更改,我们将继续向上传播以找到一个接受的模块。但由于没有其他接受的模块,我们将到达项目的根节点 - index.html 文件。此时将触发整个项目的重新加载。
情况 2:

如果更新 main.js 或 other.js,传播将再次递归查找其导入者。然而,没有接受的模块,我们将到达项目的根节点 - index.html 文件。因此,将触发完整的页面重新加载。

情况 3:

如果更新 app.jsx,我们立即发现它是一个接受的模块。然而,一些模块可能无法更新其自身的更改。我们可以通过检查它们是否是自接受的模块来确定它们是否可以更新自身。

  • 情况 3(a):如果 app.jsx 是自接受的,我们可以在这里停止,并让 HMR 客户端通知它执行 HMR。
  • 情况 3(b):如果 app.jsx不是自接受的,我们将继续向上传播以找到一个接受的模块。但由于它们都没有,我们将到达项目的根节点 - index.html 文件,将触发完整的页面重新加载。
情况 4:

如果更新 utils.js,传播将再次递归查找其导入者。首先,我们将找到 app.jsx 作为接受的模块,并在那里停止传播(例如情况 1(a))。然后,我们也会递归查找 other.js 及其导入者,但没有接受的模块,我们将到达项目的根节点 - index.html 文件。

最后,HMR传播的结果是是否需要进行完整页面重新加载,或者是否应该在客户端应用 HMR 更新。

3. HMR 客户端

Vite 应用中,我们可能会注意到 HTML 中添加了一个特殊的脚本<script type="module" src="/@vite/client"></script>,请求 /@vite/client。这个脚本包含了 HMR 客户端

我们可以在Chrome-devtool-sources中进行查看

image.png

HMR 客户端负责:

  • 与 Vite 开发服务器建立 WebSocket 连接。
  • 监听来自服务器的 HMR 载荷
  • 在运行时提供和触发 HMR API
  • 将任何事件发送回 Vite 开发服务器

从更广泛的角度来看,HMR 客户端帮助将 Vite 开发服务器和 HMR API 粘合在一起。

image.png

客户端初始化

HMR客户端能够从vite开发服务器接收任何消息之前,它首先需要建立与其的链接,通常通过WebSockets

下面是一个设置websocket链接并处理HMR传播结果的示例:

/@vite/client

const ws = new WebSocket('ws://localhost:5173')

ws.addEventListener('message', ({data}) => }
    const payload = JSON.parse(data)
    switch (paul;oad.type){
        case: "': 
           // 处理载荷
    }
|)
// 将任何事件发生回vite服务器
ws.send('')

除此之外,HMR 客户端还初始化了一些处理 HMR 所需的状态,并导出了几个 API,例如 createHotContext(),供使用 HMR API 的模块使用。例如:

// 由 Vite 的导入分析插件注入
import { createHotContext } from '/@vite/client'
import.meta.hot = createHotContext('/src/app.jsx')

export default function App() {
  return <div>Hello Front789</div>
}

// 由 `@vitejs/plugin-react` 注入
if (import.meta.hot) {
  // ...
}

传递给 createHotContext() 的 URL 字符串(也称为 owner 路径)有助于标识哪个模块能够接受更改。在createHotContext 将注册的 HMR 回调分配单例类型,而该类型用于存储owner 路径到接受回调、处理回调和修剪回调之间的关联信息。const ownerPathToAcceptCallbacks = new Map<string, Function[]>()

这基本上就是模块如何与 HMR 客户端交互并执行 HMR 更改的方式。

处理来自服务器的信息

建立 WebSocket 连接后,我们可以开始处理来自 Vite 开发服务器的信息。

/@vite/cient

ws.addEventListener('message', ({ data }) => {
  const payload = JSON.parse(data)
  switch (payload.type) {
    case 'full-reload': {
      location.reload()
      break
    }
    case 'update': {
      const updates = payload.updates
      // => { type: string, path: string, acceptedPath: string, timestamp: number }[]
      for (const update of updates) {
        handleUpdate(update)
      }
      break
    }
    case 'prune': {
      handlePrune(payload.paths)
      break
    }
    // 处理其他载荷类型...
  }
})

上面的示例处理了 HMR 传播的结果,根据 full-reloadupdate 信息类型触发完整页面重新加载或 HMR 更新。当模块不再使用时,它还处理修剪。

还有更多类型的信息类型需要处理

  • connected:当建立 WebSocket 连接时发送。
  • error:当服务器端出现错误时发送,Vite 可以在浏览器中显示错误覆盖层。
  • custom:由 Vite 插件发送,通知客户端任何事件。对于客户端和服务器之间的通信非常有用。

接下来,让我们看看 HMR 更新实际上是如何工作的。

HMR 更新

HMR传播期间找到每个HMR边界通常对应一个HMR更新

vite中,更新采用这种签名

interface Update {
  // 更新的类型
  type: 'js-update' | 'css-update'
  // 接受模块(HMR 边界根)的 URL 路径
  path: string
  // 被接受的 URL 路径(通常与上面的路径相同)
  acceptedPath: string
  // 更新发生的时间戳
  timestamp: number
}

Vite 中,它被区分为 JS 更新CSS 更新,其中 CSS 更新被特别处理为在更新时简单地交换 HTML 中的链接标签。

对于 JS 更新,我们需要找到相应的模块,以调用其 import.meta.hot.accept() 回调,以便它可以对自身应用 HMR。由于在 createHotContext() 中我们已经将路径注册为第一个参数,因此我们可以通过更新的路径轻松找到匹配的模块。有了更新的时间戳,我们还可以获取模块的新版本以传递给 import.meta.hot.accept()

/@vite/client

// 由 `createHotContext()` 填充的映射
const ownerPathToAcceptCallbacks = new Map<string, Function[]>()

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path)
  const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)

  for (const cb of acceptCbs) {
    cb(newModule)
  }
}

之前我们就介绍过,import.meta.hot.accept() 有两个函数签名

  1. import.meta.hot.accept(cb: Function)
  2. import.meta.hot.accept(deps: string | string[], cb: Function)

上面的实现对于第一个函数签名(自接受模块)的情况处理良好,但对于第二个函数签名则不适用。第二个函数签名的回调函数只有在依赖项发生更改时才需要被调用。为了解决这个问题,我们可以将每个回调函数绑定到一组依赖项。

app.jsx
import { add } from './utils.js'
import { value } from './stuff.js'

if (import.meta.hot) {
  import.meta.hot.accept(...)
  // { deps: ['/src/app.jsx'], fn: ... }

  import.meta.hot.accept('./utils.js', ...)
  // { deps: ['/src/utils.js'], fn: ... }

  import.meta.hot.accept(['./stuff.js'], ...)
  // { deps: ['/src/stuff.js'], fn: ... }
}

然后,我们可以使用 acceptedPath 来匹配依赖关系并触发正确的回调函数。

例如,如果更新了 stuff.js,那么 acceptedPath 将是 /src/stuff.js,而 path 将是 /src/app.jsx。这样,我们可以通知拥有者路径接受路径(acceptedPath)已经更新,而拥有者可以处理其更改。我们可以调整 HMR 处理程序如下:

/@vite/client

// 由 `createHotContext()` 填充的映射
const ownerPathToAcceptCallbacks = new Map<
  string,
  { deps: string[]; fn: Function }[]
>()

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path)
  const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)

  for (const cb of acceptCbs) {
    // 确保只执行可以处理 `acceptedPath` 的回调函数
    if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
      cb.fn(newModule)
    }
  }
}

但我们还没有完成!在导入新模块之前,我们还需要确保正确处理旧模块,使用 import.meta.hot.dispose()

/@vite/client

// 由 `createHotContext()` 填充的映射
const ownerPathToAcceptCallbacks = new Map<
  string,
  { deps: string[]; fn: Function }[]
>()
const ownerPathToDisposeCallback = new Map<string, Function>()

async function handleUpdate(update: Update) {
  const acceptCbs = ownerPathToAcceptCallbacks.get(update.path)

  // 如果有的话调用 dispose 回调
  ownerPathToDisposeCallbacks.get(update.path)?.()

  const newModule = await import(`${update.acceptedPath}?t=${update.timestamp}`)

  for (const cb of acceptCbs) {
    // 确保只执行可以处理 `acceptedPath` 的回调函数
    if (cb.deps.some((deps) => deps.includes(update.acceptedPath))) {
      cb.fn(newModule)
    }
  }
}

上面的代码基本上实现了大部分的 HMR 客户端

HMR 修剪

我们之前聊过,在 导入分析 阶段,Vite 内部处理了 HMR 修剪。当一个模块不再被任何其他模块导入时,Vite 开发服务器将向 HMR 客户端发送一个 { type: 'prune', paths: string[] } 载荷,其中它将独立地在运行时修剪模块。

/@vite/client
// 由 `createHotContext()` 填充的映射
const ownerPathToDisposeCallback = new Map<string, Function>()
const ownerPathToPruneCallback = new Map<string, Function>()

function handlePrune(paths: string[]) {
  for (const p of paths) {
    ownerPathToDisposeCallbacks.get(p)?.()
    ownerPathToPruneCallback.get(p)?.()
  }
}

HMR 作废

与其他 HMR API 不同,import.meta.hot.invalidate() 是可以在 import.meta.hot.accept() 中调用的动作,以退出 HMR。在 /@vite/client 中,只需发送一个 WebSocket 消息到 Vite 开发服务器

// `ownerPath` 来自于 `createHotContext()`
function handleInvalidate(ownerPath: string) {
  ws.send(
    JSON.stringify({
      type: 'custom',
      event: 'vite:invalidate',
      data: { path: ownerPath }
    })
  )
}

当 Vite 服务器接收到此消息时,它将从其导入者再次执行 HMR 传播,结果(完整重新加载或 HMR 更新)将发送回 HMR 客户端

HMR 事件

虽然不是 HMR 必需的,但 HMR 客户端还可以在运行时发出事件,当收到特定信息时。import.meta.hot.on 和 import.meta.hot.off 可以用于监听和取消监听这些事件。

if (import.meta.hot) {
  import.meta.hot.on('vite:invalidate', () => {
    // ...
  })
}

发出和跟踪这些事件与上面处理 HMR 回调的方式非常相似。

/@vite/client(URL)

+ const eventNameToCallbacks = new Map<string, Set<Function>>()

// `ownerPath` 来自于 `createHotContext()`
function handleInvalidate(ownerPath: string) {
+  eventNameToCallbacks.get('vite:invalidate')?.forEach((cb) => cb())
  ws.send(
    JSON.stringify({
      type: 'custom',
      event: 'vite:invalidate',
      data: { path: ownerPath }
    })
  )
}

HMR 数据

最后,HMR 客户端还提供了一种存储数据以在 HMR API 之间共享的方法,即 import.meta.hot.data。这些数据也可以传递给 import.meta.hot.dispose()import.meta.hot.prune() 的 HMR 回调函数。

保留数据也与我们跟踪 HMR 回调的方式类似。

HMR 修剪代码为例:

/vite/client

// 由 `createHotContext()` 填充的映射
const ownerPathToDisposeCallback = new Map<string, Function>()
const ownerPathToPruneCallback = new Map<string, Function>()
+ const ownerPathToData = new Map<string, Record<string, any>>()

function handlePrune(paths: string[]) {
  for (const p of paths) {
+    const data = ownerPathToData.get(p)
+    ownerPathToDisposeCallbacks.get(p)?.(data)
+    ownerPathToPruneCallback.get(p)?.(data)
  }
}

source article

article