超细tab标签页缓存应该具备
- 简单配置就能缓存页面
- 支持标签页刷新
- 一键关闭其他标签页
- 地址跳转自动打开或切换对应标签页
- 保持滚动位置
- 支持多级缓存
- 支持手动删除标签页缓存
- 包含 Vue2 和 Vue3 两种实现方案
快捷入口
vue2 demo: https://xiaocheng555.github.i... (PC打开食用更佳)
vue3 demo: https://xiaocheng555.github.i... (PC打开食用更佳)
代码: https://github.com/xiaocheng5...
效果图:
Vue2 实现方案
实现缓存
毫无疑问,缓存用的是 <keep-alive>
组件,用 <keep-alive>
包裹路由组件,从而缓存tab标签页。
<keep-alive>
组件有个 include
属性,在 include
数组里添加或者移除组件名可让tab标签页添加或删除缓存;为了统一管理,把缓存的操作写到vuex里。
缓存标签页:
<keep-alive ref="alive" :include="caches">
<router-view></router-view>
</keep-alive>
操作 caches
缓存:
// src/store/cache.js
import Vue from 'vue'
export default {
namespaced: true,
state: {
caches: []
},
actions: {
// 添加缓存的路由组件
addCache ({ state, dispatch }, componentName) {
const { caches } = state
if (!componentName || caches.includes(componentName)) return
caches.push(componentName)
},
// 移除缓存的路由组件
removeCache ({ state, dispatch }, componentName) {
const { caches } = state
const index = caches.indexOf(componentName)
if (index > -1) {
return caches.splice(index, 1)[0]
}
},
// 移除缓存的路由组件的实例
async removeCacheEntry ({ dispatch }, componentName) {
const cacheRemoved = await dispatch('removeCache', componentName)
if (cacheRemoved) {
await Vue.nextTick()
dispatch('addCache', componentName)
}
}
}
}
2、缓存做成可配置
如果手动添加缓存的路由组件到 caches
里,会很繁琐且容易出错;普遍做法是,在路由元信息把需要缓存的路由设置为 keepAlive: true
,如:
{
path: '/article',
component: () => import('./views/ArticleList.vue'),
name: 'article-list',
meta: {
keepAlive: true,
title: '文章列表'
}
}
然后监听路由变化,在 $route.matched
路由记录数组拿到路由的组件实例,组件实例中就有组件的名称,再将组件的名称存到 caches
里,即可实现组件缓存。整理为一句话就是:收集缓存。
// src/App.vue
methods: {
...mapActions('cache', [
'addCache',
'removeCache'
]),
// 收集缓存(通过监听)
collectCaches () {
// 收集当前路由相关的缓存
this.$route.matched.forEach(routeMatch => {
const componentName = routeMatch.components?.default?.name
// 配置了meta.keepAlive的路由组件添加到缓存
if (routeMatch.meta.keepAlive) {
this.addCache(componentName)
} else {
this.removeCache(componentName)
}
})
}
},
watch: {
'$route.path': {
immediate: true,
handler () {
this.collectCaches()
}
}
}
实现tab标签页
新增、切换标签页
tab标签与路由是一一对应的,一个路由对应一个tab标签,所以将tab标签的key值与路由记录的路径做映射(此处的路径path与路由配置的path是一样的,如路由配置了 /detail/:id
,路由记录的路径就是 /detail/:id
, 而不会是真实路径 /detail/10
)。
之后,通过监听路由,获取当前路由记录的路径作为key值,通过key值判断tab标签页是否存在,存在则切换到该tab标签页,不存在则创建新的tab标签页。其中tab标签页的标题是配置在路由 meta.title
上,同时记录当前路由 path、query、params、hash
,后续切换tab时根据这些参数做跳转,还有componentName是用来记录或清除路由缓存的。
<template>
<div class="layout-tabs">
<el-tabs
type="border-card"
v-model="curTabKey"
closable
@tab-click="clickTab"
@tab-remove="removeTab">
<el-tab-pane
v-for="item in tabs"
:label="item.title"
:name="item.tabKey"
:key="item.tabKey">
<template slot="label">{{item.title}}</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
export default {
props: {
// 【根据项目修改】tab页面在路由的第几层,或者说第几层的 router-view 组件(当前项目为第二层)
tabRouteViewDepth: {
type: Number,
default: 2
},
// tab页面的key值,从route对象中取,一个key值对应一个tab页面
// 默认为matchRoute.path值
getTabKey: {
type: Function,
default: function (routeMatch/* , route */) {
return routeMatch.path
}
},
// tab页签的标题,默认从路由meta.title中获取
tabTitleKey: {
type: String,
default: 'title'
}
},
data () {
return {
tabs: [],
curTabKey: ''
}
},
methods: {
// 切换tab
changeCurTab () {
// 当前路由信息
const { path, query, params, hash, matched } = this.$route
// tab标签页路由信息:meta、componentName
const routeMatch = matched[this.tabRouteViewDepth - 1]
const meta = routeMatch.meta
const componentName = routeMatch.components?.default?.name
// 获取tab标签页信息:tabKey标签页key值;title-标签页标题;tab-存在的标签页
const tabKey = this.getTabKey(routeMatch, this.$route)
const title = String(meta[this.tabTitleKey] || '')
const tab = this.tabs.find(tab => tab.tabKey === tabKey)
if (!tabKey) { // tabKey默认为路由的name值
console.warn(`LayoutTabs组件:${path} 路由没有匹配的tab标签页,如有需要请配置tab标签页的key值`)
return
}
// 如果同一tab路径变了(例如路径为 /detail/:id),则清除缓存实例
if (tab && tab.path !== path) {
this.removeCacheEntry(componentName || '')
tab.title = ''
}
const newTab = {
tabKey,
title: tab?.title || title,
path,
params,
query,
hash,
componentName
}
tab ? Object.assign(tab, newTab) : this.tabs.push(newTab)
this.curTabKey = tabKey
}
},
watch: {
'$route.path': {
handler () {
this.changeCurTab()
},
immediate: true
}
}
}
关闭标签页,清除缓存
关闭标签页时,如果是最后一个tab标签页,则不能删除;如果删除的是其他标签页,则关闭该标签页;如果删除的是当前标签页,则关闭当前标签页并切换到最后一个标签页;最后,清除关闭后的标签页缓存。
// 移除tab
async removeTab (tabKey) {
// 剩下一个时不能删
if (this.tabs.length === 1) return
const index = this.tabs.findIndex(tab => tab.tabKey === tabKey)
if (index < -1) return
const tab = this.tabs[index]
this.tabs.splice(index, 1)
// 如果删除的是当前tab,则切换到最后一个tab
if (tab.tabKey === this.curTabKey) {
const lastTab = this.tabs[this.tabs.length - 1]
lastTab && this.gotoTab(lastTab)
}
this.removeCache(tab.componentName || '')
}
标签页刷新
我所知道的组件刷新方法有两种:
(1)key:先给组件绑定key值,通过改变key就能刷新该组件
(2)v-if:先后设置v-if的值为false和true 来刷新组件,如下
<test-component v-if="isRender"></test-component>
this.isRender = false
this.$nextTick(() => {
this.isRender = true
})
通过实践发现,key刷新会有问题。当key绑定 <router-view>(如下),改变key值虽然能刷新当前页面,但是原来的缓存依然在,也就是说一个key对应一个缓存,如果key一直在改变,就会造成缓存越堆越多。
<keep-alive>
<router-view :key="key" />
</keep-alive>
那么,只能使用v-if的方案,先来波分析:
如果非缓存的组件,使用v-if方案是可以正常刷新,但是我发现对于缓存的组件是无效的。因为 v-if=false
时,组件并没有销毁,而是缓存起来了,这就令我很头疼。不过,我还是想到了解决办法:组件 v-if=false
时,我将组件缓存清除掉,然后再设置 v-if=true
,那么组件是不是就会重新渲染了?经过实践,这个办法是可行的。写下伪代码:
<button @click="refreshTab">刷新</button>
<keep-alive :include="caches">
<router-view v-if="isRenderTab"></router-view>
</keep-alive>
export default {
methods: {
// 刷新当前tab页面
async refreshPage () {
this.isRenderTab = false
const index = this.caches.indexOf('当前组件名称')
if (index > -1) {
// 清除缓存
this.caches.splice(index, 1)
}
this.$nextTick(() => {
this.caches.push('当前组件名称') // 重新添加缓存
this.isRenderTab = true
})
}
}
}
完整代码
多级缓存
Demo中tab标签页处于一级缓存,在它下面也可以做二级缓存。写法跟正常的 keep-alive
缓存写法一样(如下代码),二级缓存复用 caches
和 useRouteCache
中对缓存的操作;配置缓存同样是在路由里设置meta的 keepAlive: true
。
<router-view v-slot="{ Component }">
<keep-alive :include="caches">
<component :is="Component" />
</keep-alive>
</router-view>
import useRouteCache from '@/hooks/useRouteCache'
const { caches } = useRouteCache()
特殊场景
有一个详情页 /detail/:id
,我希望每次打开详情页都是一个独立的标签页。举个例子,打开 /detail/1
对应一个标签页,打开 /detail/2
对应另一个标签页,Demo中是不支持的,具体可以这样实现:tab标签页的key值设置为路由的真实路径,那么每个详情页都有一个tab标签页了,为了让每个详情页的缓存都不一样,给标签页路由加上key值为 '$route.path'。但是会有一个问题,使用 removeCache
清除详情页缓存时,会将所有详情页的缓存都清除。
<layout-tabs :getTabKey="(routeMatch , route) => route.path"></layout-tabs>
<keep-alive :include="caches">
<router-view :key="$route.path">
</router-view>
</keep-alive>
保持缓存页滚动位置
分析一下需求:当离开页面时,记录当前页的滚动位置;下次再进入该页面,拿到之前记录的值并恢复滚动的位置。这里涉及两个事件:离开页面(beforeRouteLeave)、进入页面(activated)
// src/mixins/keepScroll.js
const setting = {
scroller: 'html'
}
let gobal = false
// 获取全部选项
function getOptions ($options) {
return {
...setting,
...$options.keepScroll
}
}
// 配置设置
export function configSetting (data) {
Object.assign(setting, data)
}
const keepScroll = {
methods: {
// 恢复滚动位置
restoreKeepScrollPos () {
if (gobal && !this.$options.keepScroll) return
if (!this.__pos) this.__pos = [0, 0]
const options = getOptions(this.$options)
const scroller = document.querySelector(options.scroller)
if (!scroller) {
console.warn(`keepScroll mixin: 未找到 ${options.scroller} Dom滚动容器`)
return
}
this.__scroller = scroller
scroller.scrollTop = this.__pos[0]
scroller.scrollLeft = this.__pos[1]
},
// 记录滚动位置
recordKeepScrollPos () {
if (gobal && !this.$options.keepScroll) return
if (!this.__scroller) return
const scroller = this.__scroller
this.__pos = [scroller.scrollTop, scroller.scrollLeft]
},
// 重置滚动位置
resetKeepScrollPos () {
if (gobal && !this.$options.keepScroll) return
if (!this.__scroller) return
const scroller = this.__scroller
scroller.scrollTop = 0
scroller.scrollLeft = 0
}
},
activated () {
this.restoreKeepScrollPos()
},
deactivated () {
this.resetKeepScrollPos()
},
beforeRouteLeave (to, from, next) {
this.recordKeepScrollPos()
next()
}
}
// 全局调用 Vue.use(keepScroll, setting)
function install (Vue, data = {}) {
gobal = true
Object.assign(setting, data)
Vue.mixin(keepScroll)
}
// 支持全局或局部引入
keepScroll.install = install
export default keepScroll
实现代码有点长,主要是为了支持全局引入和局部引入。
全局引用
import keepScrollMixin from './mixins/keepScroll'
Vue.use(keepScrollMixin, {
scroller: '滚动的容器' // 默认滚动容器是html
})
在组件中配置 keepScroll: true
即可:
export default {
keepScroll: true,
data () {...}
}
局部引用
import keepScrollMixin from './mixins/keepScroll'
export default {
mixins: [keepScrollMixin],
data () {...}
}
如果需要设置滚动容器的,可以局部修改:
export default {
mixins: [keepScrollMixin],
keepScroll: {
scroller: '滚动容器'
}
}
或者全局修改:
import { configKeepScroll } from './mixins/keepScroll'
configKeepScroll({
scroller: '滚动容器'
})
Vue3 实现方案
Vue3 和 Vue2 的实现方案大体上差不多,下面会简单介绍一下,想具体了解可以看源码。
实现缓存
将缓存的操作写在一个hook里,方便调用。
// src/hooks/useRouteCache.ts
import { ref, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
const caches = ref<string[]>([])
let collect = false
let cmpNames: { [index: string]: string } = {}
export default function useRouteCache () {
const route = useRoute()
// 收集当前路由相关的缓存
function collectRouteCaches () {
route.matched.forEach(routeMatch => {
const componentDef: any = routeMatch.components?.default
const componentName = componentDef?.name || componentDef?.__name
// 配置了meta.keepAlive的路由组件添加到缓存
if (routeMatch.meta.keepAlive) {
if (!componentName) {
console.warn(`${routeMatch.path} 路由的组件名称name为空`)
return
}
addCache(componentName)
} else {
removeCache(componentName)
}
})
}
// 收集缓存(通过监听)
function collectCaches () {
if (collect) {
console.warn('useRouteCache:不需要重复收集缓存')
return
}
collect = true
watch(() => route.path, collectRouteCaches, {
immediate: true
})
}
// 添加缓存的路由组件
function addCache (componentName: string | string[]) {
if (Array.isArray(componentName)) {
componentName.forEach(addCache)
return
}
if (!componentName || caches.value.includes(componentName)) return
caches.value.push(componentName)
console.log('缓存路由组件:', componentName)
}
// 移除缓存的路由组件
function removeCache (componentName: string | string[]) {
if (Array.isArray(componentName)) {
componentName.forEach(removeCache)
return
}
const index = caches.value.indexOf(componentName)
if (index > -1) {
console.log('清除缓存的路由组件:', componentName)
return caches.value.splice(index, 1)
}
}
// 移除缓存的路由组件的实例
async function removeCacheEntry (componentName: string) {
if (removeCache(componentName)) {
await nextTick()
addCache(componentName)
}
}
// 清除缓存的路由组件的实例
function clearEntry () {
caches.value.slice().forEach(key => {
removeCacheEntry(key)
})
}
return {
collectCaches,
caches,
addCache,
removeCache,
removeCacheEntry
}
}
缓存路由:
<router-view v-slot="{ Component }">
<keep-alive :include="caches">
<component :is="Component" />
</keep-alive>
</router-view>
收集缓存
// src/App.vue
import useRouteCache from '@/hooks/useRouteCache'
// 收集路由配置meta为keepAlive: ture的缓存
const { collectCaches } = useRouteCache()
collectCaches()
实现tab标签页
完整代码
标签页刷新
当我使用 v-if
的刷新方案时,发现报错了,只要在 <keep-alive> 下 <router-view> 中加 v-if
就会报错,网上一查发现是vue3的bug,issue上有类似问题:
这样的话 v-if
就不能用了,那有没有方法实现类型的效果呢?还真有:标签页点刷新时,先跳转到一个空白页,然后清除标签页的缓存,然后再跳转回来,就能达到一个刷新效果。
先配置空白路由:
{
// 空白页,刷新tab页时用来做中转
path: '/_empty',
name: '_empty',
component: Empty
}
标签页刷新:
// 刷新tab页面
async function refreshTab (tab: Tab) {
await router.push('/_empty')
removeCache(tab.componentName || '')
router.go(-1)
}
保持缓存页滚动位置
离开页面,记录滚动位置,再次进入页面,恢复滚动位置。逻辑写为hook:
// src/hooks/useKeepScroll
import { onActivated } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
let gobalScrollBox = 'html' // 全局滚动盒子
export function configKeepScroll (scrollBox: string) {
gobalScrollBox = scrollBox
}
export default function useKeepScroll (scrollBox?: string) {
let pos = [0, 0]
let scroller: HTMLElement | null
onActivated(() => {
scroller = document.querySelector(scrollBox || gobalScrollBox)
if (!scroller) {
console.warn(`useKeepScroll: 未找到 ${scrollBox || gobalScrollBox} Dom滚动容器`)
return
}
scroller.scrollTop = pos[0]
scroller.scrollLeft = pos[1]
})
onBeforeRouteLeave(() => {
if (scroller) {
pos = [scroller.scrollTop, scroller.scrollLeft]
}
})
}
页面上使用:
<script setup lang="ts">
import useKeepScroll from '@/hooks/useKeepScroll'
useKeepScroll()
</script>
补充
1、在vue3中使用 <keep-alive>
加上 <router-view>
偶尔会热更新报错,应该是 Vue3的bug。
2、Demo中详情页,删除详情页后跳转到列表页
// 跳转列表页
if (window.history.state?.back === '/article') {
router.go(-1)
} else {
router.replace('/article')
}
其中,window.history.state?.back
获取的是返回页的地址,如果上一页的地址是 /article
,使用 router.replace('/article')
跳转会产生两条 /article
的历史记录,体验不友好,所以改为 router.go(-1)
。
结尾
以上是我的一些不成熟想法,有错误或表述不清欢迎交流与指正。
再次附上地址:
vue2 demo: https://xiaocheng555.github.i... (PC打开食用更佳)
vue3 demo: https://xiaocheng555.github.i... (PC打开食用更佳)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。