vue 不完美的多标签页解决方案
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250719143050567-1337997047.png" alt="image" loading="lazy"></p><p>开源地址:https://github.com/Montaro2017/vue-tag-view</p>
<h2 id="背景">背景</h2>
<p>多标签页多用在中后台管理系统,能够让用户同时打开多个标签页,而且不会丢失之前填写的内容,操作起来会比较方便。虽然部分开源项目有多标签页的功能,但就体验来看,算不上特别好。</p>
<h2 id="目标">目标</h2>
<ol>
<li>可以通过router.push实现打开标签页</li>
<li>同一路由组件可以多开并且数据能够缓存下来</li>
<li>不需要处理是否缓存导致的生命周期不一致的问题</li>
<li>多标签页可以关闭,同时KeepAlive中的缓存清除</li>
</ol>
<h2 id="存在的问题">存在的问题</h2>
<p>要实现多标签页的缓存,最简单的方法就是用RouterView配合KeepAlive。</p>
<pre><code class="language-vue"><RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
</code></pre>
<p>然而,这个方案存在几个问题:</p>
<ol>
<li>不能重复打开同一个路由,而是原有的组件被激活</li>
<li>组件生命周期发生变化</li>
</ol>
<h3 id="不能重复打开路由">不能重复打开路由</h3>
<p>如果给路由添加参数,打开第一次没有任何问题,但如果换另一个参数打开,还会是之前的页面,因为组件被缓存下来了。</p>
<p>例如:</p>
<p>新增一个路由 counter,在页面上添加RouterLink,并使用不同的参数</p>
<pre><code class="language-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>
</code></pre>
<p>然后再Counter组件中获取id参数,分别点击Counter 1和Counter 2,会发现点击Counter 1时获取到的id是1,点击Counter 2时却没有任何变化,而且两个RouterLink同时是激活状态。</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112511030-1854242471.png" alt="image" loading="lazy"></p>
<h3 id="组件生命周期变化">组件生命周期变化</h3>
<p>和上一个问题有所关联,因为组件没有重新加载,在需要重新获取数据时,KeepAlive改变了组件的生命周期,添加了<code>onActivated</code>和<code>onDeactivated</code>生命周期。</p>
<p>添加一个组件测试生命周期:</p>
<pre><code class="language-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>
</code></pre>
<p>再修改App.vue</p>
<pre><code class="language-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>
</code></pre>
<p>先从Home切换到About再切换回Home再切换回About。</p>
<p>查看在不使用KeepAlive切换页面时候的输出,<code>onBeforeMount -> onMounted -> onBeforeUnmount -> onUnMounted</code> 循环</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112529706-707873498.png" alt="image" loading="lazy"></p>
<p>使用KeepAlive的情况,情况就复杂很多,每次切换到页面时会激活onActivated钩子,正常情况下可以通过onActivated钩子获取路由参数,重新获取数据。</p>
<p>问题在于:如果组件可以在缓存与不缓存中切换,在获取数据时,需要考虑是写在onMounted里还是onActivated里,写在onMounted中时如果组件会被服用,需要处理路由参数变化重新获取数据;写在onActivated里,需要考虑组件不缓存了钩子函数不会被调用的情况。</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112540061-607531910.png" alt="image" loading="lazy"></p>
<h2 id="解决方案">解决方案</h2>
<h3 id="重复打开组件--生命周期变化">重复打开组件 & 生命周期变化</h3>
<p>这个问题很好解决,只需要给KeepAlive中的component加上不同的key就可以实现,key可以通过router.fullPath来计算,这样KeepAlive中就可以缓存同一个组件多次。</p>
<pre><code class="language-vue"><RouterView v-slot="{ Component, route }">
<KeepAlive>
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</RouterView>
</code></pre>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112553079-1919608877.png" alt="image" loading="lazy"></p>
<p>同时,修改下Counter组件,查看生命周期</p>
<pre><code class="language-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>
</code></pre>
<p>会发现,虽然是同一个组件,但生命周期也独立了,也就不需要考虑路由参数变化时重新获取数据,只需要在onMounted时获取一次数据就可以了。</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112602738-1447992649.png" alt="image" loading="lazy"></p>
<h2 id="关闭标签页">关闭标签页</h2>
<p>上面的问题好像一下就解决了,但第三个目标没有实现,这也是最难的一个问题。</p>
<p>KeepAlive可以通过给component添加不同的key达到路由多开的效果,但是却不能用key删除,KeepAlive只能通过exclude参数使用组件名称删除缓存。</p>
<p>这下问题麻烦了,虽然使用不同的key多开了路由,但路由的组件名称是相同的,也就是说,就算能多开了,关闭却只能全部关闭,这种是不行的。</p>
<p>思索后,想到了下面的方案:</p>
<p>不使用KeepAlive,通过监听route,变化后就向list中添加达到打开标签页的功能,渲染list中的所有组件,然后为了让组件数据缓存下来,不能使用v-if而是使用v-show来隐藏组件。</p>
<h3 id="验证方案">验证方案</h3>
<p>监听route,将访问过的路由都保存下来作为打开过的标签页,当前route作为激活的标签页</p>
<p>编写一个TagView组件,替代RouterView+KeepAlive,关闭的时候直接删除tagView就可以</p>
<pre><code class="language-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?.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>
</code></pre>
<p>然后在App.vue中使用</p>
<pre><code class="language-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>
</code></pre>
<p>样式随便写的,明白意思就好。</p>
<p>可以自由切换标签页,并且填写的内容依然保留。</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112621905-904813703.png" alt="image" loading="lazy"></p>
<p>优点:编写起来很简单</p>
<p>缺点:之前的组件一直保留,打开的页面多了可能会卡</p>
<p>总结:也算一种可行的方案,但要注意页面不能太多</p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112629474-672731797.png" alt="image" loading="lazy"><br>
之前的组件只是display: none了</p>
<h3 id="可能是优化">可能是优化</h3>
<p>上面其实解决了最大的问题,但是还可以优化一下,可以利用KeepAlive卸载dom并缓存。</p>
<p>基于上面的方案,在Component外面再套一层KeepAlive,然后将v-show改成v-if。</p>
<pre><code class="language-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>
</code></pre>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716113629178-1656426399.png" alt="image" loading="lazy"></p>
<p><img src="https://img2024.cnblogs.com/blog/2084018/202507/2084018-20250716112703814-2105614278.png" alt="image" loading="lazy"></p>
<p>这样就解决了打开页面太多可能会导致的性能问题,但是在DevTool中就会看到很多个KeepAlive了,这也是一种取舍吧。</p>
<h2 id="总结">总结</h2>
<p>上面的解决方案并不完美,要么容易影响性能,要么可能会影响开发(多个KeepAlive在DevTool里),要完美的话估计只能自己实现一个KeepAlive了。</p>
<blockquote>
<p>我正在使用免费的纯真社区版IP库。纯真(CZ88.NET)自2005年起一直为广大社区用户提供社区版IP地址库,只要获得纯真的授权就能免费使用,并不断获取后续更新的版本。如果有需要免费版IP库的朋友可以前往纯真的官网进行申请。<br>
纯真除了免费的社区版IP库外,还提供数据更加准确、服务更加周全的商业版IP地址查询数据。纯真围绕IP地址,基于 网络空间拓扑测绘 + 移动位置大数据 方案,对IP地址定位、IP网络风险、IP使用场景、IP网络类型、秒拨侦测、VPN侦测、代理侦测、爬虫侦测、真人度等均有近20年丰富的数据沉淀。</p>
</blockquote><br><br>
来源:https://www.cnblogs.com/montaro/p/18987155
頁:
[1]