Prechádzať zdrojové kódy

perf: perf the control logic of Tab (#6220)

* perf: perf the control logic of Tab

* 每个标签页Tab使用唯一的key来控制关闭打开等逻辑
* 统一函数获取tab的key
* 通过3种方式设置tab key:1、使用router query参数pageKey 2、使用路由meta参数fullPathKey设置使用fullPath或path作为key
* 单个路由可以打开多个标签页
* 如果设置fullPathKey为false,则query变更不会打开新的标签(这很实用)

* perf: perf the control logic of Tab

* perf: perf the control logic of Tab

* 测试用例适配

* perf: perf the control logic of Tab

* 解决AI提示的警告
ming4762 4 mesiacov pred
rodič
commit
3d9dba965f

+ 40 - 0
docs/src/guide/essentials/route.md

@@ -339,6 +339,10 @@ interface RouteMeta {
     | 'success'
     | 'warning'
     | string;
+  /**
+   * 路由的完整路径作为key(默认true)
+   */
+  fullPathKey?: boolean;
   /**
    * 当前路由的子级在菜单中不展现
    * @default false
@@ -502,6 +506,13 @@ interface RouteMeta {
 
 用于配置页面的徽标颜色。
 
+### fullPathKey
+
+- 类型:`boolean`
+- 默认值:`true`
+
+是否将路由的完整路径作为tab key(默认true)
+
 ### activePath
 
 - 类型:`string`
@@ -602,3 +613,32 @@ const { refresh } = useRefresh();
 refresh();
 </script>
 ```
+
+## 标签页与路由控制
+
+在某些场景下,需要单个路由打开多个标签页,或者修改路由的query不打开新的标签页
+
+每个标签页Tab使用唯一的key标识,设置Tab key有三种方式,优先级由高到低:
+
+- 使用路由query参数pageKey
+
+```vue
+<script setup lang="ts">
+import { useRouter } from 'vue-router';
+// 跳转路由
+const router = useRouter();
+router.push({
+  path: 'path',
+  query: {
+    pageKey: 'key',
+  },
+});
+```
+
+- 路由的完整路径作为key
+
+`meta` 属性中的 `fullPathKey`不为false,则使用路由`fullPath`作为key
+
+- 路由的path作为key
+
+`meta` 属性中的 `fullPathKey`为false,则使用路由`path`作为key

+ 6 - 1
packages/@core/base/typings/src/tabs.ts

@@ -1,3 +1,8 @@
 import type { RouteLocationNormalized } from 'vue-router';
 
-export type TabDefinition = RouteLocationNormalized;
+export interface TabDefinition extends RouteLocationNormalized {
+  /**
+   * 标签页的key
+   */
+  key?: string;
+}

+ 4 - 0
packages/@core/base/typings/src/vue-router.d.ts

@@ -43,6 +43,10 @@ interface RouteMeta {
     | 'success'
     | 'warning'
     | string;
+  /**
+   * 路由的完整路径作为key(默认true)
+   */
+  fullPathKey?: boolean;
   /**
    * 当前路由的子级在菜单中不展现
    * @default false

+ 2 - 2
packages/@core/ui-kit/tabs-ui/src/components/tabs-chrome/tabs.vue

@@ -40,14 +40,14 @@ const style = computed(() => {
 
 const tabsView = computed(() => {
   return props.tabs.map((tab) => {
-    const { fullPath, meta, name, path } = tab || {};
+    const { fullPath, meta, name, path, key } = tab || {};
     const { affixTab, icon, newTabTitle, tabClosable, title } = meta || {};
     return {
       affixTab: !!affixTab,
       closable: Reflect.has(meta, 'tabClosable') ? !!tabClosable : true,
       fullPath,
       icon: icon as string,
-      key: fullPath || path,
+      key,
       meta,
       name,
       path,

+ 2 - 2
packages/@core/ui-kit/tabs-ui/src/components/tabs/tabs.vue

@@ -47,14 +47,14 @@ const typeWithClass = computed(() => {
 
 const tabsView = computed(() => {
   return props.tabs.map((tab) => {
-    const { fullPath, meta, name, path } = tab || {};
+    const { fullPath, meta, name, path, key } = tab || {};
     const { affixTab, icon, newTabTitle, tabClosable, title } = meta || {};
     return {
       affixTab: !!affixTab,
       closable: Reflect.has(meta, 'tabClosable') ? !!tabClosable : true,
       fullPath,
       icon: icon as string,
-      key: fullPath || path,
+      key,
       meta,
       name,
       path,

+ 5 - 5
packages/effects/layouts/src/basic/content/content.vue

@@ -9,7 +9,7 @@ import { computed } from 'vue';
 import { RouterView } from 'vue-router';
 
 import { preferences, usePreferences } from '@vben/preferences';
-import { storeToRefs, useTabbarStore } from '@vben/stores';
+import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores';
 
 import { IFrameRouterView } from '../../iframe';
 
@@ -115,13 +115,13 @@ function transformComponent(
             :is="transformComponent(Component, route)"
             v-if="renderRouteView"
             v-show="!route.meta.iframeSrc"
-            :key="route.fullPath"
+            :key="getTabKey(route)"
           />
         </KeepAlive>
         <component
           :is="Component"
           v-else-if="renderRouteView"
-          :key="route.fullPath"
+          :key="getTabKey(route)"
         />
       </Transition>
       <template v-else>
@@ -134,13 +134,13 @@ function transformComponent(
             :is="transformComponent(Component, route)"
             v-if="renderRouteView"
             v-show="!route.meta.iframeSrc"
-            :key="route.fullPath"
+            :key="getTabKey(route)"
           />
         </KeepAlive>
         <component
           :is="Component"
           v-else-if="renderRouteView"
-          :key="route.fullPath"
+          :key="getTabKey(route)"
         />
       </template>
     </RouterView>

+ 1 - 1
packages/effects/layouts/src/basic/tabbar/tabbar.vue

@@ -30,7 +30,7 @@ const {
 } = useTabbar();
 
 const menus = computed(() => {
-  const tab = tabbarStore.getTabByPath(currentActive.value);
+  const tab = tabbarStore.getTabByKey(currentActive.value);
   const menus = createContextMenus(tab);
   return menus.map((item) => {
     return {

+ 8 - 4
packages/effects/layouts/src/basic/tabbar/use-tabbar.ts

@@ -22,7 +22,7 @@ import {
   X,
 } from '@vben/icons';
 import { $t, useI18n } from '@vben/locales';
-import { useAccessStore, useTabbarStore } from '@vben/stores';
+import { getTabKey, useAccessStore, useTabbarStore } from '@vben/stores';
 import { filterTree } from '@vben/utils';
 
 export function useTabbar() {
@@ -44,8 +44,11 @@ export function useTabbar() {
     toggleTabPin,
   } = useTabs();
 
+  /**
+   * 当前路径对应的tab的key
+   */
   const currentActive = computed(() => {
-    return route.fullPath;
+    return getTabKey(route);
   });
 
   const { locale } = useI18n();
@@ -73,7 +76,8 @@ export function useTabbar() {
 
   // 点击tab,跳转路由
   const handleClick = (key: string) => {
-    router.push(key);
+    const { fullPath, path } = tabbarStore.getTabByKey(key);
+    router.push(fullPath || path);
   };
 
   // 关闭tab
@@ -100,7 +104,7 @@ export function useTabbar() {
   );
 
   watch(
-    () => route.path,
+    () => route.fullPath,
     () => {
       const meta = route.matched?.[route.matched.length - 1]?.meta;
       tabbarStore.addTab({

+ 22 - 17
packages/stores/src/modules/tabbar.test.ts

@@ -22,12 +22,13 @@ describe('useAccessStore', () => {
     const tab: any = {
       fullPath: '/home',
       meta: {},
+      key: '/home',
       name: 'Home',
       path: '/home',
     };
-    store.addTab(tab);
+    const addNewTab = store.addTab(tab);
     expect(store.tabs.length).toBe(1);
-    expect(store.tabs[0]).toEqual(tab);
+    expect(store.tabs[0]).toEqual(addNewTab);
   });
 
   it('adds a new tab if it does not exist', () => {
@@ -38,20 +39,22 @@ describe('useAccessStore', () => {
       name: 'New',
       path: '/new',
     };
-    store.addTab(newTab);
-    expect(store.tabs).toContainEqual(newTab);
+    const addNewTab = store.addTab(newTab);
+    expect(store.tabs).toContainEqual(addNewTab);
   });
 
   it('updates an existing tab instead of adding a new one', () => {
     const store = useTabbarStore();
     const initialTab: any = {
       fullPath: '/existing',
-      meta: {},
+      meta: {
+        fullPathKey: false,
+      },
       name: 'Existing',
       path: '/existing',
       query: {},
     };
-    store.tabs.push(initialTab);
+    store.addTab(initialTab);
     const updatedTab = { ...initialTab, query: { id: '1' } };
     store.addTab(updatedTab);
     expect(store.tabs.length).toBe(1);
@@ -60,9 +63,12 @@ describe('useAccessStore', () => {
 
   it('closes all tabs', async () => {
     const store = useTabbarStore();
-    store.tabs = [
-      { fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
-    ] as any;
+    store.addTab({
+      fullPath: '/home',
+      meta: {},
+      name: 'Home',
+      path: '/home',
+    } as any);
     router.replace = vi.fn();
 
     await store.closeAllTabs(router);
@@ -157,7 +163,7 @@ describe('useAccessStore', () => {
       path: '/contact',
     } as any);
 
-    await store._bulkCloseByPaths(['/home', '/contact']);
+    await store._bulkCloseByKeys(['/home', '/contact']);
 
     expect(store.tabs).toHaveLength(1);
     expect(store.tabs[0]?.name).toBe('About');
@@ -183,9 +189,8 @@ describe('useAccessStore', () => {
       name: 'Contact',
       path: '/contact',
     };
-    store.addTab(targetTab);
-
-    await store.closeLeftTabs(targetTab);
+    const addTargetTab = store.addTab(targetTab);
+    await store.closeLeftTabs(addTargetTab);
 
     expect(store.tabs).toHaveLength(1);
     expect(store.tabs[0]?.name).toBe('Contact');
@@ -205,7 +210,7 @@ describe('useAccessStore', () => {
       name: 'About',
       path: '/about',
     };
-    store.addTab(targetTab);
+    const addTargetTab = store.addTab(targetTab);
     store.addTab({
       fullPath: '/contact',
       meta: {},
@@ -213,7 +218,7 @@ describe('useAccessStore', () => {
       path: '/contact',
     } as any);
 
-    await store.closeOtherTabs(targetTab);
+    await store.closeOtherTabs(addTargetTab);
 
     expect(store.tabs).toHaveLength(1);
     expect(store.tabs[0]?.name).toBe('About');
@@ -227,7 +232,7 @@ describe('useAccessStore', () => {
       name: 'Home',
       path: '/home',
     };
-    store.addTab(targetTab);
+    const addTargetTab = store.addTab(targetTab);
     store.addTab({
       fullPath: '/about',
       meta: {},
@@ -241,7 +246,7 @@ describe('useAccessStore', () => {
       path: '/contact',
     } as any);
 
-    await store.closeRightTabs(targetTab);
+    await store.closeRightTabs(addTargetTab);
 
     expect(store.tabs).toHaveLength(1);
     expect(store.tabs[0]?.name).toBe('Home');

+ 109 - 72
packages/stores/src/modules/tabbar.ts

@@ -1,5 +1,9 @@
 import type { ComputedRef } from 'vue';
-import type { Router, RouteRecordNormalized } from 'vue-router';
+import type {
+  RouteLocationNormalized,
+  Router,
+  RouteRecordNormalized,
+} from 'vue-router';
 
 import type { TabDefinition } from '@vben-core/typings';
 
@@ -53,23 +57,23 @@ export const useTabbarStore = defineStore('core-tabbar', {
     /**
      * Close tabs in bulk
      */
-    async _bulkCloseByPaths(paths: string[]) {
-      this.tabs = this.tabs.filter((item) => {
-        return !paths.includes(getTabPath(item));
-      });
+    async _bulkCloseByKeys(keys: string[]) {
+      const keySet = new Set(keys);
+      this.tabs = this.tabs.filter(
+        (item) => !keySet.has(getTabKeyFromTab(item)),
+      );
 
-      this.updateCacheTabs();
+      await this.updateCacheTabs();
     },
     /**
      * @zh_CN 关闭标签页
      * @param tab
      */
     _close(tab: TabDefinition) {
-      const { fullPath } = tab;
       if (isAffixTab(tab)) {
         return;
       }
-      const index = this.tabs.findIndex((item) => item.fullPath === fullPath);
+      const index = this.tabs.findIndex((item) => equalTab(item, tab));
       index !== -1 && this.tabs.splice(index, 1);
     },
     /**
@@ -102,14 +106,17 @@ export const useTabbarStore = defineStore('core-tabbar', {
      * @zh_CN 添加标签页
      * @param routeTab
      */
-    addTab(routeTab: TabDefinition) {
-      const tab = cloneTab(routeTab);
+    addTab(routeTab: TabDefinition): TabDefinition {
+      let tab = cloneTab(routeTab);
+      if (!tab.key) {
+        tab.key = getTabKey(routeTab);
+      }
       if (!isTabShown(tab)) {
-        return;
+        return tab;
       }
 
-      const tabIndex = this.tabs.findIndex((tab) => {
-        return getTabPath(tab) === getTabPath(routeTab);
+      const tabIndex = this.tabs.findIndex((item) => {
+        return equalTab(item, tab);
       });
 
       if (tabIndex === -1) {
@@ -155,10 +162,11 @@ export const useTabbarStore = defineStore('core-tabbar', {
             mergedTab.meta.newTabTitle = curMeta.newTabTitle;
           }
         }
-
+        tab = mergedTab;
         this.tabs.splice(tabIndex, 1, mergedTab);
       }
       this.updateCacheTabs();
+      return tab;
     },
     /**
      * @zh_CN 关闭所有标签页
@@ -174,65 +182,63 @@ export const useTabbarStore = defineStore('core-tabbar', {
      * @param tab
      */
     async closeLeftTabs(tab: TabDefinition) {
-      const index = this.tabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
+      const index = this.tabs.findIndex((item) => equalTab(item, tab));
 
       if (index < 1) {
         return;
       }
 
       const leftTabs = this.tabs.slice(0, index);
-      const paths: string[] = [];
+      const keys: string[] = [];
 
       for (const item of leftTabs) {
         if (!isAffixTab(item)) {
-          paths.push(getTabPath(item));
+          keys.push(item.key as string);
         }
       }
-      await this._bulkCloseByPaths(paths);
+      await this._bulkCloseByKeys(keys);
     },
     /**
      * @zh_CN 关闭其他标签页
      * @param tab
      */
     async closeOtherTabs(tab: TabDefinition) {
-      const closePaths = this.tabs.map((item) => getTabPath(item));
+      const closeKeys = this.tabs.map((item) => getTabKeyFromTab(item));
 
-      const paths: string[] = [];
+      const keys: string[] = [];
 
-      for (const path of closePaths) {
-        if (path !== tab.fullPath) {
-          const closeTab = this.tabs.find((item) => getTabPath(item) === path);
+      for (const key of closeKeys) {
+        if (key !== tab.key) {
+          const closeTab = this.tabs.find(
+            (item) => getTabKeyFromTab(item) === key,
+          );
           if (!closeTab) {
             continue;
           }
           if (!isAffixTab(closeTab)) {
-            paths.push(getTabPath(closeTab));
+            keys.push(closeTab.key as string);
           }
         }
       }
-      await this._bulkCloseByPaths(paths);
+      await this._bulkCloseByKeys(keys);
     },
     /**
      * @zh_CN 关闭右侧标签页
      * @param tab
      */
     async closeRightTabs(tab: TabDefinition) {
-      const index = this.tabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
+      const index = this.tabs.findIndex((item) => equalTab(item, tab));
 
       if (index !== -1 && index < this.tabs.length - 1) {
         const rightTabs = this.tabs.slice(index + 1);
 
-        const paths: string[] = [];
+        const keys: string[] = [];
         for (const item of rightTabs) {
           if (!isAffixTab(item)) {
-            paths.push(getTabPath(item));
+            keys.push(item.key as string);
           }
         }
-        await this._bulkCloseByPaths(paths);
+        await this._bulkCloseByKeys(keys);
       }
     },
 
@@ -243,15 +249,14 @@ export const useTabbarStore = defineStore('core-tabbar', {
      */
     async closeTab(tab: TabDefinition, router: Router) {
       const { currentRoute } = router;
-
       // 关闭不是激活选项卡
-      if (getTabPath(currentRoute.value) !== getTabPath(tab)) {
+      if (getTabKey(currentRoute.value) !== getTabKeyFromTab(tab)) {
         this._close(tab);
         this.updateCacheTabs();
         return;
       }
       const index = this.getTabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(currentRoute.value),
+        (item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value),
       );
 
       const before = this.getTabs[index - 1];
@@ -278,7 +283,7 @@ export const useTabbarStore = defineStore('core-tabbar', {
     async closeTabByKey(key: string, router: Router) {
       const originKey = decodeURIComponent(key);
       const index = this.tabs.findIndex(
-        (item) => getTabPath(item) === originKey,
+        (item) => getTabKeyFromTab(item) === originKey,
       );
       if (index === -1) {
         return;
@@ -291,12 +296,12 @@ export const useTabbarStore = defineStore('core-tabbar', {
     },
 
     /**
-     * 根据路径获取标签页
-     * @param path
+     * 根据tab的key获取tab
+     * @param key
      */
-    getTabByPath(path: string) {
+    getTabByKey(key: string) {
       return this.getTabs.find(
-        (item) => getTabPath(item) === path,
+        (item) => getTabKeyFromTab(item) === key,
       ) as TabDefinition;
     },
     /**
@@ -312,22 +317,19 @@ export const useTabbarStore = defineStore('core-tabbar', {
      * @param tab
      */
     async pinTab(tab: TabDefinition) {
-      const index = this.tabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
-      if (index !== -1) {
-        const oldTab = this.tabs[index];
-        tab.meta.affixTab = true;
-        tab.meta.title = oldTab?.meta?.title as string;
-        // this.addTab(tab);
-        this.tabs.splice(index, 1, tab);
+      const index = this.tabs.findIndex((item) => equalTab(item, tab));
+      if (index === -1) {
+        return;
       }
+      const oldTab = this.tabs[index];
+      tab.meta.affixTab = true;
+      tab.meta.title = oldTab?.meta?.title as string;
+      // this.addTab(tab);
+      this.tabs.splice(index, 1, tab);
       // 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
       const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
       // 获得固定tabs的index
-      const newIndex = affixTabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
+      const newIndex = affixTabs.findIndex((item) => equalTab(item, tab));
       // 交换位置重新排序
       await this.sortTabs(index, newIndex);
     },
@@ -372,9 +374,7 @@ export const useTabbarStore = defineStore('core-tabbar', {
       if (tab?.meta?.newTabTitle) {
         return;
       }
-      const findTab = this.tabs.find(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
+      const findTab = this.tabs.find((item) => equalTab(item, tab));
       if (findTab) {
         findTab.meta.newTabTitle = undefined;
         await this.updateCacheTabs();
@@ -419,9 +419,7 @@ export const useTabbarStore = defineStore('core-tabbar', {
      * setTabTitle(tab, computed(() => t('common.dashboard')));
      */
     async setTabTitle(tab: TabDefinition, title: ComputedRef<string> | string) {
-      const findTab = this.tabs.find(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
+      const findTab = this.tabs.find((item) => equalTab(item, tab));
 
       if (findTab) {
         findTab.meta.newTabTitle = title;
@@ -462,17 +460,15 @@ export const useTabbarStore = defineStore('core-tabbar', {
      * @param tab
      */
     async unpinTab(tab: TabDefinition) {
-      const index = this.tabs.findIndex(
-        (item) => getTabPath(item) === getTabPath(tab),
-      );
-
-      if (index !== -1) {
-        const oldTab = this.tabs[index];
-        tab.meta.affixTab = false;
-        tab.meta.title = oldTab?.meta?.title as string;
-        // this.addTab(tab);
-        this.tabs.splice(index, 1, tab);
+      const index = this.tabs.findIndex((item) => equalTab(item, tab));
+      if (index === -1) {
+        return;
       }
+      const oldTab = this.tabs[index];
+      tab.meta.affixTab = false;
+      tab.meta.title = oldTab?.meta?.title as string;
+      // this.addTab(tab);
+      this.tabs.splice(index, 1, tab);
       // 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
       const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
       // 获得固定tabs的index,使用固定tabs的下一个位置也就是活动tabs的第一个位置
@@ -605,11 +601,49 @@ function isTabShown(tab: TabDefinition) {
 }
 
 /**
- * @zh_CN 获取标签页路径
+ * 从route获取tab页的key
  * @param tab
  */
-function getTabPath(tab: RouteRecordNormalized | TabDefinition) {
-  return decodeURIComponent((tab as TabDefinition).fullPath || tab.path);
+function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) {
+  const {
+    fullPath,
+    path,
+    meta: { fullPathKey } = {},
+    query = {},
+  } = tab as RouteLocationNormalized;
+  // pageKey可能是数组(查询参数重复时可能出现)
+  const pageKey = Array.isArray(query.pageKey)
+    ? query.pageKey[0]
+    : query.pageKey;
+  let rawKey;
+  if (pageKey) {
+    rawKey = pageKey;
+  } else {
+    rawKey = fullPathKey === false ? path : (fullPath ?? path);
+  }
+  try {
+    return decodeURIComponent(rawKey);
+  } catch {
+    return rawKey;
+  }
+}
+
+/**
+ * 从tab获取tab页的key
+ * 如果tab没有key,那么就从route获取key
+ * @param tab
+ */
+function getTabKeyFromTab(tab: TabDefinition): string {
+  return tab.key ?? getTabKey(tab);
+}
+
+/**
+ * 比较两个tab是否相等
+ * @param a
+ * @param b
+ */
+function equalTab(a: TabDefinition, b: TabDefinition) {
+  return getTabKeyFromTab(a) === getTabKeyFromTab(b);
 }
 
 function routeToTab(route: RouteRecordNormalized) {
@@ -617,5 +651,8 @@ function routeToTab(route: RouteRecordNormalized) {
     meta: route.meta,
     name: route.name,
     path: route.path,
+    key: getTabKey(route),
   } as TabDefinition;
 }
+
+export { getTabKey };