前言
在上一篇文章中,我们主要的任务是向大家阐述清楚热更新的整体流程,当大家对其有了一定的认知之后,就像一个人掉进池塘里面,挣扎了两下发现自己能站的起来,心里面瞬间就不慌了,当心里面不慌了之后,接下来就是要从池塘里面爬起来,因此从这篇文章开始,我们需要对热更新的细节知识进行突破了。
本文会介绍一个您之前可能从来没有听说过的一个全新概念->热更新边界
(我也是学习了源码才明白了VITE的设计思想)。
在这篇文章中,我们还会向大家阐述VITE提供的几个热更新的API有什么用途,并给出一些实际的例子。
除此之外,从这篇文章开始,我们将会围绕着热更新中的核心类,以及之前在阐述VITE核心类的篇章尚未阐述完的核心类EnvironmentModuleGraph
。
最后,我们还要一起探究vite:import-analysis
内置中的一些处理逻辑,如果本文的篇幅有限的话,就在下一篇文章中探讨它。
好了,废话少说,开始进入正题吧。
热更新边界
在本文的开头,我们先一起来学习一个概念热更新边界
。
我们先一起来看一下VITE官方文档的解释: 劝退提示:如果您对上面的代码还感到非常陌生的话,本文就没有必要往下看了,哈哈哈。
我们现在思考一个问题,为什么我们平时写业务代码的时候基本上用不到VITE的热更新API?
我们先思考一个依赖链:App.vue->repository.js->request.js->config.js
,如果repository.js->request.js->config.js
这个依赖子链有任何文件发生了变更,App.vue是不是都应该重新渲染,因为App.vue是最终的视图呈现,而依赖子链关系到视图的数据来源。也就是说这个依赖链中的任何一个部分发生变化,App.vue都应该重新渲染,所以从我们前端开发的经验来看,热更新的边界是在最终的视图渲染处。
有的同学可能会说,我没有在App.vue
里面有写import.meta.hot.accpet
呀,这个是@vitejs/plugin-vue
自动为我们在vue的主文件上注入的: 既然这个更新边界只需要在最终的视图渲染处,我们肯定就没必要自己在非vue文件上划分新的边界了,如果我们划分了新的更新边界,那么热更新最终的更新结果就不会体现在视图呈现的处,反而还出问题了。
在vue项目里,依赖链的出口肯定会有一个更新边界,如果现在不是vue项目,某个依赖链上没有部署一个import.meta.hot.accept
,那么,我们更改这个依赖链上的某个文件将会导致浏览器整页刷新。
给大家画一个图,加深理解:
场景1: 场景2:
场景3:
如果我们把文件的依赖关系看做一棵树的话,VITE在热更新时,如果某个文件的变更引起一系列的文件需要更新时,VITE只会找到这些文件依赖链里面源码包含import.meta.hot.accept
最深的文件节点。
上面的图,各位读者还需要注意一点,拿图一举例,当我们修改了request.js的源码时,VITE只会告知我们热更新App.vue,并不是先更新request.js,再更新repository.js,最后更新App.vue,因为App.vue中会引用这些文件,当它被更新了,依赖的文件也就一并更新了。
HMRContext
这是在热更新中非常重要的一个类,import.meta.hot
上面的所有API就是来源于这个类,现在我们就来看一下它的具体实现。
构造器里面传入一个HMRClient
类的实例,并且需要一个当前模块的路径: 提供了
accept
方法,用途是当热更新完成的时候,用户可以执行自定义的回调函数。 提供了
dispose
方法,用途是在热更新开始之前,用户可以执行自定义的回调函数清除副作用。 提供了
prune
方法,用途是在热更新时,当这个模块不再被别的模块引用的时候,用户可以执行自定义的回调函数清除副作用 。 提供了
invalidate
方法,这个方法的说明写的比较晦涩难懂,我通过询问AI,通过总结AI的回答以及我个人的实践,它的目的就是刷新依赖当前热更新链的上一个热更新链,如果没有上一个热更新链,则整页刷新。 给大家举个例子可能就明白了: 假设依赖链是
main.js->api.js->config.js
js
// config.js
export const API = '/api/v1'
// 以下这段代码告知VITE,我自己处理当前文件的热更新,即划分热更新边界
if(import.meta.hot) {
import.meta.hot.accept(() => {
import.meta.hot.invalidate();
})
}
// =================================
// api.js
import { API } from './config.js'
export function getAppConfig() {
console.log(API)
}
// =================================
// main.js
import { getAppConfig } from './api.js'
getAppConfig();
if(import.meta.hot) {
// 必须部署这个方法,否则无法出现这样的效果,即再次划分热更新边界
import.meta.hot.accept(() => {
//
})
}
提供了on
和off
方法,用途是监听和移除监听自定义HMR事件。 最后是
send
方法,用途是可以向DevServer发送自定义事件。 我们看一下这个私有方法
acceptDeps
,它的目的是把对应的依赖和Callback加入到一个Map结构里面去,当热更新的时候,调用Callback,告知最新的Module
资源,之前传入的ownerPath被用作了Key,届时热更新处理的时候通过这个ownerPath去查找对应的模块,处理回调函数。
HMRClient
HMRClient
这个类主要的功能就是完成热更新的资源重新导入,触发用户手动部署的热更新的回调函数。
它的回调函数有两个重要的内容,一个是用于通信的WS上下文的实例,一个是获取最新资源的方法。 我们可以看一下这个类初始化的时候是如何调用的。
可以看到,transport这个变量就是在上一篇文章我们聊过的WS的上下文实例。然后是这个
importUpdateModule
函数,它就是一个使用import
函数加载资源的函数,返回值是一个Promise
然后,再看它其它的有用且值得我们的关注方法。
fetchUpdate
方法: 这个方法的调用时传入的参数就是WS发送过来的数据。 在获取新模块之前,如果满足某些条件并且用户有定义dispose函数,执行dispose函数清除副作用。
然后获取到新的模块,返回一个新的函数,在这个新函数里面通知更新的模块列表。
以下是VITE官网的例子:
当accept函数的第一个参数是依赖模块的数组时,很明显我们从源码看到
fetchedModule
明显一次只能加载一个模块,那么,accept的第二个函数就不可能两个模块都同时存在(这是我个人的一些理解,如果有误,欢迎大家刊误)。
那么,这个demo写成这样,可能会好一些:
js
import { foo } from './foo.js'
import { bar } from './bar.js'
foo();
bar();
if(import.meta.hot) {
import.meta.hot.accept(['./foo.js', './bar.js'], ([newFooModule, newBarModule]) => {
if(newFooModule) {
// 处理一些业务逻辑
}
if(newBarModule) {
// 处理一些业务逻辑
}
})
}
queueUpdate
方法,以队列的形式同时处理多个到达的待更新模块。
prunePaths
方法,如果某个模块在源码中删除了,会调用用户部署的import.meta.hot.dispose
方法和import.meta.hot.prune
方法
import.meta.hot.prune
只会在当前模块之前有被引用,现在不再被引用会执行,而import.meta.hot.dispose
方法会有条件的在每次模块更新之前都会执行。
关于这个import.meta.hot.dispose
回调函数的执行时间,我们之前一直的提法都是有条件的执行,至于这个有条件是什么条件?
如果我们在当前源码文件里面部署了import.meta.hot.accept
,当前的这个文件就是一个更新边界,首先,我们先假设这个文件什么文件都不依赖,那么,它自己在进行热更新之前,老的模块就不需要了,是不是就要执行dispose
回调了,这个场景很好理解。
如果我们当前的源码文件里面部署了import.meta.hot.accept
,并且在accept方法里面申明了依赖,那么,当这些被申明的依赖文件发生变化的时候,这些被声明的模块中如果部署了dispose方法,它们的dispose方法也将会一并执行。
部分热更新API的使用方法
这些API均来源于之前我们讲过的HMRContext
类中的方法。
hot.accept
划分依赖链的热更新边界,如果当前文件不是最终的结果呈现文件,一般不需要部署,在vue项目中已经由@vitejs/plugin-vue
定义。
用法1:
js
if(import.meta.hot) {
import.meta.hot.accept()
}
用法2:
js
if(import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 处理一些逻辑
})
}
用法3:
js
if(import.meta.hot) {
import.meta.hot.accept('./dep.js', (newModule) => {
// 处理一些逻辑
})
}
用法4:
js
if(import.meta.hot) {
import.meta.hot.accept(['./dep1.js', './dep2.js'], ([newModule1, newModule2]) => {
// 在之前我们讲过,因为fetchUpdate函数一次只能加载一个,所以多个Module不会同时更新
if(newModule1) {
// 处理一些逻辑
}
if(newModule) {
// 处理一些逻辑
}
})
}
hot.dispose
有副作用的时候,在当前模块要销毁的时候触发
js
if(import.meta.hot) {
import.meta.hot.dispose(() => {
// 处理一些业务逻辑
})
}
hot.prune
在当前模块被移除引用的时候触发。
js
if(import.meta.hot) {
import.meta.hot.prune(() => {
// 处理一些业务逻辑
})
}
这个方法,目前在VITE处理CSS的过程中有用到。
在之前我们讲VITE向客户端注入工具类函数的时候已经提到过,其中就包含更新css和移除css的处理函数。
hot.invalidate
强制刷新当前引用当前依赖链的上一个更新链,若没有上一个更新链,则刷新页面,这个我没有按照VITE的官方文档来解释,我以我自己做实验得出来的结论向大家解释,如果有误,欢迎大家纠正。
App.js->repository.js->request.js->config.js
,假设在这样一个热更新链上,假设现在我们在App.js上有定义import.meta.hot.accept
热更新代码:
js
// App.js
if(import.meta.hot) {
import.meta.hot.accept(() => {
import.meta.hot.invalidate();
})
}
当我们修改App.js->repository.js->request.js->config.js
这个依赖链中任何一个文件的时候,浏览器将会刷新。
现在,我们假设在request.js
增加同样的代码,并且把App.js刚才定义的热更新代码去掉。
js
// App.js
// =================
// 其它业务代码
// =================
//request.js
if(import.meta.hot) {
import.meta.hot.accept(() => {
import.meta.hot.invalidate();
})
}
此刻,我们修改request.js->config.js
,则会导致App.js和repository.js也会一并被热更新。
总结
本文把上一篇文章没有阐述完的关于热更新中核心类阐述完毕,并且向大家介绍了VITE热更新API的规格以及使用方法。
在本文中,我根据自己的理解向大家介绍了一个热更新中的一个新概念->热更新边界
,理解了这个新概念之后可以帮助大家在处理自己业务基建。
本文中的内容绝大部分来源于我自己的实践与理解,如果存在错误,还请大家指正。