大厂技术 坚持周更 精选好文
在我们的业务中,我们常常会有列表页跳转详情页,详情页可能还会继续跳转下一级页面,下一级页面还会跳转下一级页面,当我们返回上一级页面时,我想保持前一次的所有查询条件以及页面的当前状态。一想到页面缓存,在vue
中我们就想到keep-alive
这个vue
的内置组件,在keep-alive
这个内置组件提供了一个include
的接口,只要路由name
匹配上就会缓存当前组件。你或多或少看到不少很多处理这种业务代码,本文是一篇笔者关于缓存多页面的解决实践方案,希望看完在业务中有所思考和帮助。
正文开始...
业务目标
首先我们需要确定需求,假设A
是列表页,A-1
是详情页,A-1-1
,A-1-2
是详情页的子级页面,B
是其他路由页面
我们用一个图来梳理一下需求
大概就是这样的,一图胜千言
然后我们开始,主页面大概就是下面这样
pages/list/index.vue
我们暂且把这个当成A
页面模块吧
<template>
<div class="list-app">
<div><a href="javascript:void(0)" @click="handleToHello">to hello</a></div>
<el-form ref="form" :model="condition" label-width="80px" inline>
<el-form-item label="姓名">
<el-input
v-model="condition.name"
clearable
placeholder="请输入搜索姓名"
></el-input>
</el-form-item>
<el-form-item label="地址">
<el-select v-model="condition.address" placeholder="请选择地址">
<el-option
v-for="item in tableData"
:key="item.name"
:label="item.address"
:value="item.address"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="featchList">刷新</el-button>
</el-form-item>
</el-form>
<el-table
:data="tableData"
style="width: 100%"
row-key="id"
border
lazy
:load="load"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="date" label="日期"> </el-table-column>
<el-table-column prop="name" label="姓名"> </el-table-column>
<el-table-column prop="address" label="地址"> </el-table-column>
<el-table-column prop="options" label="操作">
<template slot-scope="scope">
<a href="javascript:void(0);" @click="handleView">查看详情</a>
<a href="javascript:void(0);" @click="handleEdit(scope.row)">编辑</a>
</template>
</el-table-column>
</el-table>
<!--分页-->
<el-pagination
@current-change="handleChangePage"
background
layout="prev, pager, next"
:total="100"
>
</el-pagination>
<!--弹框-->
<list-modal
title="编辑"
width="50%"
v-model="formParams"
:visible.sync="dialogVisible"
@refresh="featchList"
></list-modal>
</div>
</template>
我们再看下对应页面的业务js
<!--pages/list/index.vue-->
<script>
import { sourceDataMock } from '@/mock';
import ListModal from './ListModal';
export default {
name: 'list',
components: {
ListModal,
},
data() {
return {
tableData: [],
cacheData: [], // 缓存数据
condition: {
name: '',
address: '',
page: 1,
},
dialogVisible: false,
formParams: {
date: '',
name: '',
address: '',
},
};
},
watch: {
// eslint-disable-next-line func-names
'condition.name': function (val) {
if (val === '') {
this.tableData = this.cacheData;
} else {
this.tableData = this.cacheData.filter(v => v.name.indexOf(val) > -1);
}
},
},
created() {
this.featchList();
},
methods: {
handleToHello() {
this.$router.push('/hello-world');
},
handleChangePage(val) {
this.condition.page = val;
this.featchList();
},
handleSure() {
this.dialogVisible = false;
},
load(tree, treeNode, resolve) {
setTimeout(() => {
resolve(sourceDataMock().list);
}, 1000);
},
handleView() {
this.$router.push('/detail');
},
handleEdit(row) {
this.formParams = { ...row };
this.dialogVisible = true;
console.log(row);
},
featchList() {
console.log('----start load data----', this.condition);
const list = sourceDataMock().list;
// 深拷贝一份数据
this.cacheData = JSON.parse(JSON.stringify(list));
this.tableData = list;
},
},
};
</script>
以上业务代码主要做了以下几件事情
1、用mockjs
模拟了一份列表数据
2、根据条件筛选对应的数据,分页操作
3、从当前页面跳转子页面,或者跳转其他页面,还有打开编辑弹框
首先我们要确认几个问题,当前页面的几个特殊条件:
1、当前页面的条件变化,页面要更新
2、分页器切换,页面就需要更新
3、点击编辑弹框修改数据也是要更新
当我从列表去详情页,我从详情页返回时,此时要缓存当前页的所有数据以及页面状态,那要该怎么做呢?
我们先看下主页面
大概需求已经明白,其实就是需要缓存条件以及分页状态,还有我展开子树也需要缓存
我的大概思路就是,首先在路由文件的里放入一个标识cache
,这个cache
装载的就是当前的路由name
import Vue from 'vue';
import Router from 'vue-router';
import HelloWorld from '@/components/HelloWorld';
import List from '@/pages/list';
import Detail from '@/pages/detail';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/hello-world',
name: 'HelloWorld',
component: HelloWorld,
},
{
path: '/',
name: 'list',
component: List,
meta: {
cache: ['list'],
},
},
{
path: '/detail',
name: 'detail',
component: Detail,
meta: {
cache: [],
},
},
],
});
然后我们在App.vue
中的router-view
中加入keep-alive
,并且include
指定对应路由页面
<template>
<div id="app">
cache Page:{{ cachePage }}
<keep-alive :include="cachePage">
<router-view />
</keep-alive>
</div>
</template>
我们看下cachePage
是从哪里来的,我们通常把这种公用的变量放在全局store
中管理
import store from '@/store';
export default {
name: 'App',
computed: {
cachePage() {
return store.state.global.cachePage;
},
},
};
当我们进入这个页面时就要根据路由上设置的meta
去确认当前页面是否有缓存的name
,所以本质上也就成了,我如何设置keep-alive
中的include
值
import store from '@/store';
export default {
...
methods: {
cacheCurrentRouter() {
const { meta } = this.$route;
if (meta) {
if (meta.cache) {
store.commit('global/setGlobalState', {
cachePage: [
...new Set(store.state.global.cachePage.concat(meta.cache)),
],
});
} else {
store.commit('global/setGlobalState', {
cachePage: [],
});
}
}
},
},
created() {
this.cacheCurrentRouter();
this.$watch('$route', () => {
this.cacheCurrentRouter();
});
},
};
我们注意到,我们是根据$route
的meta.cache
然后去修改store
中的cachePage
的
然后我们去store/index.js
看下
// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import { gloablMoudle } from './modules';
Vue.use(Vuex);
const initState = {};
const store = new Vuex.Store({
state: initState,
modules: {
global: gloablMoudle,
},
});
export default store;
我们继续找到最终设置cachePage
的modules/global/index.js
// modules/global/index.js
export const gloablMoudle = {
namespaced: true,
state: {
cachePage: [],
},
mutations: {
setGlobalState(state, payload) {
Object.keys(payload).forEach((key) => {
if (Reflect.has(state, key)) {
state[key] = payload[key];
}
});
},
},
};
所以我们可以看到mutations
有这样的一段设置state
的操作setGlobalState
这块代码可以给大家分享下,为什么我要循环payload
获取对应的key
,然后再从state
中判断是否有key
,最后再赋值?
在业务中我们看到不少这样的代码
export const gloablMoudle = {
namespaced: true,
state: {
a: [],
b: []
},
mutations: {
seta(state, payload) {
state.a = payload
},
setb(state, payload) {
state.b = payload
},
...
},
actions: {
actA({commit, state}, payload) {
commit('seta', payload)
},
actB({commit, state}, payload) {
commit('setb', payload)
}
...
}
...
};
在具体业务中大概就下面这样
store.dispatch('actA', {})
store.dispatch('actB', {})
所以你会看到如此重复的代码,写多了,貌似会越来越多,有没有可以一劳永逸呢?
因此上面一块代码,你可以优化成下面这样
export const gloablMoudle = {
namespaced: true,
state: {
a: [],
b: []
},
mutations: {
setState(state, payload) {
Object.keys(payload).forEach(key => {
if (Reflect.has(state, key)) {
state[key] = payload[key]
}
})
},
},
actions: {
setActionState({commit, state}, payload) {
commit('setState', payload)
}
}
};
在业务代码里你就这样做
store.dispatch('setActionState', {a: [1,2,3]})
store.dispatch('setActionState', {b: [1,2,3]})
或者是下面这样
store.commit('setState', {a: [1,2,3]})
store.commit('setState', {b: [1,2,3]})
所以你会看到我这个文件会非常的小,同样达到目的,而且维护成本会降低很多,达到了我们代码设计的高内聚,低耦合,一劳永逸的抽象思想。
回到正题,我们已经设置的全局store
的cachePage
我们注意到在created
里面我们除了有去更新cachePage
,还有去监听路由的变化,当我们切换路由去详情页面,我们是要根据路由标识更新cachePage
的。
import store from '@/store';
export default {
...
methods: {
cacheCurrentRouter() {
const { meta } = this.$route;
if (meta) {
if (meta.cache) {
store.commit('global/setGlobalState', {
cachePage: [
...new Set(store.state.global.cachePage.concat(meta.cache)),
],
});
} else {
store.commit('global/setGlobalState', {
cachePage: [],
});
}
}
},
},
created() {
this.cacheCurrentRouter();
// 监听路由,根据路由判断当前是否应该要缓存
this.$watch('$route', () => {
this.cacheCurrentRouter();
});
},
};
我们看下最终的效果
当我们从当前页面切换到tohello
页面时,再回来,当前页面就会重新被激活,然后重新再次缓存
如果我需要detial/index.vue
也需要缓存,那么我只需要在路由文件新增当前路由名称即可
export default new Router({
routes: [
{
path: '/hello-world',
name: 'HelloWorld',
component: HelloWorld,
},
{
path: '/',
name: 'list',
component: List,
meta: {
cache: ['list'],
},
},
{
path: '/detail',
name: 'detail',
component: Detail,
meta: {
cache: ['detail'], // 这里的名称就是当前路由的名称
},
},
],
});
所以无论多少级页面,跳转哪些页面,都可以轻松做到缓存,而且核心代码非常简单
keep-alive揭秘
最后我们看下vue
中这个内置组件keep-alive
有什么特征,以及他是如何实现缓存路由组件的
从官方文档知道[1],当一个组件被keep-alive
缓存时
1、该组件不会重新渲染
2、不会触发created
,mounted
钩子函数
3、提供了一个可触发的钩子函数activated
函数【当前组件缓存时会激活该钩子】
4、deactivated
离开当前缓存组件时触发
我们注意到keep-alive
提供了3个接口props
- include,被匹配到的路由组件名(注意必须时组件的
name
) - exclude,排序不需要缓存的组件
- max 提供最大缓存组件实例,设置这个可以限制缓存组件实例
不过我们注意,keep-alive
并不能缓在函数式组件里使用,也就是是申明的纯函数组件
不会有作用
我们看下keep-alive
这个内置组件是怎么缓存组件的
在vue2.0
源码目录里看到/core/components/keep-alive.js
首先我们看到,在created
钩子里绑定了两个变量cache
,keys
created () {
this.cache = Object.create(null)
this.keys = []
},
然后我们会看到有在mounted
和updated
里面有去调用cacheVNode
...
mounted () {
this.cacheVNode()
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},
我们可以看到首先在mounted
里就是cacheVNode()
,然后就是监听props
的变化
methods: {
cacheVNode() {
const { cache, keys, vnodeToCache, keyToCache } = this
if (vnodeToCache) {
const { tag, componentInstance, componentOptions } = vnodeToCache
cache[keyToCache] = {
name: getComponentName(componentOptions),
tag,
componentInstance,
}
keys.push(keyToCache)
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
this.vnodeToCache = null
}
}
},
上面一段代码大的大意就是,如果有vnodeToCache
存在,那么就会将组件添加到cache
对象中,并且如果有max
,则会对多余的组件进行销毁
在render
里,我们看到会获取默认的slot
,然后会根据slot
获取根组件
首先会判断路由根组件上的是否有name
,没有就不缓存,直接返回vnode
render () {
const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
}
...
}
当再次访问时,就会从当前缓存对象里去找,直接执行
vnode.componentInstance = cache[key].componentInstance
,组件实例会从cache
对象中寻找
render () {
const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
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对象中寻找
vnode.componentInstance = cache[key].componentInstance
// make current key freshest
remove(keys, key)
// 在删除的时候会有用到keys
keys.push(key)
} else {
// delay setting the cache until update
this.vnodeToCache = vnode
this.keyToCache = key
}
vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
总结
-
keep-alive
缓存多级路由,主要思路根据路由的meta
标识,然后在App.vue
组件中keep-alive
包裹router-view
路由标签,我们通过全局store
变量去控制includes
判断当前路由是否该被缓存,同时需要监听路由判断是否有需要缓存,通过设置全局cachePage
去控制路由的缓存 - 优化
store
数据流代码,可以减少代码,提高的代码模块的复用度 - 当一个组件被缓存时,加载该缓存组件时是会触发
activated
钩子,当从一个缓存组件离开时,会触发deactivated
,在特殊场景可以在这两个钩子函数上做些事情 - 简略剖析
keep-alive
实现原理,从默认插槽中获取组件实例,然后会根据是否有name
,include
以及exclude
,判断是否每次返回vnode
,如果include
有需要缓存的组件,则会从cache
对象中获取实例对vnode.componentInstance
进行重新赋值优先从缓存对象中获取 - 本文示例 code example[2]
参考资料
[1]从官方文档知道: https://v2.cn.vuejs.org/v2/api/#keep-alive
[2]code example: https://github.com/maicFir/lessonNote/tree/master/vue/05-keep-alive
如果想学习更多H5游戏, webpack,node,gulp,css3,javascript,nodeJS,canvas数据可视化等前端知识和实战,欢迎在《趣谈前端》加入我们的技术群一起学习讨论,共同探索前端的边界。