当前位置: 首页 > 科技观察

基于qiankun微前端的多tab缓存方案实践

时间:2023-03-16 00:03:56 科技观察

作者|vivo互联网前端团队-唐啸本文整理了基于阿里开源微前端框架qiankun实现多tab分页的方案-应用缓存,也比较了很多不同方案之间的差异和优缺点,为使用微前端进行多标签开发的同学提供一些参考。1.什么是多标签?从产品的角度来看,我们常见的浏览器都是多标签,编辑器也是多标签。从产品的角度来看,它们旨在使用户访问能够被记录并快速定位工作空间。对于单页应用程序,可以实现多个选项卡。缓存用户的访问记录,以提供更好的用户体验。前端可以通过多种方式实现多个tab,常见的解决方案有两种:使用CSS样式display:none来控制页面的显示和隐藏模块的内容;将模块序列化并缓存起来,通过缓存的内容进行渲染(类似于vue的keep-alive原理类似,广泛应用于单页应用)。与第一种方式相比,第二种方式将DOM格式存储在序列化的JS对象中,只渲染需要显示的DOM元素,减少了DOM节点数量,提高了渲染性能。是目前主流实现的多页签名方式。那么相对于传统的单页应用,微前端qiankun修改后的多标签实现的前端应用有什么区别呢?1.1单页应用实现多Tab改造前单页应用的技术栈是Vue全家桶(vue2.6.10+element2.15.1+webpack4.0.0+vue-cli4.2.0)。vue框架提供了keep-alive来支持缓存相关的需求。使用keep-alive可以实现多tab的基本功能,但是为了支持更多的功能,我们在其基础上重新封装了vue-keep-alive组件。与keep-alive通过include和exclude来控制缓存相比,vue-keep-alive使用了更原始的发布和订阅方式来删除缓存,可以实现更完整的多tab功能。例如,同一条路线可以基于不同的参数。派生多个路由实例(如打开多个详情页标签)、动态删除缓存实例等功能。下面是vue-keep-alive的自定义扩展实现:created(){//动态删除缓存实例监听this.cache=Object.create(null);breadCompBus.$on('removeTabByKey',this.removeCacheByKey);breadCompBus.$on('removeTabByKeys',(data)=>{data.forEach((item)=>{this.removeCacheByKey(item);});});}vue-keep-alive组件可以自定义传入方法,用于自定义vnode.key,支持在同一个匹配路由中派生多个实例。//传入`vue-keep-alive`的自定义方法functionupdateComponentsKey(key,name,vnode){constmatch=this.$route.matched[1];if(match&&match.meta.multiNodeKey){vnode.key=match.meta.multiNodeKey(key,this.$route);返回vnode.key;}returnkey;}1.2使用qiankun做微前端改造后,多tab缓存有什么区别qiankun是蚂蚁金服推出的基于Single-Spa的前端微服务框架,本质上是一个路由分布式服务框架。与原来使用JSEntry的Single-Spa不同,qiankun使用HTMLEntry进行替换和优化。使用qiankun进行微前端改造后,页面被拆分为一个基础应用和多个子应用,每个子应用运行在独立的沙盒环境中。与单页应用中通过keep-alive控制组件实例的方式相比,拆分后的每个子应用的keep-alive无法控制其他子应用的实例。我们需要缓存对所有应用生效,所以我们只能将缓存放在停靠应用中。这里有几个问题:加载:主应用程序应该何时以及如何加载子应用程序实例?渲染:通过缓存实例渲染子应用时,子应用是通过DOM渲染还是有别的方式?通信:关闭一个tab时,如何判断子应用是否完全卸载,主应用应该用什么通信方式告诉子应用?2.方案选择在Githubissues、Nuggets等平台上搜索对比了一系列资料,在不修改qiankun源码的情况下,在qiankun框架下实现多tab主要有两种方式。2.1方案一:多个子应用同时存在。实现思路:在dom上通过v-show控制显示哪个子应用,display:none;控制不同子应用dom的显示和隐藏。当url发生变化时,通过loadMicroApp手动控制加载哪个子应用,关闭tab时手动调用unmount方法卸载子应用。示例:具体DOM显示(通过display:none;来控制不同子应用DOM的显示和隐藏):方案优势:loadMicroApp是qiankun提供的API,访问方便快捷;该方法不卸载子应用,页面切换速度更快相对快速的解决方案不足:切换子应用时DOM没有被销毁,会导致DOM节点过多,事件监听,并且在严重的会导致页面卡顿;切换时子应用没有卸载,路由事件监听也没有卸载,所以需要监听路由变化。监视器进行特殊处理。2.2方案二:同时只加载一个子应用,同时保存其他应用的状态。实现思路:通过registerMicroApps注册子应用,qiankun会自动加载匹配的子应用;参考keep-alive的实现方式,每个子应用都缓存了自己实例的vnode,下次进入子应用时,可以直接使用缓存的vnode渲染为真正的DOM。方案优点:同时只显示一个子应用的活动页面,可以减少DOM节点数量;非活跃子应用卸载时,DOM和不必要的事件监听会同时卸载,可以释放一定的内存。不足的解决方案:没有现成的可以快速实现的API,子应用缓存需要自己管理,实现起来比较复杂;DOM渲染多了一个从虚拟DOM到真实DOM的转换过程,渲染时间会比第一种方案略长。vue组件实例化流程介绍这里简单回顾一下vue的几个关键渲染节点:Vue关键渲染节点(来源:掘金社区)编译:编译模板,转换AST生成渲染函数;render:生成一个VNODE虚拟DOM;patch:将虚拟DOM转换为真实DOM;因此,与方案一相比,方案二是最终补丁的过程。2.3最终选择基于对两种方案优缺点的评估。同时,根据我们项目的具体情况,最终选择第二种方案进行实施。具体原因如下:过多的DOM和事件监听会造成不必要的内存浪费,同时我们的项目主要还是以编辑器展示和数据展示为主。单个选项卡内容较多,我们往往更关注内存占用。方案二在子应用二次渲染时增加了一个patch过程,渲染速度不会增加。慢多少在可接受的范围内。3、具体实现在上面的部分,我们简单描述了方案2的一个实现思路,核心思路是缓存子应用实例的vnode。那么在这一部分,我们来看一下它的一个具体的实现过程。3.1从组件级缓存到应用级缓存在Vue中,keep-alive组件通过缓存vnodes来实现组件级缓存。对于通过Vue框架实现的子应用,其实就是一个Vue实例。那么我们也可以通过缓存vnodes来实现应用级的缓存。通过分析keep-alive源码得知,keep-alive在render中通过cachehit返回对应组件的vnode,在mounted和upda中//keep-alive核心代码render(){constslot=this.$slots.defaultconstvnode:VNode=getFirstComponentChild(slot)constcomponentOptions:?VNodeComponentOptions=vnode&&vnode.componentOptionsif(componentOptions){//更多代码...//缓存命中if(cache[key]){vnode.componentInstance=cache[key].componentInstance//使当前键最新鲜remove(keys,key)keys.push(key)}else{//延迟设置缓存直到更新this.vnodeToCache=vnodethis.keyToCache=key}//设置keep-alive,防止created等生命周期被再次触发vnode.data.keepAlive=true}returnvnode||(slot&&slot[0])}//挂载更新时缓存当前组件的vnodecache需要在mounted和update这两个生命周期进行更新。在应用级缓存中,我们只需要卸载子应用即可。主动缓存整个实例的vnode。//父应用提供了unmountCache方法functionunmountCache(){//只有第一次加载产生的实例会永远保存在这里constneedCached=this.instance?.cachedInstance||这个实例;常量缓存实例={};cachedInstance._vnode=needCached._vnode;//keepalive设置为防止进入时重新创建,同keep-aliveif(!cachedInstance._vnode.data.keepAlive)cachedInstance._vnode.data.keepAlive=true;//省略其他代码...//loadedApplicationMap以key-value形式使用,用于保存当前应用的实例loadedApplicationMap[this.cacheKey]=cachedInstance;//省略其他代码...//卸载实例this.instance.$destroy();//setThis.instance=null;}//在qiankun框架提供的unmount方法中,子应用调用unmountCacheexportasyncfunctionunmount(){console.log('[vue]systemappunmount');mainService.unmountCache();}3.2重新挂载vnode到一个新的实例,将vnode缓存在内存中,然后卸载原来的实例,重新进入子应用,就可以使用缓存的vnode进行渲染渲染了。//创建子应用实例,并使用缓存的vnodefunctionnewVueInstance(cachedNode){constconfig={router:this.router,store:this.store,render:cachedNode?()=>cachedNode:instance.render,//优先使用缓存的vnode});returnnewVue(config);}//实例化一个子应用实例,根据是否有缓存的vnode('#app');那么,这里有一些疑惑:如果我们每次进入子应用都重新创建一个实例,为什么还要卸载呢?不卸载就够了吗?将缓存vnode用于新实例不会有问题吗?首先,我们来回答第一个问题。为什么切换子应用时需要卸载原有的子应用实例?有两方面的考虑:一是内存的考虑。我们需要的只是vnode,而不是整个实例,缓存整个实例是第一种方案的实现,所以我们只需要缓存我们需要的对象即可;第二,卸载子应用实例可以去掉不必要的事件监听,比如vue-router对popstate事件监听后,我们不希望原来的子应用在其他子应用运行时响应这些事件,所以这些监听器可以在卸载子应用程序时删除。对于第二个问题,情况会比较复杂。下一部分主要看遇到的主要问题以及解决方法。3.3解决应用级缓存方案问题3.3.1Vue-router相关问题实例卸载后,路由变化监听失效;新的vue-router无法记录原来的routerparams和其他参数。首先我们要明确这两个问题的原因:第一个是因为子应用卸载的时候popstate事件的监听器被移除了,那么我们需要做的就是重新注册这个监听器popstate事件,可以重新实例化一种vue-router方案;第二个问题是因为通过重新实例化vue-router解决了第一个问题之后,其实就是一个新的vue-router,我们要做的不仅仅是缓存vnode,还要缓存router相关的信息。一般的解决方法如下://实例化子应用vue-routerfunctioninitRouter(){const{router:originRouter}=this.baseConfig;constconfig=Object.assign(originRouter,{base:`app-kafka/`,});Vue.use(VueRouter);this.router=newVueRouter(config);}//创建子应用实例,并使用缓存的vnode函数newVueInstance(cachedNode){constconfig={router:this.router,//在vueinit过程中,会再次调用vue-router的init方法,重启popstate事件监听store:this.store,render:cachedNode?()=>cachedNode:instance.render,//首先使用缓存vnode});returnnewVue(config);}functionrender(){if(isCache){//场景一,重新进入应用(带缓存)constcachedInstance=loadedApplicationMap[this.cacheKey];//路由器使用缓存命中this.router=cachedInstance.$router;//使当前路由在原始Vue实例上可用this.router.apps=cachedInstance.catchRoute.apps;//使用缓存的vnode重新实例化子应用程序constcachedNode=cachedInstance._vnode;this.instance=this.newVueInstance(cachedNode);}else{//场景2,先加载子应用/重新进入应用(无缓存)this.initRouter();//正常实例化this.instance=this.newVueInstance();}}functionunmountCache(){//省略其他代码...cachedInstance.$router=this.instance.$router;缓存实例。$router.app=null;//省略其他代码...}3.3.2父子组件通信多tab方法增加了父子组件通信的频率,qiankun提供了setGlobalState通信方法,但是单应用模式下,同一个Time只支持与通信一个子应用程序。对于未挂载的子应用程序,它无法接收来自父应用程序的通信。因此,针对不同的场景,我们需要更灵活的通信方式。具有通讯方式;child到parent的通信场景比较简单,一般只在路由变化时上报,并且只有活跃的子应用才会上报,可以直接使用qiankun自带的通信方式;父应用-子应用:使用自定义事件通信;父应用到子应用不仅需要与处于活跃状态的子应用通信,还需要与当前缓存中的子应用通信;因此,父应用程序对子应用程序,通过自定义事件的方式,可以实现一个父应用程序与多个子应用程序之间的通信。//自定义事件发布constevt=newCustomEvent('microServiceEvent',{detail:{action:{name:action,data},basePath,//用于唯一标识子应用},});document.dispatchEvent(evt);//自定义事件监听document.addEventListener('microServiceEvent',this.listener);3.3.3缓存管理防止内存泄露使用缓存最重要的是管理好缓存,在不需要的时候及时清理。这是JS中非常重要但又容易被忽视的一个东西。应用级缓存子应用vnode、router等属性,子应用切换时缓存;页面级缓存使用vue-keep-alive来缓存组件的vnode;删除tab时,监听remove事件,删除page对应的vnode;vue-keep-当alive组件中的所有缓存都被删除时,通知删除整个子应用缓存;3.4整体框架最后,我们从整体的角度来理解多标签缓存的实现。因为不仅需要管理子应用的缓存,还需要在每个子应用中注册vue-keep-alive组件。我们将在主应用程序的mainService中管理这些服务。registerMicroApps在注册子应用时,将passprops传递给子应用,从而实现同一套代码在多处实现复用。//子应用程序main.jsletmainService=null;exportasyncfunctionmount(props){mainService=null;const{MainService}=props;//注册主应用服务mainService=newMainService({//传入相应的参数});//实例化vue并渲染mainService.render(props);}exportasyncfunctionunmount(){mainService.unmountCache();}最后梳理一下关键流程:4.存在的问题4.1暂时只支持vue框架的实例化缓存方案也是基于vue已有的特性实现的。react社区对于多tab的实现并没有统一的实现方案,笔者也没有过多探索。考虑到现有项目是基于vue技术栈的,后期升级只会升级到vue3.0,可以在一段时间内全面支持。5.总结与社区中大部分方案1的实现相比,本文提供了另一种实现多标签页缓存的方式,主要是子应用缓存处理上有一些差异,总体思路和通信方式是互通的。另外,本文不对qiankun框架的使用做过多的分歧总结。官网和Github上已经有很多相关问题和坑经验的总结,可供参考。