From 693fd61e5da4c19432c836108c6df43882a36de4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=96=AF=E7=8B=82=E7=9A=84=E7=8B=AE=E5=AD=90Li?=
<15040126243@163.com>
Date: Thu, 10 Sep 2026 12:31:30 +0800
Subject: [PATCH] =?UTF-8?q?add=20=E6=96=B0=E5=A2=9E=20KeepAliveTabs.tsx=20?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=20=20keep-alive=20=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/layout/KeepAliveTabs.tsx | 73 +++++++++++++++++++++++++
src/layouts/BasicLayout.tsx | 14 ++++-
src/stores/tagsViewStore.ts | 37 +++++++++++++
src/types/react-router-dom.d.ts | 15 +++++
4 files changed, 136 insertions(+), 3 deletions(-)
create mode 100644 src/components/layout/KeepAliveTabs.tsx
create mode 100644 src/types/react-router-dom.d.ts
diff --git a/src/components/layout/KeepAliveTabs.tsx b/src/components/layout/KeepAliveTabs.tsx
new file mode 100644
index 00000000..bbe7f45c
--- /dev/null
+++ b/src/components/layout/KeepAliveTabs.tsx
@@ -0,0 +1,73 @@
+import { useLocation } from '@umijs/max';
+import { Activity } from 'react';
+// 与 @umijs/max 同一 react-router 实例 umi 构建时已做全局别名
+import { UNSAFE_LocationContext } from 'react-router-dom';
+import DynamicPage from '@/pages/dynamicPage';
+import HomePage from '@/pages/index';
+import ProfilePage from '@/pages/system/user/profile';
+import { keepAliveCache, useTagsViewStore, type CachedPageEntry } from '@/stores/tagsViewStore';
+
+interface KeepAliveTabsProps {
+ /** 刷新计数器 变化时重挂载当前激活的标签页 */
+ refreshKey: number;
+}
+
+function currentFullPath(pathname: string, search: string) {
+ return `${pathname}${search || ''}`;
+}
+
+/** 除动态路由外的两个静态布局路由 */
+function renderStaticPage(pathname: string) {
+ if (pathname === '/index' || pathname === '/') return ;
+ if (pathname === '/user/profile') return ;
+ return undefined;
+}
+
+/**
+ * 基于 React 19 的标签页缓存(对标 Vue 版 keep-alive + cachedViews)
+ * - 每个打开过的标签页对应一个 Activity 缓存实例 隐藏而非卸载 保留页面状态
+ * - 关闭标签后不再渲染对应实例(等价于从 include 中剔除) 登出时 resetTags 清空缓存
+ * - 通过 UNSAFE_LocationContext 冻结每个缓存页的路由上下文
+ * 隐藏页不会被当前路由变化触发重渲染或重新请求
+ */
+export default function KeepAliveTabs({ refreshKey }: KeepAliveTabsProps) {
+ const location = useLocation();
+ const tags = useTagsViewStore(state => state.tags);
+
+ const activeKey = currentFullPath(location.pathname, location.search);
+
+ // refreshKey 变化说明用户点击了刷新 递增当前页版本号触发 Activity 重挂载
+ keepAliveCache.noteRefresh(activeKey, refreshKey);
+
+ // 登记当前路由快照(新标签首次打开)
+ keepAliveCache.register(activeKey, {
+ pathname: location.pathname,
+ search: location.search,
+ hash: location.hash,
+ state: location.state,
+ key: location.key
+ });
+
+ // 渲染集合 = 当前路由 + 已打开标签中登记过的页面(标签由 TagsView 在导航后登记 首帧需兜底渲染当前路由)
+ const tagKeys = new Set(tags.map(tag => tag.key));
+ tagKeys.add(activeKey);
+ const entries: Array<[string, CachedPageEntry]> = [];
+ for (const key of tagKeys) {
+ const entry = keepAliveCache.get(key);
+ if (entry) {
+ entries.push([key, entry]);
+ }
+ }
+
+ return (
+ <>
+ {entries.map(([key, entry]) => (
+
+
+ {renderStaticPage(entry.location.pathname) ?? }
+
+
+ ))}
+ >
+ );
+}
diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx
index 670c6524..39a1b9c1 100644
--- a/src/layouts/BasicLayout.tsx
+++ b/src/layouts/BasicLayout.tsx
@@ -14,6 +14,7 @@ import { isHandledRequestError } from '@/api/request';
import defaultAvatar from '@/assets/images/profile.jpg';
import appLogo from '@/assets/logo/logo.png';
import ExternalLinkButton from '@/components/layout/ExternalLinkButton';
+import KeepAliveTabs from '@/components/layout/KeepAliveTabs';
import LayoutSettings from '@/components/layout/LayoutSettings';
import LocaleSelect from '@/components/layout/LocaleSelect';
import MenuSearch from '@/components/layout/MenuSearch';
@@ -489,9 +490,16 @@ export default function BasicLayout() {
/>
)}
{/* React 19.3 ViewTransition 页面切换过渡动画 不支持的浏览器自动降级为无动画 */}
-
-
-
+ {layoutSettings.tagsView ? (
+ /* Activity 标签页缓存 切换标签隐藏而非卸载 保留页面状态(对标 Vue keep-alive) */
+
+
+
+ ) : (
+
+
+
+ )}
item.key === homeTag.key) ? tags : [homeTag, ...tags];
}
+/** KeepAliveTabs 缓存页的路由快照(非响应式 模块级) */
+export interface CachedPageEntry {
+ location: {
+ pathname: string;
+ search: string;
+ hash: string;
+ state: unknown;
+ key: string;
+ };
+ /** 重挂载版本号 刷新时变化 */
+ version: number;
+}
+
+const cachedPages = new Map();
+
+/** 上一次渲染看到的刷新计数器 用于识别刷新动作 */
+let lastSeenRefreshKey: number | null = null;
+
+export const keepAliveCache = {
+ get: (key: string) => cachedPages.get(key),
+ /** 登记新访问标签的路由快照 */
+ register: (key: string, location: CachedPageEntry['location']) => {
+ if (cachedPages.has(key)) return;
+ cachedPages.set(key, { location, version: 0 });
+ },
+ /** refreshKey 变化时递增当前页版本号 触发 Activity 重挂载 */
+ noteRefresh: (activeKey: string, refreshKey: number) => {
+ if (lastSeenRefreshKey === refreshKey) return;
+ lastSeenRefreshKey = refreshKey;
+ const existing = cachedPages.get(activeKey);
+ if (existing) {
+ cachedPages.set(activeKey, { ...existing, version: existing.version + 1 });
+ }
+ }
+};
+
export const useTagsViewStore = create((set, get) => ({
tags: [homeTag, ...readPersistedTags().filter(item => item.key !== homeTag.key)],
fullscreen: false,
@@ -82,6 +118,7 @@ export const useTagsViewStore = create((set, get) => ({
},
resetTags: () => {
localStorage.removeItem(tagsStorageKey);
+ cachedPages.clear();
set({ tags: [homeTag], fullscreen: false });
},
setFullscreen: fullscreen =>
diff --git a/src/types/react-router-dom.d.ts b/src/types/react-router-dom.d.ts
new file mode 100644
index 00000000..71a258ec
--- /dev/null
+++ b/src/types/react-router-dom.d.ts
@@ -0,0 +1,15 @@
+// umi 在构建时将 react-router-dom 别名到与 @umijs/max 相同的实例
+// 这里仅补充 tsc 需要的 UNSAFE_LocationContext 类型声明 供 KeepAliveTabs 冻结缓存页的路由上下文
+declare module 'react-router-dom' {
+ import type { Context } from 'react';
+
+ export interface RouteLocation {
+ pathname: string;
+ search: string;
+ hash: string;
+ state: unknown;
+ key: string;
+ }
+
+ export const UNSAFE_LocationContext: Context<{ location: RouteLocation }>;
+}