Vite 热更新的主要流程

5,568 阅读8分钟

热更新的英文全称为Hot Module Replacement,简写为 HMR。当修改代码时,HMR 能够在不刷新页面的情况下,把页面中发生变化的模块,替换成新的模块,同时不影响其他模块的正常运作

本文讲的会讲述热更新的每个流程,主要的作用是什么,还有这些流程是怎么串起来的,目的是帮助大家对热更新的流程有个基本的了解。

由于篇幅原因,本文不会非常深入的每个流程的细节。

本文的用到的代码放在 GitHub,里边有两个项目,一个是纯 ts 的热更新项目,一个是普通的 vue 项目

热更新流程

在介绍热更新的主要流程前,我们先来看看这个问题

把一头大象装进冰箱,需要几步?

这个问题相信大家都非常的熟悉,只需要三步:

  1. 打开冰箱门
  2. 把大象装进冰箱
  3. 把冰箱门关起来

这个问题本身不是考验人的的逻辑能力,而是考验抽象解决方案关键步骤的能力

热更新的流程非常大,且很复杂,我们要把复杂问题简单化,只关注核心的流程,将次要的问题抽象化,从而对整个热更新的过程有所理解

在这个问题中,核心流程就是这三个步骤,然后我们可以进一步细化我们需要关注的步骤,其他步骤可以暂且忽略

既然只关心核心的流程,那么你觉得,热更新的有哪些核心流程?

从修改代码,到界面更新,这个过程发生了什么?

这是我在给小伙伴分享时,他们提出的:

  1. 修改代码
  2. 重新编译(怎么编译,编译产物是什么,先不管)
  3. 告诉前端要热更新了(怎么告诉,先不管)
  4. 前端执行热更新代码进行热更新(怎么更新,先不管)

实际上,也就是这么几个过程

下面是我画的热更新的主要流程的时序图,大家一开始可能是看不懂的,这不重要,后面会逐一细讲,只要大概清晰各个部分的时序关系即可

image-20220508124910625

vite server:指 vite 在开发时启动的 server

vite client:vite dev server 会在 index.html 中,注入路径为 @vite/client 的脚本,这个脚本是运行在浏览器的

image-20220509204437568

暂时先记住这个核心流程:

  1. 修改代码,vite server 监听到代码被修改
  2. vite 计算出热更新的边界(即受到影响,需要进行更新的模块)
  3. vite server 通过 websocket 告诉 vite client 需要进行热更新
  4. 浏览器拉取修改后的模块
  5. 执行热更新的代码

我们先从离我们最近的浏览器端,开始介绍

热更新 API 简介

该小节主要讲这两部分:

image-20220509210519511

这里主要涉及到两个 API:

这两个 API 定义了拉取到新的代码之后,如何进行老代码的退出,和新代码的更新

我们先来看看,没有使用热更新 API 的代码被修改时,会发生什么?

不使用热更新 API

该小节对应的项目代码在 /package/ts-file-test,对应的文件为 no-hrm.ts

下图主要是一个 ts 文件,直接获取到一个 DOM,并替换其 innerHTML

img

我们可以看到,该文件没有定义热更新,当文件被修改时,整个页面都重新刷新了。因为 vite 不知道如何进行热更新,所以只能刷新页面

使用 hot.accept API

该小节对应的项目代码在 /package/ts-file-test,对应的文件为 accept.ts

import.meta.hot.accept API 用于传入一个回调函数,来定义该模块修改后,需要怎么去热更新

// src/accept.ts
export const render = () => {
  const el = document.querySelector<HTMLDivElement>('#accept')!;
  el.innerHTML = `
    <h1>Project: ts-file-test</h1>
    <h2>File: accept.ts</h2>
    <p>accept test</p>
  `;
};

if (import.meta.hot) {
  // 调用的时候,调用的是老的模块的 accept 回调
  import.meta.hot.accept((mod) => {
    // 老的模块的 accept 回调拿到的是新的模块
    console.log('mod', mod);
    console.log('mod.render', mod.render);
    mod.render();
  });
}

当我们将修改该文件时(将 <p>accept test</p> 改成 <p>accept test2</p> ),之前老的模块注册的 accept 的回调就会被执行

mod 就是修改后的模块对象,在该文件中,mod 就是一个导出了 render 函数的对象

image-20220509214914047

当模块被修改时,重新执行 render 函数,设置 innerHTML 更新界面。

这时候我们定义了如何进行热更新,vite 就不会刷新页面了(刷新页面会清空所有请求,而下图没有清空请求)

accept

dispose 类似 hot,只是 dispose 定义的是老模块如何退出,而 hot 定义的是新模块如何更新

什么时候老模块需要退出?

假如你的页面有个定时器,就要在老模块退出时,将定时器清除,否则每次修改,页面会新增一个定时器,页面上的定时器会越来越多,造成内存泄露

dispose 主要用来做一些模块的退出工作

写热更新代码非常麻烦,应该没有人会在业务中写?

热更新代码的确很麻烦,业务中基本上也不会有人写,但我们在写 vue 代码时,确实有热更新的。

那是因为, vite 的 vite-plugin 插件,在编译模块时加入了 vue 热更新的代码

vite 本身只提供热更新 API,不提供具体的热更新逻辑,具体的热更新行为,由 vue、react 这些框架提供

热更新边界

该小节主要讲这一部分

image-20220509210156587

什么是热更新边界?作用是什么?

假设有两个文件,关系如下

image-20220509225027495

从上一小节,我们可以知道,vue 自带了热更新逻辑,而我们写的 ts 文件,没有热更新逻辑

useData.ts 被修改时,这时候是会刷新页面吗?

答案是不会的。vue 组件依赖的 ts 文件被修改,可以对这个 vue 文件进行热更新,重新加载组件。如果刷新页面,那开发体验就不太好了。

这时候,index.vue 就被称为热更新边界——最近的可接受热更新的模块

沿着依赖树,往上找到最近的一个可以热更新的模块,即热更新边界,对其进行热更新即可

为什么有时候修改代码可以热更新,有时候却是刷新页面?例如在 vue 项目中修改 main.ts

修改 main.ts 时,因为往上找不到可以热更新的模块了,vite 不知道如何进行热更新,因此只能刷新页面

如果其他 ts 文件,能找到热更新边界,就可以直接进行热更新

文件跟模块不是一一对应的吗?为什么需要遍历文件对应的模块?

在 vite 中,文件跟模块不是一一对应

因为 vite 可以加入查询参数,可查看 vite 文档【更改资源被引入的方式

// 显式加载资源为一个 URL
import assetAsURL from './asset.js?url'

// 以字符串形式加载资源
import assetAsString from './shader.glsl?raw'

// 加载为 Web Worker
import Worker from './worker.js?worker'

// 在构建时 Web Worker 内联为 base64 字符串
import InlineWorker from './worker.js?worker&inline'

同一个文件,可能作为多个模块,例如 raw 时的编译产出的模块跟 worker 时编译产出的模块就是两个不同的模块

因为,一个文件,是对应多个模块的。这些模块都需要找到他们的热更新边界,并进行热更新

浏览器接收热更新信号

该小节主要讲这一部分

image-20220510194729431

websocket 是什么创建的?

vite dev server 会在 index.html 中,注入路径为 @vite/client 的脚本,当访问 index.html 时,就会拉取该脚本

client.ts 在加载时,会创建 websocket 并监听 message 事件

image-20220510195111037

handleMessage 负责处理各种信号,由于篇幅有限,我们不会展开讲细节

async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connected':
      // 连接信号
      console.log(`[vite] connected.`)
      setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
      break
    case 'update':
      // 模块更新信号
      break
    case 'custom': {
      // 自定义信号
      break
    }
    case 'full-reload':
      // 页面刷新信号
      break
    case 'prune':
      // 模块删除信号
      break
    case 'error': {
      // 错误信号
      break
    }
  }
}

我们可以通过抓包的方式,看到 vite dev server 跟 client 之前的通信

image-20220510203012725

server 模块转换

该小节主要讲这一部分

image-20220510203050804

模块代码转换 vite 的核心,这部分足以开一个大的主题去讲,同样的,本文只会介绍个大概,只需要知道 vite 会转换代码即可,转换细节暂时可以不关注,把 vite server 当做一个黑箱

之前说的到,vite 的 plugin-vue 插件,将热更新代码注入到模块中,就是在编译转换模块的过程中处理

image-20220510204346277

从图中可以看出,index.vue 经过编译后,内容是 js 代码,其中还能看到 import.meta.hot.accept 定义热更新的回调

时序图中,有个循环条件,直到动态 import 的模块没有模块依赖,是什么意思?

假如有以下两个文件:

index.vue
  - useData.ts

index.vue 依赖(import)了 useData.ts

当修改 useData.ts 时,会执行以下的步骤:

  1. vite 沿着依赖树,往上找到 index.vue,作为热更新边界
  2. server 将热更新边界信息,通过 websocket 传递到 client
  3. client 执行老的 index.vueimport.meta.hot.dispose 回调
  4. client 动态 import(index.vue),vite 会重新编译 index.vue
  5. 执行 index.vue 的代码(此时请求到 index.vue 虽然是 vue 后缀,但是它的内容经过编译后,是 js 代码),执行过程中遇到 import useData.ts

image-20220510213354073

  1. 动态拉取 useData.ts 模块,vite 会重新编译 useData.ts
  2. 执行 useData.ts 的代码
  3. client 执行新的 index.vueimport.meta.hot.accept 回调

因为热更新边界的模块,可能会存在依赖,import 了其他模块,这些模块都需要 import 拉取,直到动态 import 的模块没有模块依赖

参考资料