From 331da3c8c7b86da2a5057b74cf958292f80c44c9 Mon Sep 17 00:00:00 2001 From: zhongming4762 Date: Wed, 4 Feb 2026 19:29:33 +0800 Subject: [PATCH] perf: optimize the closing jump logic of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 依据tab访问历史回退上一个tab,原逻辑是返回一下个 或 上一个 --- .../shared/src/utils/__tests__/stack.test.ts | 107 ++++++++++++++++++ packages/@core/base/shared/src/utils/index.ts | 1 + packages/@core/base/shared/src/utils/stack.ts | 103 +++++++++++++++++ packages/stores/src/modules/tabbar.ts | 50 +++++--- 4 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 packages/@core/base/shared/src/utils/__tests__/stack.test.ts create mode 100644 packages/@core/base/shared/src/utils/stack.ts diff --git a/packages/@core/base/shared/src/utils/__tests__/stack.test.ts b/packages/@core/base/shared/src/utils/__tests__/stack.test.ts new file mode 100644 index 000000000..2803ef245 --- /dev/null +++ b/packages/@core/base/shared/src/utils/__tests__/stack.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createStack, Stack } from '../stack'; + +describe('stack', () => { + let stack: Stack; + + beforeEach(() => { + stack = new Stack(); + }); + + it('push & size should work', () => { + stack.push(1, 2); + + expect(stack.size).toBe(2); + }); + + it('peek should return top element without removing it', () => { + stack.push(1, 2); + + expect(stack.peek()).toBe(2); + expect(stack.size).toBe(2); + }); + + it('pop should remove and return top element', () => { + stack.push(1, 2); + + expect(stack.pop()).toBe(2); + expect(stack.size).toBe(1); + expect(stack.peek()).toBe(1); + }); + + it('pop on empty stack should return undefined', () => { + expect(stack.pop()).toBeUndefined(); + expect(stack.peek()).toBeUndefined(); + }); + + it('clear should remove all elements', () => { + stack.push(1, 2); + + stack.clear(); + + expect(stack.size).toBe(0); + expect(stack.peek()).toBeUndefined(); + }); + + it('toArray should return a shallow copy', () => { + stack.push(1, 2); + + const arr = stack.toArray(); + arr.push(3); + + expect(stack.size).toBe(2); + expect(stack.toArray()).toEqual([1, 2]); + }); + + it('dedup should remove existing item before push', () => { + stack.push(1, 2, 1); + + expect(stack.toArray()).toEqual([2, 1]); + expect(stack.size).toBe(2); + }); + + it('dedup = false should allow duplicate items', () => { + const s = new Stack(false); + + s.push(1, 1, 1); + + expect(s.toArray()).toEqual([1, 1, 1]); + expect(s.size).toBe(3); + }); + + it('remove should delete all matching items', () => { + stack.push(1, 2, 1); + + stack.remove(1); + + expect(stack.toArray()).toEqual([2]); + expect(stack.size).toBe(1); + }); + + it('maxSize should limit stack capacity', () => { + const s = new Stack(true, 3); + + s.push(1, 2, 3, 4); + + expect(s.toArray()).toEqual([2, 3, 4]); + expect(s.size).toBe(3); + }); + + it('dedup + maxSize should work together', () => { + const s = new Stack(true, 3); + + s.push(1, 2, 3, 2); // 去重并重新入栈 + + expect(s.toArray()).toEqual([1, 3, 2]); + expect(s.size).toBe(3); + }); + + it('createStack should create a stack instance', () => { + const s = createStack(true, 2); + + s.push(1, 2, 3); + + expect(s.toArray()).toEqual([2, 3]); + }); +}); diff --git a/packages/@core/base/shared/src/utils/index.ts b/packages/@core/base/shared/src/utils/index.ts index fe8cd289f..fb9a48078 100644 --- a/packages/@core/base/shared/src/utils/index.ts +++ b/packages/@core/base/shared/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './letter'; export * from './merge'; export * from './nprogress'; export * from './resources'; +export * from './stack'; export * from './state-handler'; export * from './to'; export * from './tree'; diff --git a/packages/@core/base/shared/src/utils/stack.ts b/packages/@core/base/shared/src/utils/stack.ts new file mode 100644 index 000000000..d8f5d4af3 --- /dev/null +++ b/packages/@core/base/shared/src/utils/stack.ts @@ -0,0 +1,103 @@ +/** + * @zh_CN 栈数据结构 + */ +export class Stack { + /** + * @zh_CN 栈内元素数量 + */ + get size() { + return this.items.length; + } + /** + * @zh_CN 是否去重 + */ + private readonly dedup: boolean; + /** + * @zh_CN 栈内元素 + */ + private items: T[] = []; + + /** + * @zh_CN 栈的最大容量 + */ + private readonly maxSize?: number; + + constructor(dedup = true, maxSize?: number) { + this.maxSize = maxSize; + this.dedup = dedup; + } + + /** + * @zh_CN 清空栈内元素 + */ + clear() { + this.items.length = 0; + } + + /** + * @zh_CN 查看栈顶元素 + * @returns 栈顶元素 + */ + peek(): T | undefined { + return this.items[this.items.length - 1]; + } + + /** + * @zh_CN 出栈 + * @returns 栈顶元素 + */ + pop(): T | undefined { + return this.items.pop(); + } + + /** + * @zh_CN 入栈 + * @param items 要入栈的元素 + */ + push(...items: T[]) { + items.forEach((item) => { + // 去重 + if (this.dedup) { + const index = this.items.indexOf(item); + if (index !== -1) { + this.items.splice(index, 1); + } + } + this.items.push(item); + if (this.maxSize && this.items.length > this.maxSize) { + this.items.splice(0, this.items.length - this.maxSize); + } + }); + } + /** + * @zh_CN 移除栈内元素 + * @param itemList 要移除的元素列表 + */ + remove(...itemList: T[]) { + this.items = this.items.filter((i) => !itemList.includes(i)); + } + /** + * @zh_CN 保留栈内元素 + * @param itemList 要保留的元素列表 + */ + retain(itemList: T[]) { + this.items = this.items.filter((i) => itemList.includes(i)); + } + + /** + * @zh_CN 转换为数组 + * @returns 栈内元素数组 + */ + toArray(): T[] { + return [...this.items]; + } +} + +/** + * @zh_CN 创建一个栈实例 + * @param dedup 是否去重 + * @param maxSize 栈的最大容量 + * @returns 栈实例 + */ +export const createStack = (dedup = true, maxSize?: number) => + new Stack(dedup, maxSize); diff --git a/packages/stores/src/modules/tabbar.ts b/packages/stores/src/modules/tabbar.ts index b1b4060f2..2de11b81d 100644 --- a/packages/stores/src/modules/tabbar.ts +++ b/packages/stores/src/modules/tabbar.ts @@ -11,7 +11,9 @@ import { toRaw } from 'vue'; import { preferences } from '@vben-core/preferences'; import { + createStack, openRouteInNewWindow, + Stack, startProgress, stopProgress, } from '@vben-core/shared/utils'; @@ -47,8 +49,17 @@ interface TabbarState { * @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能 */ updateTime?: number; + /** + * @zh_CN 上一个标签页打开的标签 + */ + visitHistory: Stack; } +/** + * @zh_CN 访问历史记录最大数量 + */ +const MAX_VISIT_HISTORY = 50; + /** * @zh_CN 访问权限相关 */ @@ -62,6 +73,7 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs = this.tabs.filter( (item) => !keySet.has(getTabKeyFromTab(item)), ); + this.visitHistory.remove(...keys); await this.updateCacheTabs(); }, @@ -166,6 +178,8 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs.splice(tabIndex, 1, mergedTab); } this.updateCacheTabs(); + // 添加访问历史记录 + this.visitHistory.push(tab.key as string); return tab; }, /** @@ -174,6 +188,8 @@ export const useTabbarStore = defineStore('core-tabbar', { async closeAllTabs(router: Router) { const newTabs = this.tabs.filter((tab) => isAffixTab(tab)); this.tabs = newTabs.length > 0 ? newTabs : [...this.tabs].splice(0, 1); + // 设置访问历史 + this.visitHistory.retain(this.tabs.map((item) => getTabKeyFromTab(item))); await this._goToDefaultTab(router); this.updateCacheTabs(); }, @@ -249,29 +265,26 @@ export const useTabbarStore = defineStore('core-tabbar', { */ async closeTab(tab: TabDefinition, router: Router) { const { currentRoute } = router; + const currentTabKey = getTabKey(currentRoute.value); // 关闭不是激活选项卡 - if (getTabKey(currentRoute.value) !== getTabKeyFromTab(tab)) { + if (currentTabKey !== getTabKeyFromTab(tab)) { this._close(tab); this.updateCacheTabs(); + // 移除访问历史 + this.visitHistory.remove(getTabKeyFromTab(tab)); return; } - const index = this.getTabs.findIndex( - (item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value), - ); - - const before = this.getTabs[index - 1]; - const after = this.getTabs[index + 1]; - - // 下一个tab存在,跳转到下一个 - if (after) { - this._close(tab); - await this._goToTab(after, router); - // 上一个tab存在,跳转到上一个 - } else if (before) { - this._close(tab); - await this._goToTab(before, router); - } else { + if (this.getTabs.length <= 1) { console.error('Failed to close the tab; only one tab remains open.'); + return; + } + // 从访问历史记录中移除当前关闭的tab + this.visitHistory.remove(currentTabKey); + this._close(tab); + const previousTabKey = this.visitHistory.pop(); + if (previousTabKey) { + // 跳转到上一个tab + await this._goToTab(this.getTabByKey(previousTabKey), router); } }, @@ -527,11 +540,12 @@ export const useTabbarStore = defineStore('core-tabbar', { persist: [ // tabs不需要保存在localStorage { - pick: ['tabs'], + pick: ['tabs', 'visitHistory'], storage: sessionStorage, }, ], state: (): TabbarState => ({ + visitHistory: createStack(true, MAX_VISIT_HISTORY), cachedTabs: new Set(), dragEndIndex: 0, excludeCachedTabs: new Set(),