乐趣区

关于vue3:超细的tab标签页缓存方案Vue2Vue3


theme: fancy

超细 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 缓存写法一样(如下代码),二级缓存复用 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()

非凡场景

有一个详情页 /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 关上食用更佳)

代码: https://github.com/xiaocheng5…

退出移动版