使用 keep-alive 的 include 属性实现 Vue 页面缓存

众所周知,Vue 中的 keep-alive 可以对组件进行缓存,搭配上 vue-router 的 <router-view> 则可以实现页面缓存。

但网上大多数的方案都是采用在 router 的 meta 属性里增加一个 keepAlive 字段,然后在父组件或者根组件中,根据 keepAlive 字段的状态使用 <keep-alive> 标签,实现对 <router-view> 的缓存,如下:

1
2
3
4
<keep-alive>
<router-view v-if="$route.meta.keepAlive" />
</keep-alive>
<router-view v-if="!$route.meta.keepAlive" />

如果要对页面动态控制是否需要缓存,则是在 beforeRouteLeave() 里去控制 keepAlive 的状态。

这个方法看似简单,但问题挺多,网上的解决方案似乎也不太理想,我甚至连尝试都懒得去尝试。

因为这个方案为了解决一个问题,反而创造出了一堆问题,为了解决这一堆问题,又引入了各种“奇思妙想”、“剑走偏锋”的骚操作,光是看大家的代码就让我头大。

在思考并搜索还有什么更好解决方案的时候,我无意翻看到 Vue 的官方文档,在 keep-alive 的介绍里看到, 2.1.0 里新增了 include 和 exclude 这两个属性,这似乎给我了一点思路。

于是带着这两个关键词,重新去百度里搜寻了一番,果然,已经有现成的解决方案了。

实现思路

这个解决方案思路其实很清晰,因为 include 属性支持传入字符串、正则和数组,利用 vuex 全局去管理 include 里的数据,就可以达到动态管理缓存。

比起开篇介绍的那个方案,这个方案从始至终都没有销毁 <router-view> ,从而规避了很多无形的坑。加上 include 本身又是官方提供的属性,跟着官方走,准没错!

实现代码

老罗说的好:少废话,先看东西。

首先 include 属性里存放的是组件的 name ,也就是说,我们的页面组件必须都先设置上 name ,注意了,这个 name 并不是 router 里的 name ,而是组件的 name

接着,因为 include 的数据是通过 vuex 动态管理的,所以需要定义一个 store ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const state = {
list: []
}

const mutations = {
add(state, name) {
state.list.indexOf(name) < 0 && state.list.push(name)
},
remove(state, name) {
state.list = state.list.filter(v => {
return v != name
})
},
clean(state) {
state.list = []
}
}

export default {
namespaced: true,
state,
mutations
}

在 mutations 里定义了三个对 list 状态更改的事件,分别是 addremoveclean ,随后我们在父组件或者根组件中就可以这样使用了。

1
2
3
<keep-alive :include="$store.state.keepAlive.list">
<router-view />
</keep-alive>

准备工作做好后,那什么时候去控制 include 里的数据呢?那就是在页面进入和离开的时候去控制就行,这里就需要用到 beforeRouteEnter()beforeRouteLeave() 这两个钩子函数。

我们假设这样一个场景,有这样两个页面,一个商品列表页(A),一个商品详情页(B),当从 A 页面跳转到 B 页面的时候,希望把 A 页面缓存上,这样在 B 页面做 $router.go(-1) 这种返回操作的时候,可以继续浏览 A 页面的内容。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A 页面
// 页面进入前
beforeRouteEnter(to, from, next) {
next(vm => {
vm.$store.commit('keepAlive/add', 'List')
})
},
// 页面离开前
beforeRouteLeave(to, from, next) {
if (['detail'].indexOf(to.name) < 0) {
this.$store.commit('keepAlive/remove', 'List')
}
next()
}

这里有一点需要注意,当离开 A 页面前,需要判断去往的页面是否为 B 页面,也就是这句 if (['detail'].indexOf(to.name) < 0) 代码(这里的 detail 是去往页面 router 里的 name ,并非组件的 name),如果去往的页面不是 B 页面,则清除缓存,比如从 A 页面返回了更上级的页面,如果不清除,下次再进来的时候,会直接调取缓存,而不是全新打开。

是不是很简单?思路是不是也特别清晰?先别着急,我们来踩踩坑。

踩坑

缓存无法清除

以上面举的例子,想要清除 A 页面的缓存,必须从 A 页面进行操作,比如从 A 页面返回到更上级的 C 页面。

但在实际业务中,页面之间的联系并非是一条直线的。比如从 A 页面进入 B 页面, B 页面有个功能按钮是可以直接进入 C 页面的,这时候再从 C 页面进入 A 页面, A 页面的缓存是还存在的,导致打开还是上次缓存的内容,而不是全新的 A 页面。

这时候就需要用到 $store.commit('keepAlive/clean') 了,因为涉及到具体业务逻辑,所以在什么时候调用 clean 方法需要具体页面具体分析。我的原则就是在顶级,或者次顶级页面上,做缓存清空处理,比如例子中的 C 页面,或者是一般项目的首页。

页面刷新后缓存失效

关于 Vue 刷新的问题,我在《Vue中刷新当前页的几种方式及优劣分析》已经有提到过。

其中方案三的刷新,无法和 keep-alive 共存,所以在需要缓存的相关页面里,建议使用方案二,或者使用方案四,手动进行数据更新。

如何更新缓存

有这么一种情况,从 A 页面进入 B 页面,在 B 页面做了一些操作后,返回 A 页面,这时候 A 页面部分数据要进行更新。

最常见的就是订单列表页,从订单列表页进入订单详情页,在订单详情页里做了一些操作,比如关闭该订单,这时改变了订单的状态,当返回的时候,订单列表页虽然被缓存了,但列表里的信息要进行更新。

我自己想到的方案是,在 B 页面离开前,往去往页面的 meta 里添加一个特定字段,例如 to.meta.returnRefresh ,至于这个字段什么时候要添加,我们可以自己控制。然后在订单列表页的 activated() 钩子里处理即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 订单详情页
beforeRouteLeave(to, from, next) {
if (['orderList'].indexOf(to.name) >= 0 && this.dataChange) {
to.meta.returnRefresh = true
}
next()
}

// 订单列表页
activated() {
if (this.$route.meta.returnRefresh) {
// 业务代码
}
}

我的这个方案没什么大问题,就是在体验上有点欠缺。因为 A 页面的更新,是当 A 页面被激活后才会进行,能明显看到返回 A 页面后,数据才进行更新,整个过程用户是有感的。

于是我开始在网上搜寻相关解决方案,同时在用 Vue 开发者工具操作的时候发现一个细节:

因为 A 页面被缓存了,所以实际上 A 页面和 B 页面这两个 <router-view> 是并存的,只是其中一个被隐藏了。既然这两个组件是并存的,我开始有方向了,搜索一圈之后,找到了解决方案。

简单来说,就是兄弟组件之间的通信,父子组件的通信我们比较了解,但兄弟平级组件之间的通信,和父子组件不一样,他们需要借助事件总线,因为 $on()$emit() 的事件必须是在一个公共的实例上才能触发,那我们可以新建一个 Vue 实例当作事件总线,达到可以不管组件之间的父子关系,都能通过这个实例通信的目的。

这里我偷懒了,直接把现有 Vue 实例当做事件总线,并将它绑定到 Vue 原型链上,方便后续使用。

1
2
3
4
5
6
7
// main.js

Vue.prototype.$eventBus = new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

准备好后,我们来看下如何在订单详情页通知订单列表页进行数据更新。

1
2
3
4
5
6
7
8
9
10
11
12
// 订单详情页
this.$eventBus.$emit('refreshOrderList')

// 订单列表页
mounted() {
this.$eventBus.$on('refreshOrderList', () => {
// 业务代码
})
},
beforeDestroy() {
this.$eventBus.$off('refreshOrderList')
}

我们在订单详情页里任何时候都可以通过 this.$eventBus.$emit('refreshOrderList') 去通知订单列表页更新数据,这样数据的更新对用户来说是无感的,用户返回订单列表页的时候,数据是已经更新好了,对用户体验上有明显的提升。

避免意外情况,在订单列表页被销毁前,手动销毁下监听的事件,这样就万无一失了。

总结

其实通篇的解决方案,网上都能找到类似的影子,如何将它们合理的使用在项目或产品中,这才是我们需要多去思考的。

其次我似乎没有遇到从 include 列表移除组件,组件没有被销毁的问题,可能 Vue 已经修复了这个 bug 吧。

参考