概念
HMR也叫热更新,HMR 最初由 Webpack 设计实现,至今已几乎成为现代工程化工具必备特性之一。在 HMR 之前,应用的加载、更新是一种页面级别的原子操作,即使只是单个代码文件发生变更都需要刷新整个页面才能最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态。而引入热更新之后如果有新的模块发生变化,编译成功会以消息的方式通知客户端,让客户端来请求对应模块的最新代码,并进行客户端的局部更新。
HMR 不适用于生产环境,应用于开发环境。此功能可以很大程度提高生产效率。
而vite同样也是实现了对应的热更新逻辑,并且热更新的速度更是达到了毫秒级别;
vite的热更新是怎么做到毫秒级别的,且热更新的速度不会随着模块增多而变慢,核心是依赖于其收集模块间依赖关系,基于这个依赖关系来实现热更新;
下面我们就一起来看下vite这部分的实现:
模块依赖关系构建
流程图
可以根据流程图先对原理有个大概的了解
相关代码如下:
_createServer
上述代码会创建ModuleGraph实例,这个是用来记录所有模块的依赖关系的;
之后会使用transfromMiddleware中间件处理请求;
transfromMiddleware
这个中间件里做的事情:
1.忽略favicon.ico的请求(不做处理)
2.处理请求的url,去除时间戳参数,decode解码;
3.处理sourceMap 源码映射文件逻辑,略过;
4.使用transformRequest 方法处理请求模块,这里会串联起所有的模块依赖关系绑定,下面可以一起来看下实现原理:
5.最后将拿到模块内容返回给客户端(如果是预构建三方依赖设置强缓存策略);
中间件的部分核心代码如下:
transformRequest
这个方法主要是用transfromRequest方法获取模块的内容返回给客户端,这里有个细节就是会复用请求 ,如果发起很多请求,请求的过程中后面的请求和前面的请求的是同一个模块后面的请求会复用前面的请求的返回;并且在此过程中在如果模块失效会清除缓存并且重新发起请求;
除此之外这块主要逻辑在request方法也就是doTransform方法中:
doTransform
该方法会判断模块是否已经缓存,已经缓存的话会判断模块是否软失效,如果是软失效就返回上次记录的transfromResult模块内容,不是软失效并且有对应的缓存就直接返回缓存的transformResult;
软失效模块:当一个模块被修改时,热更新场景下引入该模块的模块会被标记为软失效(下面也会讲到这部分逻辑),软失效模块会将之前的transfromResult结果保存;然后在浏览器加载新的模块成功后,再将这个模块标记为硬失效。当模块变成硬失效后,Vite会通过WebSocket通知浏览器,浏览器接收到这个通知后,会重新加载这个模块。这样,即使模块更新失败,用户也不会看到一个空白的页面,或者看到一个错误的页面。
如果没有缓存该模块,会解析模块并且缓存,首先会调用pluginContainer的resolveId钩子获取模块的resolvedId,然后调用loadAndTransform方法得到模块转化结果;
代码如下:
loadAndTransform
这个方法核心就两个逻辑,load和transfrom,最后拿到transfrom的结果return;
load :优先判断插件是否有对该模块的处理 ,调用插件容器load钩子有返回的话则直接用返回结果,如果插件没有返回结果 就用fsPromises读取文件内容返回;
transfrom:
在调用transfrom钩子之前如果这个模块没有实例化成对应的ModuleNode则会调用ModuleGraph的_ensureEntryFromUrl方法实例化并注册到模块依赖表中,这时候只是在模块依赖图中建立了这个节点但是还没有构建出模块节点的依赖关系;
拿到load的返回结果之后调用插件容器transfrom钩子,在内置插件集合 里有个开发环境的插件vite:import-analysis,这个插件有transfrom钩子的逻辑,这个插件的逻辑非常核心,这个插件会根据code分析出依赖关系,请求的文件模块一个个都经过这样的处理然后将ModuleGraph依赖关系图构建完成。
transfrom之后会返回模块的结果。
importAnalysis(vite:import-analysis)
vite:import-analysis插件是vite内置插件,只会在开发环境使用,插件注册的地方在packages/vite/src/node/plugins/index.ts
importAnalysisPlugin
我们一起来看下这个插件的逻辑🌈
1.利用es-module-lexer库得到该模块的imports(引入的模块)和exports(导出的模块);
2.遍历拿到的imports信息,处理import.meta.hot.accept的逻辑(判断accept API使用是否合法,记录模块的accept依赖等);
3.给模块代码注入环境变量import.meta.env 和注入热更新的上下文createHotContext(import.meta.hot) (使用magic-stringAPI处理,上下文的代码在@vite/client里,@vite/client这个路径也被内置插件alias了);
4.将得到的importedUrls(该模块依赖的模块url集合),acceptedUrls(import.meta.hot.accept 声明的依赖模块 url 集合),isSelfAccepting(是否接受自身更新)这些信息给到moduleGraph.updateModuleInfo 方法,去进行模块之间依赖关系的绑定;
moduleGraph.updateModuleInfo
这里会拿到importAnalysisPlugin 插件里提供的importedUrls (该模块依赖的模块url集合),acceptedUrls (import.meta.hot.accept 声明的依赖模块 url 集合),isSelfAccepting(是否接受自身更新)参数;
1.首先会判断依赖的模块(也就是importedUrls在该方法里importedUrls入参名是importedModules)是否都已经实例成一个ModuleNode,没有的话也是会调用ensureEntryFromUrl方法进行实例化并且在ModuleGraph中注册这些节点;
2.构建模块间的依赖关系用于hmr;(主要是将本模块引入的模块 和调用acceptAPI依赖关系的模块 加入到acceptedHmrDeps 中,将该模块加入到被引入模块的importers中,后续的热更新会根据这些信息得到热更新的边界)
acceptedHmrDeps代表当前模块所接受更新的模块;
importers代表当前模块被哪些模块引用;
到这为止,就如上面所说的,现在就串联起所有的模块依赖关系绑定,构建出了模块的依赖关系图moduleGraph;
模块热更新
vite使用chokidar监听项目根路径,监听项目内文件的创建,删除和更新;
流程图
文件修改热更新
我们先关注文件内容更新触发的热更新操作:
1.这里会触发插件容器的watchChange钩子,执行相关的插件逻辑;
2.然后调用moduleGraph.onFileChange方法,去清除文件对应模块的缓存;
onFileChange
主要是调用invalidateModule方法清除模块的缓存;
invalidateModule
a.针对软失效模块记录上次的模块返回;
b.清除该模块的缓存;
c.清除引入该模块的上层模块的缓存:遍历上层模块,如果对应上层模块没有依赖(accept)该模块的更新的话;
当子模块更新时,上层模块也需要重新加载。此时需要更新时间戳和清空缓存的代码,防止再次返回缓存的代码。
同理如果监听了子模块更新,这里就不需要更新自身了,而是可以通过执行监听的回调函数重新执行子模块导出的内容。
到这里去除模块缓存的逻辑就结束了;
onHMRUpdate
3.然后回到对监听文件的处理的最后一步onHMRUpdate ,HMR 的收集更新,onHMRUpdate方法里调用的是handleHMRUpdate方法,可以一起来看下:
handleHMRUpdate
- 这里先会判断是否是配置文件 或 配置文件的依赖项(自定义插件) 或 环境变量文件的相关修改,是的话直接重启vite服务;
- 如果是注入客户端热更新文件更新就刷新页面(vite/dist/client/client.mjs)
- 然后获取更新文件对应模块mods,构建热更新上下文给到handleHotUpdate插件钩子(vue文件的热更新逻辑就对应插件中处理),这里支持插件自定义热更新的处理;
-
updateModules
普通模块的改动则是调用updateModules 方法更新确认需要热更新的模块,这个方法会遍历需要更新模块依次查找热更新边界,然后将模块更新的信息传给客户端。一层一层查并且清楚模块缓存。
这里会调用propagateUpdate 方法收集热更新的边界,hasDeadEnd 为true的时候代表需要刷新页面,否则只需要局部更新即可,最后将收集到的boundaries 通过websocket给到客户端进行更新操作;
- propagateUpdate
a. 如果该模块接受自我更新则将自身加入boundaries(热更新列表)中,然后针对循环引用处理和对postcss插件(Tailwind JIT 可以将任何文件注册为 CSS 文件的依赖项)场景的处理;
针对 #7561 bugfix处理(这里略过) 如果这个模块是动态引入并且永远不会被引入,也就不会经过importAnalysis处理,同杨该模块也需要跳过热更新的处理,否则,HMR 信号会触发错误的整页重新加载。
b.有接受hmr导出的场景将自身加入boundaries中; 当前模块的上层模块接受当前模块的更新加入到boundaries列表中;
递归propagateUpdate处理当前模块的上层模块,确认热更新边界;
#3716 bugfix处理 当前模块不是css模块并且有css模块导入了当前模块返回true(刷新页面)
这里的逻辑可能比较难理解,在下面我们会画图说明:
最后当热更新边界的信息收集完成后,服务端将这些信息推送给客户端,从而完成局部的模块更新。
说明图: 根据标注顺序看,相信你能理解😊
文件增加&文件删除热更新
可以看到这里的操作首先是和更新文件的操作一样,先触发插件的watchChange钩子;
然后调用handleFileAddUnlink 方法,这个方法主要是将该模块从被引用模块的imports(被哪些模块所引用) 列表中删除;
然后就是调用onHMRUpadate方法进行热更新,这个方法的逻辑上面也已经讲过了,这里就不再赘述;
客户端的更新处理
客户端主要实现是在client文件,这个文件会注入到html中;
client主要就是和服务端建立websocket连接,根据接收到的不同的指令进行模块的更新或者页面的刷新;
这里贴上有关热更新的部分核心代码:
1.触发vite:beforeUpdate热更新的回调钩子;
2.针对js-type的更新逻辑 queueUpdate(fetchUpdate(update));
fetchUpdate
这里主要就是重新请求热更新模块,以及处理热更新函数定义的回调;可看注释
queueUpdate
queueUpdate 函数收集fetchUpdate的返回函数;并在下一个任务队列中触发所有回调。
如果没有没有模块接收热更新,会直接像客户端发送页面重新加载的消息,客户端直接reload页面。
热更新上下文注入(acceptAPI也在里面)
还有这里的import.meta.hot 方法(模块热更新的相关处理方法都挂载import.meta.hot上)实现在importAnalysis插件逻辑中(上面介绍过)会注入到模块文件中;
还有css样式文件的登记和挂载方法(updateStyle)实现也是会注入到对应模块里;
总结
Vite 首先是根据分析模块的依赖关系,创建完善了模块依赖图,在 HMR 过程中,服务端会根据这张图来寻找 HMR 边界模块。
HMR 更新由客户端和服务端通过 WebSocket 进行通信。Vite 服务端通过查找模块依赖图确定热更新的边界 ,并将局部更新或者全局刷新的信息传递给客户端,客户端接收到热更信息 后,会通过动态 import 请求 并加载最新模块的内容,并执行派发更新的回调( import.meta.hot.accept 中定义的回调函数),从而完成完整的热更新过程。其中有非常多细节的处理,有兴趣可以之后再去看源码细细品尝。
note:
虚拟模块搭配vite插件使用可以自定义返回内容(比如说自定义virtual:allJsModule返回所有js模块等等,可以获取运行时的内部状态;)
vite单独给vue文件定义了热更新的策略,应该是在插件里做了热更新钩子的处理;