vue 不完美的多标签页解决方案

背景

多标签页多用在中后台管理系统,能够让用户同时打开多个标签页,而且不会丢失之前填写的内容,操作起来会比较方便。虽然部分开源项目有多标签页的功能,但就体验来看,算不上特别好。

目标

  1. 可以通过router.push实现打开标签页
  2. 同一路由组件可以多开并且数据能够缓存下来
  3. 不需要处理是否缓存导致的生命周期不一致的问题
  4. 多标签页可以关闭,同时KeepAlive中的缓存清除

存在的问题

要实现多标签页的缓存,最简单的方法就是用RouterView配合KeepAlive。

vue 复制代码
<RouterView v-slot="{ Component }">
  <KeepAlive>
    <component :is="Component" />
  </KeepAlive>
</RouterView>

然而,这个方案存在几个问题:

  1. 不能重复打开同一个路由,而是原有的组件被激活
  2. 组件生命周期发生变化

不能重复打开路由

如果给路由添加参数,打开第一次没有任何问题,但如果换另一个参数打开,还会是之前的页面,因为组件被缓存下来了。

例如:

新增一个路由 counter,在页面上添加RouterLink,并使用不同的参数

vue 复制代码
<template>
  <header>
    <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
    <div class="wrapper">
      <HelloWorld msg="You did it!" />
      <nav>
        <RouterLink to="/home">Home</RouterLink>
        <RouterLink to="/about">About</RouterLink>
        <RouterLink to="/counter?id=1">Counter 1</RouterLink>
        <RouterLink to="/counter?id=2">Counter 2</RouterLink>
      </nav>
    </div>
  </header>
<RouterView v-slot="{ Component }">
  <KeepAlive>
    <component :is="Component" />
  </KeepAlive>
</RouterView>
</template>

然后再Counter组件中获取id参数,分别点击Counter 1和Counter 2,会发现点击Counter 1时获取到的id是1,点击Counter 2时却没有任何变化,而且两个RouterLink同时是激活状态。

组件生命周期变化

和上一个问题有所关联,因为组件没有重新加载,在需要重新获取数据时,KeepAlive改变了组件的生命周期,添加了onActivatedonDeactivated生命周期。

添加一个组件测试生命周期:

vue 复制代码
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

<script setup>

import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount, onActivated, onDeactivated } from 'vue'

onMounted(() => { console.log("onMounted") })
onUpdated(() => { console.log("onUpdated") })
onUnmounted(() => { console.log("onUnmounted") })
onBeforeMount(() => { console.log("onBeforeMount") })
onBeforeUpdate(() => { console.log("onBeforeUpdate") })
onBeforeUnmount(() => { console.log("onBeforeUnmount") })
onActivated(() => { console.log("onActivated") })
onDeactivated(() => { console.log("onDeactivated") })
</script>

<style>
@media (min-width: 1024px) {
  .about {
    min-height: 100vh;
    display: flex;
    align-items: center;
  }
}
</style>

再修改App.vue

vue 复制代码
<template>
  <header>
    <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />

    <div class="wrapper">
      <HelloWorld msg="You did it!" />
      <nav>
        <RouterLink to="/home">Home</RouterLink>
        <RouterLink to="/about">About</RouterLink>
        <RouterLink to="/counter?id=1">Counter 1</RouterLink>
        <RouterLink to="/counter?id=2">Counter 2</RouterLink>
      </nav>
    </div>
  </header>

  <RouterView v-slot="{ Component }">
    <!-- <KeepAlive> -->
      <component :is="Component" />
    <!-- </KeepAlive> -->
  </RouterView>
</template>

<script setup>
import { watch } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'

const route = useRoute()

watch(route, () => {
  console.log("页面切换", route.fullPath)
})

</script>

先从Home切换到About再切换回Home再切换回About。

查看在不使用KeepAlive切换页面时候的输出,onBeforeMount -> onMounted -> onBeforeUnmount -> onUnMounted 循环

使用KeepAlive的情况,情况就复杂很多,每次切换到页面时会激活onActivated钩子,正常情况下可以通过onActivated钩子获取路由参数,重新获取数据。

问题在于:如果组件可以在缓存与不缓存中切换,在获取数据时,需要考虑是写在onMounted里还是onActivated里,写在onMounted中时如果组件会被服用,需要处理路由参数变化重新获取数据;写在onActivated里,需要考虑组件不缓存了钩子函数不会被调用的情况。

解决方案

重复打开组件 & 生命周期变化

这个问题很好解决,只需要给KeepAlive中的component加上不同的key就可以实现,key可以通过router.fullPath来计算,这样KeepAlive中就可以缓存同一个组件多次。

vue 复制代码
<RouterView v-slot="{ Component, route }">
  <KeepAlive>
    <component :is="Component" :key="route.fullPath" />
  </KeepAlive>
</RouterView>

同时,修改下Counter组件,查看生命周期

vue 复制代码
<template>
    <div> ID = {{ id }}</div>
</template>

<script setup>

import { useRoute } from 'vue-router'
import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount, onActivated, onDeactivated } from 'vue'

const route = useRoute()
const id = route.query.id

onMounted(() => { console.log(route.fullPath, "onMounted") })
onUpdated(() => { console.log(route.fullPath, "onUpdated") })
onUnmounted(() => { console.log(route.fullPath, "onUnmounted") })
onBeforeMount(() => { console.log(route.fullPath, "onBeforeMount") })
onBeforeUpdate(() => { console.log(route.fullPath, "onBeforeUpdate") })
onBeforeUnmount(() => { console.log(route.fullPath, "onBeforeUnmount") })
onActivated(() => { console.log(route.fullPath, "onActivated") })
onDeactivated(() => { console.log(route.fullPath, "onDeactivated") })
</script>

会发现,虽然是同一个组件,但生命周期也独立了,也就不需要考虑路由参数变化时重新获取数据,只需要在onMounted时获取一次数据就可以了。

关闭标签页

上面的问题好像一下就解决了,但第三个目标没有实现,这也是最难的一个问题。

KeepAlive可以通过给component添加不同的key达到路由多开的效果,但是却不能用key删除,KeepAlive只能通过exclude参数使用组件名称删除缓存。

这下问题麻烦了,虽然使用不同的key多开了路由,但路由的组件名称是相同的,也就是说,就算能多开了,关闭却只能全部关闭,这种是不行的。

思索后,想到了下面的方案:

不使用KeepAlive,通过监听route,变化后就向list中添加达到打开标签页的功能,渲染list中的所有组件,然后为了让组件数据缓存下来,不能使用v-if而是使用v-show来隐藏组件。

验证方案

监听route,将访问过的路由都保存下来作为打开过的标签页,当前route作为激活的标签页

编写一个TagView组件,替代RouterView+KeepAlive,关闭的时候直接删除tagView就可以

vue 复制代码
<template>
  <div class="tags">
    <div class="tag" v-for="tagView in tagViews" :class="{ active: tagView.key === currentTagView?.key }"
     @click="router.push(tagView.route)">
      {{ tagView.title }}</div>
  </div>
  <div class="content">
    <template v-for="tagView in tagViews" :key="tagView.key">
      <Component :is="tagView.component" v-show="tagView.key === currentTagView.key" />
    </template>
  </div>
</template>

<script setup>

import { inject, ref, shallowRef, toValue, watch } from 'vue'
import { useRoute, useRouter, viewDepthKey } from 'vue-router'

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

const tagViews = ref([])
const currentTagView = ref(null)
// 参考了vue官方的RouterView, 是RouterView嵌套的深度
const routerViewDepth = inject(viewDepthKey, 0)

const routeKey = (route) => {
  return route.fullPath
}

const routeTitle = (route) => {
  // 还没有设计title,先用fullPath替代
  return route.fullPath
}

const toTagView = (route) => {
  const depth = toValue(routerViewDepth)
  return {
    title: routeTitle(route),
    key: routeKey(route),
    route: { ...route },
    component: shallowRef(route.matched[depth]?.components['default'])
  }
}

watch(route, () => {
  // 判断是否已存在,存在则不添加
  const key = routeKey(route)
  let tagView = tagViews.value.find(tagView => tagView.key === key)
  if (!tagView) {
    tagView = toTagView(route)
    tagViews.value.push(tagView)
  }
  currentTagView.value = tagView
})

</script>

<style scoped>
.tags {
  gap: 8px;
  padding: 4px;
  display: flex;
  border: 1px solid #ccc;
}

.tag {
  padding: 4px 12px;
  border: 1px solid #ccc;
}

.tag.active {
  color: #fff;
  background-color: #409EFF;
}
</style>

然后在App.vue中使用

vue 复制代码
<template>
  <div class="left-menu">
    <RouterLink to="/counter?id=1">Counter 1</RouterLink>
    <RouterLink to="/counter?id=2">Counter 2</RouterLink>
  </div>
  <div class="right-content">
    <TagView />
  </div>
</template>

<script setup>
import { watch } from 'vue'
import TagView from './components/TagView.vue'
import { RouterLink, useRoute } from 'vue-router'

const route = useRoute()

watch(route, () => {
  console.log("页面切换", route.fullPath)
})

</script>


<style scoped>
.left-menu {
  display: flex;
  padding: 8px;
  width: 220px;
  border: 1px solid #ccc;
  flex-direction: column;
}

.right-content {
  flex: 1;
  padding: 8px;
}
</style>

样式随便写的,明白意思就好。

可以自由切换标签页,并且填写的内容依然保留。

优点:编写起来很简单

缺点:之前的组件一直保留,打开的页面多了可能会卡

总结:也算一种可行的方案,但要注意页面不能太多

之前的组件只是display: none了

可能是优化

上面其实解决了最大的问题,但是还可以优化一下,可以利用KeepAlive卸载dom并缓存。

基于上面的方案,在Component外面再套一层KeepAlive,然后将v-show改成v-if。

vue 复制代码
<template>
  <div class="tags">
    <div class="tag" v-for="tagView in tagViews" :class="{ active: tagView.key === currentTagView?.key }"
      @click="router.push(tagView.route)">
      {{ tagView.title }}</div>
  </div>
  <div class="content">
    <template v-for="tagView in tagViews" :key="tagView.key">
      <KeepAlive>
        <Component :is="tagView.component" v-if="tagView.key === currentTagView.key" />
      </KeepAlive>
    </template>
  </div>
</template>

这样就解决了打开页面太多可能会导致的性能问题,但是在DevTool中就会看到很多个KeepAlive了,这也是一种取舍吧。

总结

上面的解决方案并不完美,要么容易影响性能,要么可能会影响开发(多个KeepAlive在DevTool里),要完美的话估计只能自己实现一个KeepAlive了。

相关推荐
医疗信息化王工3 小时前
医院自律端系统——预警处置模块全栈实战(ASP.NET Core + Vue3 + Quartz 定时调度)
mysql·postgresql·vue·asp.net core·quartz
大大杰哥6 小时前
Vue2学习(1)--了解基本方法与概念
javascript·学习·vue
Agatha方艺璇1 天前
前端开发技术复习笔记
vue·bootstrap·css3·html5·web
小葛要努力1 天前
创建vue2项目
程序人生·vue
七仔啊1 天前
基于海康门禁的人员计数系统
vue
步十人2 天前
【Vue3】前置知识简单概述(包括ES6核心语法,模块化ESM以及npm基础)
arcgis·npm·vue·es6
有梦想的程序星空3 天前
【环境配置】Vue3项目离线化本地部署echarts全攻略
前端·javascript·vue·echarts
向日的葵0063 天前
vue路由(二)
前端·javascript·vue.js·vue
小妖6664 天前
Hydration completed but contains mismatches
javascript·vue·vuepress
lianyinghhh4 天前
FlowGame 从零上手:开源 AI 工作流编排框架与 Vue 3 接入实战
python·低代码·开源·vue·rag·flowgame·ai工作流编排