热更新的英文全称为Hot Module Replacement
,简写为 HMR。当修改代码时,HMR 能够在不刷新页面的情况下,把页面中发生变化的模块,替换成新的模块,同时不影响其他模块的正常运作
本文讲的会讲述热更新的每个流程,主要的作用是什么,还有这些流程是怎么串起来的,目的是帮助大家对热更新的流程有个基本的了解。
由于篇幅原因,本文不会非常深入的每个流程的细节。
本文的用到的代码放在 GitHub,里边有两个项目,一个是纯 ts 的热更新项目,一个是普通的 vue 项目
热更新流程
在介绍热更新的主要流程前,我们先来看看这个问题
把一头大象装进冰箱,需要几步?
这个问题相信大家都非常的熟悉,只需要三步:
- 打开冰箱门
- 把大象装进冰箱
- 把冰箱门关起来
这个问题本身不是考验人的的逻辑能力,而是考验抽象解决方案关键步骤的能力
热更新的流程非常大,且很复杂,我们要把复杂问题简单化,只关注核心的流程,将次要的问题抽象化,从而对整个热更新的过程有所理解
在这个问题中,核心流程就是这三个步骤,然后我们可以进一步细化我们需要关注的步骤,其他步骤可以暂且忽略
既然只关心核心的流程,那么你觉得,热更新的有哪些核心流程?
从修改代码,到界面更新,这个过程发生了什么?
这是我在给小伙伴分享时,他们提出的:
- 修改代码
- 重新编译(怎么编译,编译产物是什么,先不管)
- 告诉前端要热更新了(怎么告诉,先不管)
- 前端执行热更新代码进行热更新(怎么更新,先不管)
实际上,也就是这么几个过程
下面是我画的热更新的主要流程的时序图,大家一开始可能是看不懂的,这不重要,后面会逐一细讲,只要大概清晰各个部分的时序关系即可
vite server:指 vite 在开发时启动的 server
vite client:vite dev server 会在 index.html
中,注入路径为 @vite/client
的脚本,这个脚本是运行在浏览器的
暂时先记住这个核心流程:
- 修改代码,vite server 监听到代码被修改
- vite 计算出热更新的边界(即受到影响,需要进行更新的模块)
- vite server 通过 websocket 告诉 vite client 需要进行热更新
- 浏览器拉取修改后的模块
- 执行热更新的代码
我们先从离我们最近的浏览器端,开始介绍
热更新 API 简介
该小节主要讲这两部分:
这里主要涉及到两个 API:
这两个 API 定义了拉取到新的代码之后,如何进行老代码的退出,和新代码的更新
我们先来看看,没有使用热更新 API 的代码被修改时,会发生什么?
不使用热更新 API
该小节对应的项目代码在 /package/ts-file-test,对应的文件为 no-hrm.ts
下图主要是一个 ts 文件,直接获取到一个 DOM,并替换其 innerHTML
我们可以看到,该文件没有定义热更新,当文件被修改时,整个页面都重新刷新了。因为 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 函数的对象
当模块被修改时,重新执行 render 函数,设置 innerHTML 更新界面。
这时候我们定义了如何进行热更新,vite 就不会刷新页面了(刷新页面会清空所有请求,而下图没有清空请求)
dispose 类似 hot,只是 dispose 定义的是老模块如何退出,而 hot 定义的是新模块如何更新
什么时候老模块需要退出?
假如你的页面有个定时器,就要在老模块退出时,将定时器清除,否则每次修改,页面会新增一个定时器,页面上的定时器会越来越多,造成内存泄露
dispose 主要用来做一些模块的退出工作
写热更新代码非常麻烦,应该没有人会在业务中写?
热更新代码的确很麻烦,业务中基本上也不会有人写,但我们在写 vue 代码时,确实有热更新的。
那是因为, vite 的 vite-plugin
插件,在编译模块时加入了 vue 热更新的代码。
vite 本身只提供热更新 API,不提供具体的热更新逻辑,具体的热更新行为,由 vue、react 这些框架提供
热更新边界
该小节主要讲这一部分
什么是热更新边界?作用是什么?
假设有两个文件,关系如下
从上一小节,我们可以知道,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 时编译产出的模块就是两个不同的模块
因为,一个文件,是对应多个模块的。这些模块都需要找到他们的热更新边界,并进行热更新
浏览器接收热更新信号
该小节主要讲这一部分
websocket 是什么创建的?
vite dev server 会在 index.html
中,注入路径为 @vite/client
的脚本,当访问 index.html
时,就会拉取该脚本
client.ts 在加载时,会创建 websocket 并监听 message 事件
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 之前的通信
server 模块转换
该小节主要讲这一部分
模块代码转换 vite 的核心,这部分足以开一个大的主题去讲,同样的,本文只会介绍个大概,只需要知道 vite 会转换代码即可,转换细节暂时可以不关注,把 vite server 当做一个黑箱
之前说的到,vite 的 plugin-vue
插件,将热更新代码注入到模块中,就是在编译转换模块的过程中处理
从图中可以看出,index.vue
经过编译后,内容是 js 代码,其中还能看到 import.meta.hot.accept
定义热更新的回调
时序图中,有个循环条件,直到动态 import 的模块没有模块依赖,是什么意思?
假如有以下两个文件:
index.vue - useData.ts
index.vue
依赖(import)了 useData.ts
当修改 useData.ts
时,会执行以下的步骤:
- vite 沿着依赖树,往上找到
index.vue
,作为热更新边界 - server 将热更新边界信息,通过 websocket 传递到 client
- client 执行老的
index.vue
的import.meta.hot.dispose
回调 - client 动态
import(index.vue)
,vite 会重新编译index.vue
- 执行
index.vue
的代码(此时请求到index.vue
虽然是 vue 后缀,但是它的内容经过编译后,是 js 代码),执行过程中遇到 importuseData.ts
- 动态拉取
useData.ts
模块,vite 会重新编译useData.ts
- 执行
useData.ts
的代码 - client 执行新的
index.vue
的import.meta.hot.accept
回调
因为热更新边界的模块,可能会存在依赖,import 了其他模块,这些模块都需要 import 拉取,直到动态 import 的模块没有模块依赖