add 新增 KeepAliveTabs.tsx 支持 keep-alive 功能

This commit is contained in:
疯狂的狮子Li 2026-09-10 12:31:30 +08:00
parent 912086075a
commit 693fd61e5d
4 changed files with 136 additions and 3 deletions

View File

@ -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 <HomePage />;
if (pathname === '/user/profile') return <ProfilePage />;
return undefined;
}
/**
* React 19 <Activity> ( 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]) => (
<Activity key={`${key}:${entry.version}`} mode={key === activeKey ? 'visible' : 'hidden'}>
<UNSAFE_LocationContext.Provider value={{ location: entry.location }}>
{renderStaticPage(entry.location.pathname) ?? <DynamicPage />}
</UNSAFE_LocationContext.Provider>
</Activity>
))}
</>
);
}

View File

@ -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 页面切换过渡动画 不支持的浏览器自动降级为无动画 */}
<ViewTransition>
<Outlet key={`${location.pathname}${location.search}:${refreshKey}`} />
</ViewTransition>
{layoutSettings.tagsView ? (
/* Activity 标签页缓存 切换标签隐藏而非卸载 保留页面状态(对标 Vue keep-alive) */
<ViewTransition>
<KeepAliveTabs refreshKey={refreshKey} />
</ViewTransition>
) : (
<ViewTransition>
<Outlet key={`${location.pathname}${location.search}:${refreshKey}`} />
</ViewTransition>
)}
</ProLayout>
<LayoutSettings
open={settingsOpen}

View File

@ -59,6 +59,42 @@ function normalizeTags(tags: TagViewItem[]) {
return tags.some(item => 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<string, CachedPageEntry>();
/** 上一次渲染看到的刷新计数器 用于识别刷新动作 */
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<TagsViewState>((set, get) => ({
tags: [homeTag, ...readPersistedTags().filter(item => item.key !== homeTag.key)],
fullscreen: false,
@ -82,6 +118,7 @@ export const useTagsViewStore = create<TagsViewState>((set, get) => ({
},
resetTags: () => {
localStorage.removeItem(tagsStorageKey);
cachedPages.clear();
set({ tags: [homeTag], fullscreen: false });
},
setFullscreen: fullscreen =>

15
src/types/react-router-dom.d.ts vendored Normal file
View File

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