# 多标签页

在后台管理系统中,多标签页是一个很常见的功能,用来记住用户已打开的页面并进行缓存以帮助用户快速操作。

1、实现原理

简易多标签页样式和交互效果主要借助UI框架的Tab切换组件,实现原理很简单,通过一个路由信息列表来渲染和维护标签列表,通过组件内的路由导航守卫,监听路由的更改,如果此路由是缓存路由,通过路由表中的keepAlive字段判断,并且不在标签页中就把当前的路由信息添加进去,如果存在就调到此标签,并通过路由元信息中的 path 进行点击切换跳转,删除时如果删除当前页就重新跳到第一个。

2、页面缓存

在用户打开页面时把页面添加到标签页中,然后对所有的标签页进行缓存,我们借助vue的keep-alive进行实现,但是有一个问题,标签页页面缓存后关闭页面再次打开还是被缓存,显然,这是不符合的,我们需要在标签页中切换时是缓存的,当关闭页面重新打开后缓存被清除,再重新缓存,这也是标签页最基础的功能。

如果要实现上述缓存功能,我们需要使用keep-alive的include方法结合vuex来实现,打开页面时把路由name添加到vuex中,关闭标签页时删除,keep-alive的include方法就能识别路由的name实现动态缓存。

这使用keep-alive缓存一级路由时是没有问题的,但是如果缓存多级路由就会出现问题,我们渲染二级时使用router-view来显示子路由,每次切换一级路由时,因为router-view没有名称所以切换时被刷新,导致router-view下面的子路由也无法被缓存,解决这个只需要给router-view组件添加name,然后在一级路由include缓存列表里加上这个名字,不能加到vuex中会失效。

MulTab组件实现:

<template>
  <div class="tab">
    <a-tabs v-model:active-key="activeKey" type="card-gutter" :editable="true" @tab-click="selectRoute" @delete="deleteRoute" >
      <a-tab-pane v-for="page in state.pageList" :key="page.path" :title="(page?.meta?.title as string)" :closable="state.pageList.length > 1"></a-tab-pane>
    </a-tabs>
  </div>
</template>

<script setup lang="ts">
/**
 * 简易多标签页
 * @desc 头部多标签页展示
 * 默认在路由中开启keepAlive属性就会缓存组件,为了不让页面一直被缓存,重新打开后还是被缓存,
 * 通过cacheList配合include属性控制让其再关闭标签页后就不在缓存,重新打开可以刷新
 * @author changz
 * @example 调用示例
 * <MultiTab></MultiTab>
 * */

import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute, onBeforeRouteUpdate, type RouteLocationNormalizedLoaded } from 'vue-router'
import { usePublicStore } from '@/stores/modules/public'

const publicStore = usePublicStore()
const router = useRouter()
const route = useRoute()

const activeKey = ref('') // 被选中tab

interface State {
  pageList: RouteLocationNormalizedLoaded[]
}
const state = reactive<State>({
  pageList: []
})

onMounted(() => {
  const deepRoute = Object.assign({}, route)
  getCurrentTabByRoute(deepRoute)
})

// 监听当前路由更改
onBeforeRouteUpdate((to, from, next) => {
  getCurrentTabByRoute(to)
  next()
})

// 获取当前Tab
const getCurrentTabByRoute = (route: RouteLocationNormalizedLoaded) => {
  activeKey.value = route.path
  const pathList = state.pageList.map(item => item.path)
  if (!pathList.includes(route.path)) {
    state.pageList.push(route)
    setTabsCache()
  }
}

// 删除Tab
const deleteRoute = (key: string | number) => {
  state.pageList = state.pageList.filter(page => page.path !== key)
  setTabsCache()
  // 删除当前页时自动跳转到最后一个
  if (activeKey.value === key) {
    const pathList = state.pageList.map(item => item.path)
    activeKey.value = pathList[pathList.length - 1]
    selectRoute()
  }
}

// 设置缓存
const setTabsCache = () => {
  const cacheList = state.pageList.filter(item => item.meta.keepAlive).map(item => item.name)
  publicStore.cacheList = cacheList as string[]
}

const selectRoute = () => {
  router.push({ path: activeKey.value })
}
</script>
<style lang="less" scoped>
.tab {
  width: 100%;
  padding: 8px 16px;
  background-color: #ffffff;
  border-top: 1px solid #f2f2f2;
  border-bottom: 1px solid #f1f1f1;
}

// 去除tab默认样式
:deep(.arco-tabs-nav::before) {
  display: none;
}
:deep(.arco-tabs-content) {
  display: none;
}
:deep(.arco-tabs-tab-active) {
  border-bottom: 1px solid var(--color-neutral-3);
}

:deep(.arco-tabs-nav-type-card-gutter) {
  .arco-tabs-tab-active {
    border-bottom: 1px solid var(--color-neutral-3);
  }
  .arco-tabs-tab-active:hover {
    border-bottom: 1px solid var(--color-neutral-3);
  }
}
</style>

路由表:

import { shallowRef } from 'vue'
import BasicLayout from '@/layouts/BasicLayout.vue'
import RouteView from '@/layouts/RouteView.vue'
import { type RouteRecordRaw } from 'vue-router'

/**
 * @desc 权限路由表
 * 路由分为需要登录的不需要登录的
 * 不需要登录的放在基础路由里,如登录、注册、活动页,需要登录的放在权限路由里,
 * 白名单页面放在基础路由里,如活动页,通过meta字段isWhite区分登录注册页
 * 需要登录的又分为需要权限控制和不需要权限控制的,通过meta字段isAuth进行判断需不需要权限判断
 * 在导航栏显示的路由统一放在根路由下走基础布局
 * 不在导航栏显示的可以放在根路由外面走其他布局,比如个人中心
 * 或者在根路由走基础布局,通过hidden自动控制,比如详情页
 * keepAlive是否缓存该组件,缓存必须在每一层router-view加keep-alive才会生效
 * permission为权限id,全局必须保持唯一
 * */

const HomeComponent = () => import(/* webpackChunkName: 'home' */ '@/views/home/index.vue')
const Workbench = () => import(/* webpackChunkName: 'workbench' */ '@/views/workbench/index.vue')
const MapCharts = () => import(/* webpackChunkName: 'map-charts' */ '@/views/map-charts/index.vue')

// 权限路由
export const asyncRouterMap: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Index',
    component: shallowRef(BasicLayout),
    meta: { hidden: false, keepAlive: false, isAuth: false },
    children: [
      {
        path: '/home',
        name: 'Home',
        component: HomeComponent,
        meta: { title: '主页', icon: 'icon-apps', hidden: false, keepAlive: true, isAuth: true, permission: 'home' }
      },
      {
        path: '/dashboard',
        name: 'Dashboard',
        component: shallowRef(RouteView),
        meta: { title: '仪表盘', icon: 'icon-dashboard', hidden: false, keepAlive: true, isAuth: true, permission: 'dashboard' },
        children: [
          {
            path: '/workbench',
            name: 'Workbench',
            component: Workbench,
            meta: { title: '工作台', icon: 'icon-common', hidden: false, keepAlive: false, isAuth: true, permission: 'workbench' }
          },
          {
            path: '/map-charts',
            name: 'MapCharts',
            component: MapCharts,
            meta: { title: '实时监控', icon: 'icon-computer', hidden: false, keepAlive: true, isAuth: true, permission: 'map_charts' }
          }
        ]
      }
    ]
  }
]

router-view组件:

<template>
  <!-- 每个路由只缓存当前的子路由 -->
  <RouterView v-slot="{ Component }">
    <KeepAlive :include="publicStore.cacheList">
      <component :is="Component"/>
    </KeepAlive>
  </RouterView>
</template>

<script setup lang="ts" name="RouteView">
/**
 * @desc 子路由模板
 * 用于包含子路由但没有页面的组件上,只用来渲染子组件内容
 * 设置name用来防止在父级路由添加缓存时刷新缓存页
 * 一般多层级菜单缓存失效都是因为RouterView模板是动态的,切换时直接刷新了无法缓存
 * */
import { usePublicStore } from '@/stores/modules/public'
const publicStore = usePublicStore()
</script>

一级路由基础布局组件:

<template>
  <a-spin dot :loading="publicStore.pageLoad" :style="{ width: '100%' }" tip="加载中...">
    <a-layout class="basic">
      <!-- 侧边导航栏 start -->
      <a-layout-sider>
      </a-layout-sider>
      <!-- 侧边导航栏 end -->
      <a-layout>
        <!-- 头部 start -->
        <a-layout-header>
          <GlobalHeader @COLLAPSE_EVENT="onCollapse"></GlobalHeader>
        </a-layout-header>
        <!-- 头部 end -->

        <a-layout class="basic-layout">
          <MultiTab></MultiTab>
          <!-- <Breadcrumb></Breadcrumb> -->
          <div class="layout-content">
            <!-- 路由缓存,只针对当前子路由进行缓存 -->
            <!-- RouteView 子路由模板名称,防止刷新子路由 -->
            <RouterView v-slot="{ Component }">
              <!-- <Transition name="fade" mode="out-in" appear> -->
                <KeepAlive :include="['RouteView', ...publicStore.cacheList]">
                  <component :is="Component"></component>
                </KeepAlive>
              <!-- </Transition> -->
            </RouterView>
          </div>
          <!-- <a-layout-footer>Footer</a-layout-footer> -->
        </a-layout>
      </a-layout>
    </a-layout>
  </a-spin>
</template>

<script setup lang="ts" name="BasicLayout">
/**
 * @description 侧栏菜单布局
 * */

import { ref, reactive } from 'vue'
import { usePublicStore } from '@/stores/modules/public'

import MultiTab from '@/components/MultiTab.vue'

const publicStore = usePublicStore()
</script>