BCVP.VUE3系列第九课:实现tabs标签栏

艺帆风顺 发布于 2025-04-03 34 次阅读


BCVP 开发者社区出品BCVP V3开发数字化服务化绿色化

放假不停歇,趁着假期学习下VUE3相关的内容,一方面是自己保持活力,另一方面也是工作需要,本系列是我的自学教程,如果有从0开始学习VUE3的,可以跟着一起练习下,毕竟前端我也是泥腿子出身,这一系列会使用Vite、TS、Pinia、Element-Plus等新知识点,既是查漏补缺,也是知识分享。

代码地址:

https://github.com/anjoy8/bcvp.vue3.git

这是每篇文章一节课一个分支,方便大家学习,会慢慢的将blog.admin项目进行翻新,使用的后端接口还是BlogCore。

系列文章:

第一课:项目初始化与核心知识点说明

第二课:基于泛型基类封装Axios请求

第三课:封装Axios拦截器

第四课:登录页设计

第五课:获取用户信息

第六课:获取动态菜单接口

第七课:基于布局模式实现动态菜单渲染

第八课:丰富面包屑组件

0、本文介绍

本文参考的是开源项目

https://gitee.com/HalseySpicy/Geeker-Admin/tree/template

分步骤讲解框架核心逻辑,今天的内容是:实现tabs标签栏功能,效果图:

1、设计标签数据的状态管理

还是老规矩,只要是对数据的管理和控制,就想到使用状态管理器Pinia来处理,新建文件srcstoresmodulestabs.ts


    import router from "@/router";import { defineStore } from "pinia";import type { TabsState, TabsMenuProps } from "@/stores/interface";import { useKeepAliveStore } from "./keepAlive";
    const keepAliveStore = useKeepAliveStore();
    export const useTabsStore = defineStore({ id: "blogvue3-tabs", state: (): TabsState => ({ tabsMenuList: [] }), actions: { // Add Tabs async addTabs(tabItem: TabsMenuProps) { if (this.tabsMenuList.every(item => item.path !== tabItem.path)) { this.tabsMenuList.push(tabItem); } }, // Remove Tabs async removeTabs(tabPath: string, isCurrent: boolean = true) { const tabsMenuList = this.tabsMenuList; // 如果是删除当前路由 if (isCurrent) { tabsMenuList.forEach((item, index) => { if (item.path !== tabPath) return; // 让页面自动加载前一个或者后一个路由页面 const nextTab = tabsMenuList[index + 1] || tabsMenuList[index - 1]; if (!nextTab) return; router.push(nextTab.path); }); } // 数据清理 this.tabsMenuList = tabsMenuList.filter(item => item.path !== tabPath); }, // Close Tabs On Side async closeTabsOnSide(path: string, type: "left" | "right") { // 关闭左侧、右侧 const currentIndex = this.tabsMenuList.findIndex(item => item.path === path); if (currentIndex !== -1) { const range = type === "left" ? [0, currentIndex] : [currentIndex + 1, this.tabsMenuList.length]; this.tabsMenuList = this.tabsMenuList.filter((item, index) => { return index 0] || index >= range[1] || !item.close; }); } keepAliveStore.setKeepAliveName(this.tabsMenuList.map(item => item.name)); }, // Close MultipleTab async closeMultipleTab(tabsMenuValue?: string) { // 关闭其他 this.tabsMenuList = this.tabsMenuList.filter(item => { return item.path === tabsMenuValue || !item.close; }); keepAliveStore.setKeepAliveName(this.tabsMenuList.map(item => item.name)); }, // Set Tabs async setTabs(tabsMenuList: TabsMenuProps[]) { this.tabsMenuList = tabsMenuList; }, // Set Tabs Title async setTabsTitle(title: string) { const nowFullPath = location.hash.substring(1); this.tabsMenuList.forEach(item => { if (item.path == nowFullPath) item.title = title; }); } },});

    内容比较简单,应该都能看的懂,就是实现了对tabsMenuList的增删改查操作,借助Pinia可以很简单的实现数据管理和维护,同时也能实现响应式。

    2、定义tabs组件

    标签组件设计比较常规,就是将用户点击的路由给存下来,然后借助element-plus官方提供的el-tabs组件,实现渲染,再增加几个小操作而已。新建srclayoutscomponentsTabsindex.vue,添加内容
      template> div class="tabs-box"> div class="tabs-menu"> el-tabs v-model="tabsMenuValue" type="card" @tab-click="tabClick" @tab-remove="tabRemove"> el-tab-pane v-for="item in tabsMenuList" :key="item.path" :label="item.title" :name="item.path" :closable="item.close"> template #label> el-icon v-show="item.icon && tabsIcon" class="tabs-icon"> component :is="item.icon">component> el-icon> {{ item.title }} template> el-tab-pane> el-tabs> MoreButton /> div> div>template>
      script setup lang="ts">import { ref, computed, watch, onMounted } from "vue";import { useRoute, useRouter } from "vue-router";import { useGlobalStore } from "@/stores/modules/global";import { useTabsStore } from "@/stores/modules/tabs";import { useAuthMenuStore } from "@/stores/modules/authMenu";import { useKeepAliveStore } from "@/stores/modules/keepAlive";import type { TabsPaneContext, TabPaneName } from "element-plus";import MoreButton from "./components/MoreButton.vue";import type { TabsMenuProps } from "@/stores/interface";
      const route = useRoute();const router = useRouter();const tabStore = useTabsStore();const authStore = useAuthMenuStore();const globalStore = useGlobalStore();const keepAliveStore = useKeepAliveStore();
      const tabsMenuValue = ref(route.fullPath);const tabsMenuList = computed(() => tabStore.tabsMenuList);const tabsIcon = computed(() => globalStore.tabsIcon);
      onMounted(() => { // initTabs();});
      // 监听路由的变化(防止浏览器后退/前进不变化 tabsMenuValue)watch( () => route.fullPath, () => { if (route.meta.isFull) return; tabsMenuValue.value = route.fullPath; const tabsParams = { icon: route.meta.icon as string, title: route.meta.title as string, path: route.fullPath, name: route.name as string, close: route.path != '/' }; tabStore.addTabs(tabsParams); route.meta.isKeepAlive && keepAliveStore.addKeepAliveName(route.name as string); }, { immediate: true });
      // 初始化需要固定的 tabsconst initTabs = () => { authStore.flatMenuListGet.forEach(item => { if (!item.IsButton) { const tabsParams = { icon: item.meta.icon, title: item.meta.title, path: item.path, name: item.name, close: route.path != '/' // 可以固定某些路由不被删除 }; tabStore.addTabs(tabsParams as TabsMenuProps); } });};
      // Tab Clickconst tabClick = (tabItem: TabsPaneContext) => { const fullPath = tabItem.props.name as string; router.push(fullPath);};
      // Remove Tabconst tabRemove = (fullPath: TabPaneName) => { const name = tabStore.tabsMenuList.filter(item => item.path == fullPath)[0].name || ""; keepAliveStore.removeKeepAliveName(name); tabStore.removeTabs(fullPath as string, fullPath == route.fullPath);};script>
      style scoped lang="scss">@import "./index.scss";style>

      可以看到,核心逻辑就是有一个watch监听,将数据给放到状态管理器Pinia中。

      然后再来个更多操作的按钮组件,将标签的增删改查操作同样在Pinia中处理:

      新建srclayoutscomponentsTabscomponentsMoreButton.vue,

        template> el-dropdown trigger="click" :teleported="false"> div class="more-button"> i :class="'iconfont icon-xiala'">i> div> template #dropdown> el-dropdown-menu> el-dropdown-item @click="refresh"> el-icon> Refresh /> el-icon>刷新 el-dropdown-item> el-dropdown-item @click="maximize"> el-icon> FullScreen /> el-icon>最大化 el-dropdown-item> el-dropdown-item divided @click="closeCurrentTab"> el-icon> Remove /> el-icon>关闭当前 el-dropdown-item> el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'left')"> el-icon> DArrowLeft /> el-icon>关闭左侧 el-dropdown-item> el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'right')"> el-icon> DArrowRight /> el-icon>关闭右侧 el-dropdown-item> el-dropdown-item @click="closeOtherTab"> el-icon> CircleClose /> el-icon>关闭其它 el-dropdown-item> el-dropdown-item @click="closeAllTab"> el-icon> FolderDelete /> el-icon>关闭所有 el-dropdown-item> el-dropdown-menu> template> el-dropdown>template>
        script setup lang="ts">import { inject, nextTick } from "vue";import { useTabsStore } from "@/stores/modules/tabs";import { useGlobalStore } from "@/stores/modules/global";import { useKeepAliveStore } from "@/stores/modules/keepAlive";import { useRoute, useRouter } from "vue-router";
        const route = useRoute();const router = useRouter();const tabStore = useTabsStore();const globalStore = useGlobalStore();const keepAliveStore = useKeepAliveStore();
        // refresh current pageconst refreshCurrentPage: Function = inject("refresh") as Function;const refresh = () => { setTimeout(() => { keepAliveStore.removeKeepAliveName(route.name as string); refreshCurrentPage(false); nextTick(() => { keepAliveStore.addKeepAliveName(route.name as string); refreshCurrentPage(true); }); }, 0);};
        // maximize current pageconst maximize = () => { globalStore.setGlobalState("maximize", true);};
        // Close Currentconst closeCurrentTab = () => { if (route.path == '/') return; tabStore.removeTabs(route.fullPath); keepAliveStore.removeKeepAliveName(route.name as string);};
        // Close Otherconst closeOtherTab = () => { tabStore.closeMultipleTab(route.fullPath);};
        // Close Allconst closeAllTab = () => { tabStore.closeMultipleTab(); router.push('/');};script>

        3、Main中引用Tabs组件

        在文件srclayoutscomponentsMainindex.vue,增加内容:

        这种组件封装还是比较简单的,通过Pinia也能实现响应式,不用太关心数据通讯,一劳永逸。

        到了这里,其实已经开发完成了,不过会有一个问题,就是刷新页面的时候,之前点击后保存的数据会丢失掉,但是你可能好奇,为何左侧的菜单没有丢失,是因为每次刷新页面,就重新拉取了一次动态菜单的权限接口,又重新渲染了一次,所以左侧菜单就不受影响。

        但是tabs标签栏不行,这数据本来就不是数据库控制的,所以就需要借助前端的持久化工具了——比如localstorage,传统的做法,在vue2的时候,就是用的vuex配合localstorage,实现数据的持久化,其实在第五课的时候,也是这么操作用户数据的,那有没有更简单的办法呢,答案是肯定的。

        4、自定义配置Pinia持久化

        有一个Pinia的插件pinia-plugin-persistedstate,可以帮助我们很简单的将状态管理中的数据做持久化处理。

        首先需要安装这个依赖,直接npm install即可,

        然后新增src/stores/index.ts,使用这个插件:

          import { createPinia } from "pinia";import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
          // pinia persistconst pinia = createPinia();pinia.use(piniaPluginPersistedstate);
          export default pinia;

          接下来对持久化方案进行配置,可以自定义存储位置,

          新增srcstoresconfigpiniaPersist.ts:

            import type { PersistedStateOptions } from "pinia-plugin-persistedstate";
            /** * @description pinia 持久化参数配置 * @param {String} key 存储到持久化的 name * @param {Array} paths 需要持久化的 state name * @return persist * */const piniaPersistConfig = (key: string, paths?: string[]) => { const persist: PersistedStateOptions = { key, storage: localStorage, // storage: sessionStorage, paths }; return persist;};
            export default piniaPersistConfig;

            这里直接配置到localstorage中了,然后在main.ts主程序入口中,更新Pinia的注册方式:

            最后在tabs.ts的标签状态管理中,引入这个配置:

              import router from "@/router";import { defineStore } from "pinia";import piniaPersistConfig from "@/stores/config/piniaPersist";
              export const useTabsStore = defineStore({ id: "blogvue3-tabs", state: (): TabsState => ({ tabsMenuList: [] }), actions: { // Add Tabs // .... 更多逻辑 }, persist: piniaPersistConfig("blogvue3-tabs")});

              现在可以试试路由跳转和页面刷新都没问题

              最后的最后,需要引用两个样式scss文件,分别是iconfont和element的。

              本文全部的提交就如图:

              下篇文章我们继续对页面优化,增加Header顶部右侧的功能ToolBar和个人信息配置,敬请期待。