likes
comments
collection
share

超细的tab标签页缓存方案(Vue2/Vue3)

作者站长头像
站长
· 阅读数 41

超细tab标签页缓存应该具备

  • 简单配置就能缓存页面

  • 支持标签页刷新

  • 一键关闭其他标签页

  • 地址跳转自动打开或切换对应标签页

  • 保持滚动位置

  • 支持多级缓存

  • 支持手动删除标签页缓存

  • 包含 Vue2 和 Vue3 两种实现方案

快捷入口

vue2 demo: xiaocheng555.github.io/vue-tabs-ca… (PC打开食用更佳)

vue3 demo: xiaocheng555.github.io/vue-tabs-ca… (PC打开食用更佳)

代码: github.com/xiaocheng55…

效果图:

超细的tab标签页缓存方案(Vue2/Vue3)

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绑定 (如下),改变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
      })
    }
  }
}

完整代码

超细的tab标签页缓存方案(Vue2/Vue3)

多级缓存

Demo中tab标签页处于一级缓存,在它下面也可以做二级缓存。写法跟正常的 keep-alive 缓存写法一样(如下代码),二级缓存复用 cachesuseRouteCache 中对缓存的操作;配置缓存同样是在路由里设置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()

超细的tab标签页缓存方案(Vue2/Vue3)

特殊场景

有一个详情页 /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标签页

完整代码

超细的tab标签页缓存方案(Vue2/Vue3)

标签页刷新

当我使用 v-if 的刷新方案时,发现报错了,只要在 下 中加 v-if 就会报错,网上一查发现是vue3的bug,issue上有类似问题:

超细的tab标签页缓存方案(Vue2/Vue3)

这样的话 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。

超细的tab标签页缓存方案(Vue2/Vue3)

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: xiaocheng555.github.io/vue-tabs-ca… (PC打开食用更佳)

vue3 demo: xiaocheng555.github.io/vue-tabs-ca… (PC打开食用更佳)

代码: github.com/xiaocheng55…