写在前面,其实在我工作的第一年就使用过keepAlive,那时需要实现一个需求保存用户的编辑状态,这样切换页面的时候要能保存他之前在页面上的所有的编辑信息(不请求接口保存的情况下)。那时候主要也就是用到activated和deactivated钩子函数。然后接下来的工作中基本上不怎么使用到keepAlive啦,直到前两天接到了一个需求:要实现从一级页面进入到详情页面,然后从详情页返回到一级页面的时候需要保留之前的状态。然后我像之前一样使用keepAlive,但是在实际开发的过程中遇到了一些问题,于是我决定,从头再来学习一下keepAlive.
什么是keepAlive
keepAlive是一个内置组件,能够在组件切换过程中将状态保留在内存中,防止重复的渲染DOM.
keepAlive的原理
keepAlive主要有三个属性include,exclude和max。还有四个钩子:created,destroyed,mounted和render。 在created中创建缓存列表和缓存组件的key列表,在destroyed中遍历销毁清空所有的缓存列表和key列表。在mounted中会监听include和exclude属性,处理组件的缓存。最核心的是渲染的时候,首先会默认拿插槽的值,缓存第一个组件,然后取出第一个组件的名称,判断当前组件是否在缓存中(根据name判断),如果存在就缓存,不存在就return。具体的缓存过程就是通过缓存列表中的key值取到组件(如果没有key值,就组件的标签和cid拼接一个key,然后把这个key值存在key列表中)。这一步涉及到LRU(Least recently used)算法, 也就是说缓存写满的时候,会根据所有数据的访问记录,淘汰掉未来被访问几率最低的数据。在这里就是说key如果达到了设定的最大组件缓存数,就会删除最久未使用的,从前面删除,然后把当前使用的往数组的最后面添加进去。 我们现在从源码的角度来分析以下keepAlive的原理
源码
源码文件位置:src/core/commponent/keep-alive.js
核心源码:
export default {
name: 'keep-alive',
abstract: true,
props: {
include: patternTypes,
exclude: patternTypes,
max: [String, Number]
},
created () {
this.cache = Object.create(null)
this.keys = []
},
destroyed () {
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys)
}
},
watch: {
include (val: string | RegExp | Array<string>) {
pruneCache(this, name => matches(val, name))
},
exclude (val: string | RegExp | Array<string>) {
pruneCache(this, name => !matches(val, name))
}
},
render () {
const vnode: VNode = getFirstComponentChild(this.$slots.default)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions)
if (name && (
(this.include && !matches(this.include, name)) ||
(this.exclude && matches(this.exclude, name))
)) {
return vnode
}
const { cache, keys } = this
const key: ?string = 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
}
}
created和destroyed
- created钩子会创建一个空对象cache,作为缓存容器,用来存放vnode节点
- destroyed钩子会在组件被销毁的时候清除cache缓存中所有组件实例
render
这里面是整个keep-alive实现缓存的核心代码。主要分为以下几步
- 先获取到keep-alive插槽里面的内容
- 再调用getFirstComponentChild方法获取到第一个组件,获取到该组件的name,没有的话就用tag名
- 根据获取到的name和include/exclude进行匹配,匹配不成功就直接返回这个vnode, 匹配成功的话就执行缓存
匹配的函数主要就是根据include与exclude的字符串,数组,正则表达式这三种方式来判断是否匹配当前组件。
function matches (pattern: string | RegExp | Array<string>, name: string): boolean { if (Array.isArray(pattern)) { return pattern.indexOf(name) > -1 } else if (typeof pattern === 'string') { return pattern.split(',').indexOf(name) > -1 } else if (isRegExp(pattern)) { return pattern.test(name) } /* istanbul ignore next */ return false } - 缓存机制:
- 拿到组件的key值,如果组件有key就直接取,如果没有的话就将tag和cid拼接一个key.
- 根据这个key值在缓存列表cache中查找,如果存在则说明之前已经缓存过了,直接将缓存的vnode的componentInstance(组件实例)覆盖到目前的vnode上面。否则将vnode存储在cache中。最后返回vnode
- 具体缓存处理:命中缓存直接从缓存中取出vnode的组件实例,此时重新调整该组件key的顺序,将其从原来的位置删除,并重新添加到this.keys的最后一个。如果没有命中缓存,说明该组件还没有被缓存过,需要将组件的key作为键,vnode作为值存到cache列表中,并且把key存到this.keys中。此时再判断this.keys是否达到设置的最大数量this.max。这里面涉及到LRU淘汰策略。
- this.keys的逻辑:
- 将新数据从尾部插入到this.keys
- 每当缓存命中时,删除原来位置的key, 然后放到尾部
- 当this.keys满的时候,将头部的数据丢弃
mounted
监听include和exclude的变化,如果发生变化则表示定义需要缓存的组件的规则或者不需要缓存的组件的规则发生了变化,就调用pruneCache方法。
function pruneCache (keepAliveInstance: any, filter: Function) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode: ?VNode = cache[key]
if (cachedNode) {
const name: ?string = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}
pruneCache函数主要是遍历this.cache对象,取出每一项的name值,用其与新的缓存规则进行匹配,如果匹配不上,则表示该组件不需要被缓存,则调用pruneCacheEntry方法将其从this.cache中删除.
小结
从以上源码我们可以看出,keep-alive组件的缓存是基于vnode而不是直接在DOM节点上进行缓存的,然后再需要重新渲染的时候将vnode从cache中取出并渲染
keepAlive的用法
基本用法
keepAlive的用法一般分为两类:
- 在页面中缓存某些组件 对于这种我们可以直接使用keepAlive包裹就行
<keep-alive>
<component></component>
</keep-alive>
这种情况经常会使用的场景就是页面中几个tab的切换,缓存不活跃的组件。 这样就可以使用component的is属性来动态加载组件。比如像下面这种, 其中activeComponent就是我们页面引入组件的组件名
<keep-alive>
<component :is='activeComponent'></component>
</keep-alive>
- 缓存某些页面 因为每一个页面在vue中都是具有一个路由,因此我们这种缓存页面的方式是要结合路由来考虑的。通常的用法如下: 1)在需要缓存的页面组件的路由添加元信息keepAlive。
{
path: '/list',
name: 'list',
component: () => import(@/views/list);
meta: {
title: '列表',
keepAlive: true
}
}
2)在页面显示的地方(也就是router-view离开)用keepAlive包裹 通常我们的router-link是很多页面跳转都会在当前位置显示,并不是所有的页面组件都需要缓存,因此我们需要过滤
<keepAlive>
<router-view v-if="$route.meta.keepAlive"></router-view>
</keepAlive>
<router-view v-if="!$route.meta.keepAlive"></router-view>
常用属性
在keepAlive组件中,常用到的属性主要有以下几种:
- include: 指定哪些组件被缓存
- exclude: 指定哪些组件不被缓存
- max: 指定组件缓存的数量。当缓存组件的数量达到最大值时,会将第一个缓存的组件删除,并将当前组件添加进去。
include和exclude的用法类似,都支持以下几种写法
<!-- 以英文逗号分隔的字符串 -->
<KeepAlive include="a,b">
<component :is="view" />
</KeepAlive>
<!-- 正则表达式 (需使用 `v-bind`) -->
<KeepAlive :include="/a|b/">
<component :is="view" />
</KeepAlive>
<!-- 数组 (需使用 `v-bind`) -->
<KeepAlive :include="['a', 'b']">
<component :is="view" />
</KeepAlive>
max的用法比较简单,属性值就是数字。
<KeepAlive max="4">
<component :is="view" />
</KeepAlive>
特别说明
- include和exclude的属性值要和组件的名称保持一致。特别注意是组件页面(.vue文件)中的name值,不是配置路由里面的name值。如果项目中有两处router-view(多层嵌套),请在每个里面都加上
- 既然有include和exclude可以过滤,那么和$route.meta.keepAlive去判断元信息有什么区别呢? 先来看一下这个例子:
// app.vue
<keepAlive v-if="isRouterAlive" include="saleDataList, averageMonPosition">
<router-view v-if="$route.meta.keepAlive"></router-view>
</keepAlive>
<router-view v-if="!$route.meta.keepAlive"></router-view>
此时我们只给我们的saleDataList组件添加$route.meta.keepAlive为true, 而averageMonPosition没有添加,此时两个组件都没有缓存,原因是因为我们的项目中不止一个router-view,都需要加上。这时候saleDataList有缓存了,但是averageMonPosition没有被缓存。原因很简单,averageMonPosition没有设置$route.meta.keepAlive,它根本就不存在keepAlive标签
再看一下下面这个例子
// app.vue
<keepAlive include="saleDataList,averageMonPosition">
<router-view></router-view>
</keepAlive>
// 其他页面
<keepAlive include="saleDataList,averageMonPosition">
<router-view></router-view>
</keepAlive>
saleDataList和averageMonPosition组件都没有设置$route.meta.keepAlive为true,此时两个都可以缓存
因此可以得出结论
- 使用include和exclude的效果,和$route.meta.keepAlive去判断元信息的效果是一样的,都是我们去实现组件缓存的方式,而且它们可以共同存在,共同作用
- 使用$route.meta.keepAlive的话就只需要在app.vue里面加上keepAlive组件就行,但是如果使用include和exclude,那么凡是项目中用到router-view的地方都需要加上include和exclude
- 使用include和exclude的时候,多个name值之间用逗号分隔,不能有空格
生命周期钩子
当一个组件实例从DOM上移除但因为被KeepAlive缓存而仍作为组件树的一部分时,它将变为不活跃状态而不是被卸载。当一个组件实例作为缓存树的一部分被插入到DOM中时,它将会被重新激活。 keepAlive中有两个生生命周期的钩子:activated和deactivated
- activated: 组件挂载是调用
- deactivated: 组件卸载时会被调用。
没有使用keepAlive时整体生命周期
beforeRouteEnter --> created --> mounted --> updated
离开页面: destroyed
使用keepAlive的生命周期
-
第一个次进入页面 beforeRouteEnter --> created --> mounted --> activated --> updated
离开页面: deactivated --> updated
-
后续进入页面 beforeRouteEnter --> activated
离开页面: deactivated --> updated
小结
是否使用keepAlive生命周期之所以会有一些差别,是因为keepAlive是用来做组件缓存的,而且一定会存在activated和deactivated这两个生命周期,至于这两个生命周期在什么时候会触发呢,我们可以这样来理解
- 第一个进入页面,因为组件不存在,因此需要初始化渲染,然后再执行activated周期,相当于对组件有更改然后就继续执行updated钩子,离开的时候因为页面需要缓存,因此组件不能销毁,所以不存在destroyed,而是执行deactivated,这个时候相当于又对组件更改了,因此需要执行updated
- 第二次或者后续进入页面此时页面已经有缓存了,这时候就没有初始化和渲染了,因此只需要执行activated,离开的时候和第一次进入页面一样,执行deactivated,这个时候相当于又对组件更改了,因此需要执行updated
- 只要涉及到路由的生命周期钩子函数,不论是否使用keepAlive,都会执行,因此上述例子中的beforeRouteEnter在进入页面的时候都会调用
keepAlive的应用场景
- 列表页进入详情页,再返回列表页的时候希望保留用户之前的筛选操作和结果
- tabs切换时保留切换之前的操作
- 前进刷新,后退缓存用户浏览器数据和浏览位置 其实这些应用场景就是要保存用户在页面上的操作,对于这类场景都可以使用keepAlive来实现。
我在使用keepAlive中遇到的问题
-
使用include或exclude不生效 不生效的情况有以下几种:
1) include或exclude的属性值和组件的name值不一致
2)页面路由嵌套,没有在所有的router-view中都加上
3)vue-router版本太低,必须为2.0以上版本才可以
-
二次进入页面显示首次缓存
场景描述:从一级详情列表页面进入二级详情页面,然后返回列表页面的时候缓存页面,然后从别的菜单再进入一级详情页面,之后从一级详情列表页面进入二级详情页面,然后返回列表页面的时候显示的是上一次的缓存。
原因描述: 其实从keepalive的原理我们可以看出。在render渲染的时候,命中了缓存,那么就会直接从cache中取出了vnode进行渲染,因此这也是为什么二次进入页面的时候会展示首次缓存的原因啦。
解决方案:触发destroyed,将缓存销毁,这样二次进入的时候就会重新添加缓存了。实现方式就是触发页面的reload,具体实现如下:
- 在keepAlive中加一个判断条件,这个判断条件在页面加载的时候重新赋值,这样keep-alive组件就能执行到destroyed钩子了
<keepAlive v-if="isRouterAlive"> <router-view v-if="$route.meta.keepAlive"></router-view> </keepAlive> <router-view v-if="!$route.meta.keepAlive"></router-view> data() { return { isRouterAlive: true }; } - 定义一个reload方法。改变keepAlive的判断条件的值,实现页面的刷新
reload () { this.isRouterAlive = false; this.$nextTick(() => (this.isRouterAlive = true)); } - 通过provide向子孙组件们暴露一个reload方法
provide() { return { reload: this.reload }; } - 在需要使用到缓存的组件中注入这个reload方法
inject: ['reload'], beforeRouteEnter (to, from, next) { to.meta.keepAlive = true; if (from.path.includes('/customer/manage')) { next(vm => { setTimeout(() => { document.querySelector('.main-container').scrollTop = vm.scrollTop; document.querySelector('.main-container').scrollLeft = vm.scrollLeft; }); }); } else { next(vm => { vm.reload(); }); } }
- 在keepAlive中加一个判断条件,这个判断条件在页面加载的时候重新赋值,这样keep-alive组件就能执行到destroyed钩子了