当前位置: 首页 > Web前端 > vue.js

哎,vue中的keep-alive有个“大坑”,你可能不知道,

时间:2023-04-01 00:42:16 vue.js

的背景是这样的。我们使用vue2开发了一个在线客服的IM应用。基本布局是左边是访客列表,右边是访客对话,为了让对话加载更友好,我们使用来缓存对话路由。但是,如果所有的会话都缓存起来,不一定会造成缓存过多的问题。自然地,使用提供的max属性来设置缓存对话内容组件的上限。根据LRU算法,最旧访问的组件将被销毁,最近使用的组件将被保留。本以为好事如期而至,结果上线后翻车了。真实的对话量增加了,记忆力飙升又停滞了。后来详细分析内存增长点,通过vue的devtool查看组件树,发现对话内容组件一直在增长,没有保持max设置的上限!请各位读者注意安全,切勿心急。下面详细分析造成这个“大坑”的原理和解决方法。场景模拟为了方便模拟后台案例,这里用vue2写了一个简单的demo。对话列表组件APP.vue,点击列表中的某个访客,加载与该访客的对话内容。这里需要给被keep-alive组件包裹的对话内容组件加上key唯一标志,这样同名(不同key)的组件会被缓存,否则对话内容组件ChatContent.vue不会被缓存,只需添加一个计数器验证组件就会被缓存。场景模拟结果表明,虽然缓存组件的最大个数为3,但实际上所有的内容组件都被一个一个缓存了。似乎设置max属性无效。Vue2中组件的实现原理为什么缓存同名组件时max属性会失效?这里需要看下Vue2中组件的实现原理。LRU算法Vue会将VNode和组件实例(componentInstance)存放在缓存(cache)中,缓存是一个Object,同时维护一个keys队列;根据LRU算法对缓存和key进行管理:当前活跃组件如果缓存中已经存在,则先删除该组件对应的key,再插入继续前进;当前活动组件不在缓存中,而是直接存储在缓存中。这个时候判断是否超过了缓存的上限。如果是,使用pruneCacheEntry清除keys第一个位置(oldest)的组件对应的缓存。if(cache[key]){vnode.componentInstance=cache[key].componentInstance;//使当前键freshestremove(keys,key);keys.push(key);}else{cache[key]=vnode;keys.push(key);//修剪最老的条目if(this.max&&keys.length>parseInt(this.max)){pruneCacheEntry(cache,keys[0],keys,this._vnode);console.log('cache:',cache)console.log('keys:',keys)}}清除缓存函数实现下面看一下缓存清理函数的实现pruneCacheEntry:比较当前传入的组件与缓存中的组件标签相同,不同则销毁组件实例,否则不销毁。functionpruneCacheEntry(cache,key,keys,current){varcached$$1=cache[key];if(cached$$1&&(!current||cached$$1.tag!==current.tag)){缓存$$1.componentInstance.$destroy();}cache[key]=null;remove(keys,key);}这里好像没有问题,请问是什么问题呢?源码调试发现问题。我们打印缓存(VNode缓存)和keys,发现没有问题。根据URL算法,得到正确的结果。看看清缓存函数中打印cached$$1.tag和current.tag的真相!因为两个组件同名,所以是相等的,没有进入销毁组件实例的判断。这就是问题的根源!为什么不销毁相同组件名称的实例?可能是为了某些场景下的组件复用。解决方案既然找到了问题的症结,当然最好是从源头上解决问题,但现实是vue2的源码层面并没有解决(vue3有解决方案,这个以后再讨论later),只能从我们的应用端想办法了。我在这里可以想到两种选择。方案一:剪枝方式维护一个全局状态(如vuex)最大长度为max的对话ids队列,类似于vue中LRU算法中的keys,当componentactivatedhook函数被触发时更新ids队列。对话内容组件子组件判断当前对话id是否在ids队列中。如果不是,则通过v-if去除,否则缓存,很大程度上释放缓存。类似于砍掉树枝来减轻重量,这里称之为“剪枝法”。方案二:自定义清除缓存函数我们不再使用keep-alive提供的max属性来清除缓存,让它缓存所有的组件实例,当前激活的组件由activated钩子函数触发,此时通过this.$vnode.parent.componentInstance获取组件实例,然后就可以获取挂载在其上的缓存和key。这样我们就可以根据key的定义使用LRU算法精确的清理缓存了。activated(){const{cache,keys}=this.$vnode.parent.componentInstance;console.log('激活缓存:',cache)console.log('激活键:',keys)letcacheLen=0constmax=3Object.keys(cache).forEach(key=>{if(cache[key]){cacheLen+=1if(cacheLen>max){constkey=keys.shift()cache[key].componentInstance.$destroy()cache[key]=null}}})},下面和vue的devtool工具对比,效果完全符合预期!方法二从组件根部开始清理缓存组件,更彻底,对业务代码侵入性更小。你认为这是结束了吗?上面我也提到了这个问题在vue3中已经解决了。组件在vue3中的实现原理就不多说了,我们先看看上面同样的case用vue3写出来的效果?这里就不“重复”代码了,只看devtool组件树的表现。没有多余的缓存组件,很好!LRU算法在vue3中的实现和vue3中的LRU算法是一样的,只是缓存和key分别使用Map和Set数据结构实现,数据更干净简洁。常量缓存=新地图();constkeys=newSet();//...if(cachedVNode){//复制挂载状态vnode.el=cachedVNode.el;//...//使这个键成为最新的keys.delete(key);keys.add(key);}else{keys.add(key);//修剪最旧的条目if(max&&keys.size>parseInt(max,10)){pruneCacheEntry(keys.values().next().value);}}vue3中的清理缓存函数实现vue3中的清理组件实例缓存函数也是pruneCacheEntry,不同的是比较当前传入的组件与缓存中的组件标签是否相同,决定是否销毁组件实例。functionpruneCacheEntry(key){constcached=cache.get(key);if(!current||cached.type!==current.type){unmount(cached);}elseif(current){//当前活动实例不应再保持活动状态。//我们现在不能卸载它,但以后可能会卸载,所以现在重置它的标志。resetShapeFlag(current);}cache.delete(key);keys.delete(key);}我们来看看cache.type和current.type的对比。我们会发现,它不再是简单的组件名称字符标记,而是包含很多属性的对象描述,因为在初始化组件实例时,它会给每个Instance加上属性:props、render、setup、__hmrId等。函数initProps(instance,rawProps,isStateful,isSSR=false){constprops={};constattrs={};def(attrs,InternalObjectKey,1);instance.propsDefaults=/*@__PURE__*/Object.create(null);setFullProps(instance,rawProps,props,attrs);//...instance.attrs=attrs;}functionisInHmrContext(instance){while(instance){if(instance.type.__hmrId)returntrue;instance=instance.parent;}}即使对象中的所有属性都一样,但是对象的引用地址不一样,导致cache.type和current.type不相等,所以实例对象会被销毁卸载(缓存)。以上就是vue3对这个问题的解决方案。总结最后,在vue2中,会缓存同名组件,max会失效。推荐在获取组件实例的基础上,使用自定义的清理缓存函数销毁缓存实例。下图是我在实际项目中优化的结果。结束~