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 }>; +}