Skip to content
本页目录

Vite的HMR

什么是HMR

HMR的全称叫做Hot Module Replacement,称为模块热替换或者模块热更新

在计算机领域当中也有一个类似的概念叫热插拔,我们经常使用的 USB 设备就是一个典型的代表,当我们插入 U 盘的时候,系统驱动会加载在新增的 U 盘内容,不会重启系统,也不会修改系统其它模块的内容。HMR 的作用其实一样,就是在页面模块更新的时候,直接把页面中发生变化的模块替换为新的模块,同时不会影响其它模块的正常运作

我们在日常开发中,修改一个文件,如何看到更新之后的页面效果呢?很久之前,我们是通过live reload也就是自动刷新页面的方式来解决的。不过随着前端工程的日益庞大,开发场景也越来越复杂,这种live reload的方式在诸多的场景下却显得十分鸡肋,简单来说就是模块局部更新+状态保存的需求在live reload的方案没有得到满足,从而导致开发体验欠佳。当然,针对部分场景也有一些临时的解决方案,比如状态存储到浏览器的本地缓存(localStorage 对象)中,或者直接 mock 一些数据。但这些方式未免过于粗糙,无法满足通用的开发场景,且实现上也不够优雅。

那么,如果在改动代码后,想要进行模块级别的局部更新该怎么做呢?业界一般使用HMR技术来解决这个问题,像 WebpackParcel 这些传统的打包工具底层都实现了一套 HMR API,而我们今天要讲的就是 Vite 自己所实现的 HMR API。相比于传统的打包工具,ViteHMR API 基于 ESM 模块规范来实现,可以达到毫秒级别的更新速度,性能非常强悍。

接下来,我们就一起来谈谈,Vite 中这一套 HMR 相关的 API 是如何设计的,以及我们可以通过这些 API 实现哪些功能。

深入HMR API

Vite 作为一个完整的构建工具,本身实现了一套 HMR 系统,值得注意的是,这套 HMR 系统基于原生的 ESM 模块规范来实现,在文件发生改变时 Vite 会侦测到相应 ES 模块的变化,从而触发相应的 API,实现局部的更新。

Vite 的 HMR API 设计也并非空穴来风,它基于一套完整的 ESM HMR 规范来实现,这个规范由同时期的 no-bundle 构建工具 Snowpack、WMR 与 Vite 一起制定,是一个比较通用的规范。

我们可以直观地来看一看 HMR API 的类型定义:

js
interface ImportMeta {
  readonly hot?: {
    readonly data: any
    accept(): void
    accept(cb: (mod: any) => void): void
    accept(dep: string, cb: (mod: any) => void): void
    accept(deps: string[], cb: (mods: any[]) => void): void
    prune(cb: () => void): void
    dispose(cb: (data: any) => void): void
    decline(): void
    invalidate(): void
    on(event: string, cb: (...args: any[]) => void): void
  }
}

这里稍微解释一下,import.meta对象为现代浏览器原生的一个内置对象,Vite 所做的事情就是在这个对象上的 hot 属性中定义了一套完整的属性和方法。因此,在 Vite 当中,你就可以通过import.meta.hot来访问关于 HMR 的这些属性和方法,比如import.meta.hot.accept()。接下来,我们就来一一熟悉这些 API 的使用方式。

模块更新时逻辑: hot.accept

import.meta.hot 对象上有一个非常关键的方法accept,因为它决定了 Vite 进行热更新的边界,那么如何来理解这个accept的含义呢?

从字面上来看,它表示接受的意思。没错,它就是用来接受模块更新的。 一旦 Vite 接受了这个更新,当前模块就会被认为是 HMR 的边界。那么,Vite 接受谁的更新呢?这里会有三种情况:

  • 接受自身模块的更新
  • 接受某个子模块的更新
  • 接受多个子模块的更新

这三种情况分别对应 accept 方法三种不同的使用方式,下面我们就一起来分析一下。

1. 接受自身更新

当模块接受自身的更新时,则当前模块会被认为 HMR 的边界。也就是说,除了当前模块,其他的模块均未受到任何影响。再通俗点说就是当前模块的修改只影响它本身,不会影响任何其他模块。
举个例子,我们有一个项目,目录结构如下

├── favicon.svg
├── index.html
├── node_modules
│   └── ...
├── package.json
├── src
│   ├── main.ts
│   ├── render.ts
│   ├── state.ts
│   ├── style.css
│   └── vite-env.d.ts
└── tsconfig.json

这里我放出一些关键文件的内容,如下面的 index.html

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <p>
      count: <span id="count">0</span>
    </p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

里面的 DOM 结构比较简单,同时引入了 /src/main.ts 这个文件,内容如下:

js
import { render } from './render';
import { initState } from './state';
render();
initState();

文件依赖了render.tsstate.ts,前者负责渲染文本内容,而后者负责记录当前的页面状态:

js
// src/render.ts
// 负责渲染文本内容
import './style.css'
export const render = () => {
  const app = document.querySelector<HTMLDivElement>('#app')!
  app.innerHTML = `
    <h1>Hello Vite!</h1>
    <p target="_blank">This is hmr test.123</p>
  `
}
// src/state.ts
// 负责记录当前的页面状态
export function initState() {
  let count = 0;
  setInterval(() => {
    let countEle = document.getElementById('count');
    countEle!.innerText =  ++count + '';
  }, 1000);
}

好了,仓库当中关键的代码就目前这些了。现在,你可以执行pnpm i安装依赖,然后npm run dev启动项目,在浏览器访问可以看到这样的内容:
vite-hmr-1

同时,每隔一秒钟,你可以看到这里的count值会加一。OK,现在你可以试着改动一下render.ts的渲染内容,比如增加一些文本:

ts
// render.ts
export const render = () => {
  const app = document.querySelector<HTMLDivElement>('#app')!
  app.innerHTML = `
    <h1>Hello Vite!</h1>
    <p target="_blank">This is hmr test.123 这是增加的文本</p> 
  `
}

效果如下所示: vite-hmr-2

页面的渲染内容是更新了,但不知道你有没有注意到最下面的count值瞬间被置零了,并且查看控制台,也有这样的 log:

[vite] page reload src/render.ts

很明显,当 render.ts 模块发生变更时,Vite 发现并没有 HMR 相关的处理,然后直接刷新页面了。 在render.ts中加上如下的代码:

js
// 条件守卫
if (import.meta.hot) { 
import.meta.hot.accept((mod) => mod.render()) 
}

import.meta.hot对象只有在开发阶段才会被注入到全局,生产环境是访问不到的,另外增加条件守卫之后,打包时识别到 if 条件不成立,会自动把这部分代码从打包产物中移除,来优化资源体积。因此,我们需要增加这个条件守卫语句。

接下来,可以注意到我们对于 import.meta.hot.accept的使用:

js
import.meta.hot.accept((mod) => mod.render())

这里我们传入了一个回调函数作为参数,入参即为 Vite 给我们提供的更新后的模块内容,在浏览器中打印mod内容如下,正好是render模块最新的内容:

vite-hmr-3

我们在回调中调用了一下 mod.render 方法,也就是当模块变动后,每次都重新渲染一遍内容。这时你可以试着改动一下渲染的内容,然后到浏览器中注意一下count的情况,并没有被重新置零,而是保留了原有的状态: vite-hmr-4

没错,现在 render 模块更新后,只会重新渲染这个模块的内容,而对于 state 模块的内容并没有影响,并且控制台的 log 也发生了变化:

[vite] hmr update /src/render.ts

现在我们算是实现了初步的 HMR,也在实际的代码中体会到了 accept 方法的用途。当然,在这个例子中我们传入了一个回调函数来手动调用 render 逻辑,但事实上你也可以什么参数都不传,这样 Vite 只会把 render模块的最新内容执行一遍,但 render 模块内部只声明了一个函数,因此直接调用import.meta.hot.accept()并不会重新渲染页面。

2.接受依赖模块的更新

上面介绍了接受自身模块更新的情况,现在来分析一下接受依赖模块更新是如何做到的。
还是拿示例项目来举例,main模块依赖render 模块,也就是说,main模块是render父模块,那么我们也可以在 main 模块中接受render模块的更新,此时 HMR 边界就是main模块了。 我们将 render模块的 accept 相关代码先删除:

ts
// render.ts
if (import.meta.hot) { 
  import.meta.hot.accept((mod) => mod.render()) 
} 

然后再main模块增加如下代码:

ts
// main.ts
import { render } from './render';
import './state';
render();
if (import.meta.hot) { 
  import.meta.hot.accept('./render.ts', (newModule) => {
    newModule.render();
  })
}

在这里我们同样是调用 accept 方法,与之前不同的是,第一个参数传入一个依赖的路径,也就是render模块的路径,这就相当于告诉 Vite: 我监听了 render 模块的更新,当它的内容更新的时候,请把最新的内容传给我。同样的,第二个参数中定义了模块变化后的回调函数,这里拿到了 render 模块最新的内容,然后执行其中的渲染逻辑,让页面展示最新的内容。

通过接受一个依赖模块的更新,我们同样又实现了 HMR 功能,你可以试着改动 render模块的内容,可以发现页面内容正常更新,并且状态依然保持着原样。

3.接受多个子模块的更新

接下来是最后一种 accept 的情况——接受多个子模块的更新。有了上面两种情况的铺垫,这里再来理解第三种情况就容易多了。 父模块可以接受多个子模块的更新,当其中任何一个子模块更新之后,父模块会成为 HMR 边界。还是拿之前的例子来演示,现在我们更改main模块代码:

ts
// main.ts
import { render } from './render';
import { initState } from './state';
render();
initState();
if (import.meta.hot) { 
  import.meta.hot.accept(['./render.ts', './state.ts'], (modules) => {
    console.log(modules);
  })
}

在代码中我们通过 accept 方法接受了renderstate两个模块的更新,接着让我们手动改动一下某一个模块的代码,观察一下回调中modules的打印内容。例如当我改动 state模块的内容时,回调中拿到的 modules 是这样的:

vite-hmr-5

可以看到 Vite 给我们的回调传来的参数modules其实是一个数组,和我们第一个参数声明的子模块数组一一对应。因此modules数组第一个元素是 undefined,表示render模块并没有发生变化,第二个元素为一个 Module 对象,也就是经过变动后state模块的最新内容。于是在这里,我们根据 modules 进行自定义的更新,修改 main.ts:

js
// main.ts
import { render } from './render';
import { initState } from './state';
render();
initState();
if (import.meta.hot) {
  import.meta.hot.accept(['./render.ts', './state.ts'], (modules) => {
    // 自定义更新
    const [renderModule, stateModule] = modules;
    if (renderModule) {
      renderModule.render();
    }
    if (stateModule) {
      stateModule.initState();
    }
  })
}

现在,你可以改动两个模块的内容,可以发现,页面的相应模块会更新,并且对其它的模块没有影响。但实际上你会发现另外一个问题,当改动了state模块的内容之后,页面的内容会变得错乱: 定时器的数字增加会变得没有规律

这是为什么呢?

因为我们在state中设置了一个定时器,但当模块更改之后,这个定时器并没有被销毁,紧接着我们在 accept 方法调用 initState 方法又创建了一个新的定时器,导致 count 的值错乱。那如何来解决这个问题呢?这就涉及到新的 HMR 方法——dispose方法了。

模块销毁时逻辑: hot.dispose

这个方法相较而言就好理解多了,代表在模块更新、旧模块需要销毁时需要做的一些事情,拿刚刚的场景来说,我们可以通过在state模块中调用 dispose 方法来轻松解决定时器共存的问题,代码改动如下:

js
// state.ts
let timer: number | undefined;
if (import.meta.hot) {
  import.meta.hot.dispose(() => {
    if (timer) {
      clearInterval(timer);
    }
  })
}
export function initState() {
  let count = 0;
  timer = setInterval(() => {
    let countEle = document.getElementById('count');
    countEle!.innerText =  ++count + '';
  }, 1000);
}

此时,我们再来到浏览器观察一下 HMR 的效果,发现当我稍稍改动一下state模块的内容(比如加个空格),页面确实会更新,而且也没有状态错乱的问题,说明我们在模块销毁前清除定时器的操作是生效的。但你又可以很明显地看到一个新的问题: 原来的状态丢失了,count的内容从64突然变成1。这又是为什么呢? 让我们来重新梳理一遍热更新的逻辑: vite-hmr-6 当我们改动了state模块的代码,main模块接受更新,执行 accept 方法中的回调,接着会执行 state 模块的initState方法。注意了,此时新建的 initState 方法的确会初始化定时器,但同时也会初始化 count 变量,也就是count0 开始计数了!

这显然是不符合预期的,我们期望的是每次改动state模块,之前的状态都保存下来。怎么来实现呢?

共享数据: hot.data 属性

这就不得不提到 hot 对象上的 data 属性了,这个属性用来在不同的模块实例间共享一些数据。使用上也非常简单,让我们来重构一下 state 模块:

ts
let timer: number | undefined;
if (import.meta.hot) {
  // 初始化 count 
  if (!import.meta.hot.data.count) {
    import.meta.hot.data.count = 0;
  }
  import.meta.hot.dispose(() => {
    if (timer) {
      clearInterval(timer);
    }
  })
}
export function initState() {
  const getAndIncCount = () => {
    const data = import.meta.hot?.data || {
      count: 0
    };
    data.count = data.count + 1;
    return data.count;
  };
  timer = setInterval(() => {
    let countEle = document.getElementById('count');
    countEle!.innerText =  getAndIncCount() + '';
  }, 1000);
}

我们在 import.meta.hot.data 对象上挂载了一个count 属性,在二次执行initState的时候便会复用 import.meta.hot.data 上记录的 count 值,从而实现状态的保存,此时,我们终于大功告成。

其它方法

1. import.meta.hot.decline()

这个方法调用之后,相当于表示此模块不可热更新,当模块更新时会强制进行页面刷新。

2. import.meta.hot.invalidate()

这个方法就更简单了,只是用来强制刷新页面。

2. 自定义事件

我们还可以通过 import.meta.hot.on 来监听 HMR 的自定义事件,内部有这么几个事件会自动触发:

  • vite:beforeUpdate 当模块更新时触发;
  • vite:beforeFullReload 当即将重新刷新页面时触发;
  • vite:beforePrune 当不再需要的模块即将被剔除时触发;
  • vite:error 当发生错误时(例如,语法错误)触发。

如果你想自定义事件可以通过上节中提到的 handleHotUpdate 这个插件 Hook 来进行触发:

js
// 插件 Hook
handleHotUpdate({ server }) {
  server.ws.send({
    type: 'custom',
    event: 'custom-update',
    data: {}
  })
  return []
}
// 前端代码
import.meta.hot.on('custom-update', (data) => {
  // 自定义更新逻辑
})

Released under the MIT License.