背景
项目中,多级页面跳转时,经常需要记录页面状态,比如记录列表页的滚动位置、记录用户已输入的查询条件等。如果用变量记录状态,后面随业务功能增加,则会徒增很多工作量;官方提供了keep-alive,用于缓存组件状态,用这种实现方案则减少了很多维护成本,一劳永逸。
keep-alive是Vue提供的一个抽象组件,主要用于保留组件状态或避免重新渲染。 比如从 详情页 -->返回 列表页 的时候页面的状态是缓存,不用重新请求数据,可以提升用户体验。
但在使用中,这块却偏偏遇到很多bug,找了很多帖子,终于解决,有必要记录一下。
有问题的写法
项目中,用路由守卫去控制组件的缓存状态,希望的效果:从详情页到列表页时走缓存、从首页到列表页时,不走缓存,列表页正常刷新接口取最新数据;
但运行时,首页进入列表页,依然是走的缓存,改了keepalive属性也没用。
代码
router.js
{
path: '/list',
name: 'list',
component: () => import('../views/list.vue'),
meta: { keepAlive: true }
},
router.beforeEach((to, from, next) => {
if (from.name=='list' ) {
if(to.name=='detail'){
to.meta.keepAlive = true; //当我们进入到C时开启B的缓存
}else{
from.meta.keepAlive = false;//当我们前进的不是C时我们让B页面刷新
}
}
next();
})
根组件
<keep-alive>
<router-view v-if="$route.meta.keepAlive" />
</keep-alive>
<router-view v-if="!$route.meta.keepAlive" />
结果来看,简单的修改keep-alive属性,并不能避免走缓存;能发现,keep-alive的设计,在实际业务中是存在一些坑的,需要去了解源码才能解决。
分析源码
想知道问题在哪里,就要知道keep-alive这个抽象组件的实现功能、解决问题,则需要了解底层实现
贴源码前,简单说一些keep-alive的设计
简单来说 就是: created声明了要缓存的组件对 象
cache,和存储的组件keys,keep-alive销毁的时候会用pruneCacheEntry将缓存 的所有组件实例销毁,也就是调用组件实例的destroy方法。在挂载完成后监听 include和exclude,动态地销毁已经不满足include和满足exclude的组件
项目没有用include和exclude这俩props,换了一种写法,效果一样,在路由配置时,加了meta属性,根据meta属性来确认要不要包裹在keep-alive里面(包裹就理解为默认插槽内容)
接下来就是源码
源码路径(直接npm下载依赖): node_modules/vue/src/components/keep-alive.js
贴出来主要结构
组件实例:
created () {
this.cache = Object.create(null) // 存储需要缓存的组件
this.keys = []
// 存储每个需要缓存的组件的key,即对应this.cache对象中的键值
},
// 销毁keep-alive组件的时候,对缓存中的每个组件执行销毁
destroyed () {
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys)
}
},
mounted () {
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},
render() {
/* 获取默认插槽中的第一个组件节点 */
const slot = this.$slots.default
const vnode = getFirstComponentChild(slot)
/* 获取该组件节点的componentOptions */
const componentOptions = vnode && vnode.componentOptions
if (componentOptions) {
/* 获取该组件节点的名称,优先获取组件的name字段,如果name不存在则获取组件的tag */
const name = getComponentName(componentOptions)
const { include, exclude } = this
/* 如果name不在inlcude中或者存在于exlude中则表示不缓存,直接返回vnode */
if (
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
}
const { cache, keys } = this
const key = vnode.key == null
// same constructor may get registered as different local components
// so cid alone is not enough (##3269)
? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance
// make current key freshest
remove(keys, key)
keys.push(key)
} else {
cache[key] = vnode
keys.push(key)
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}
vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
}
通过this.$slots.default拿到插槽组件,也就是keep-alive包裹的组件, getFirstComponentChild获取第一个子组件,获取该组件的name(存在组件 名则直接使用组件名,否则会使用tag)。
将这个name通过include与 exclude属性进行匹配,匹配不成功(说明不需要进行缓存)则不进行任何操作 直接返回vnode(vnode节点描述对象,vue通过vnode创建真实的DOM)。
匹配到了就开始缓存,根据key在this.cache中查找,如果存在则说明之前已 经缓存过了,直接将缓存的vnode的componentInstance(组件实例)覆盖到 目前的vnode上面。否则将vnode存储在cache中。并且通过remove(keys, key),将当前的key从keys中删除再重新keys.push(key),这样就改变了当前 key在keys中的位置。这个是为了实现max的功能,并且遵循缓存淘汰策略。
如果没匹配到,说明没缓存过,这时候需要进行缓存,并且判断当前缓存的个数 是否超过max指定的个数,如果超过,则销毁keys里的最后一个组件,并从keys 中移除,这个就是LRU(Least Recently Used :最近最少使用)缓存淘汰算 法。
最后返回vnode或者默认插槽的第一个组件进行DOM渲染。
那可以发现,问题就在于,重复进入列表页时,由于keepAlive值为true,会匹配到cache中的旧缓存。而业务需要的是刷新不走缓存。所以列表跳到首页后,就要手动将这个页面的缓存给清除。而这个实现,需要你
(1)找到在cache中存储的当前缓存,也就是根据key找到对应的键值对,清空键值
(2)调用keep-alive提供的$destory()清除此实例
那场景,自然是在列表回到首页的时候,去加这两个逻辑,也就是在列表页的组件路由守卫beforeRouteLeave里面
beforeRouteLeave(to, from, next) {
if (to.name != "Home") {
//不回首页 不需要销毁
console.log('enter beforeRouteLeave detail')
from.meta.keepAlive = true;
} else {
//进入首页 调用distory方法 清除所有缓存 保证下次进列表页 永远是刷新的
console.log('enter beforeRouteLeave home')
let vnode = this.$vnode
let parentVnode = vnode && vnode.parent;
if (parentVnode && parentVnode.componentInstance && parentVnode.componentInstance.cache) {
var key = vnode.key == null
? vnode.componentOptions.Ctor.cid + (vnode.componentOptions.tag ? `::${vnode.componentOptions.tag}` : '')
: vnode.key;
var cache = parentVnode.componentInstance.cache;
var keys = parentVnode.componentInstance.keys;
console.log('cache', cache)
console.log('keys', keys)
console.log('cache[key]', cache[key])
if (cache[key]) {
console.log('enter destroy')
//清除实例
this.$destroy()
// remove key
if (keys.length) {
var index = keys.indexOf(key)
if (index > -1) {
keys.splice(index, 1)
}
}
//清除此缓存
cache[key] = null
}
}
}
next();
},
在需要清除缓存的分支里,核心逻辑,是通过vueInstance.$vnode.parent.componentInstance获取到keep-alive实例,清除cache的同时,destory当前实例,也就是上面的(1)(2)步
到这里 就能实现从详情页到列表页时走缓存、从首页到列表页时,不走缓存
总结
其实这个问题,也是用keep-alive组件经常碰到的,看来,这种封装好的组件,用的时候,往往有坑需要你结合实际业务去定制和填充。
当然,这些不是组件本身的设计问题,而是实际业务中,用轮子,就不可避免遇到需要添加逻辑的情况。
keep-alive在设计时,考虑了缓存的设置和清除并给出了内置函数;但是使用中,对于这种b=>a,b=>c的跳转,仍然需要你自己定制清除逻辑,具体就是:
(1)找到在cache中存储的当前缓存,也就是根据key找到对应的键值对,清空键值
(2)调用keep-alive提供的$destory()清除实例
如此就能顺利解决,底层理清,就不会无从下手。