跳到主要内容

基于AntDesignPro实现多Tab页

实现步骤

基本布局

umi会默认读取/src/layouts/index.tsx作为全局layout,因此我们新建一个/src/layouts/index.tsx文件

const KeepAliveLayout = () => {
return (
<div>KeepAliveLayout</div>
)
}

export default KeepAliveLayout;

效果:无论怎么切换路由都会显示这个文本

image-20240826092118467

引入Tab组件

import { Tabs } from 'antd';

const KeepAliveLayout = () => {
return (
<Tabs
items={[{
key: 'tab1',
label: 'tab1',
children: (
<div>tab1</div>
)
}, {
key: 'tab2',
label: 'tab2',
children: (
<div>tab2</div>
)
}]}
/>
)
}

export default KeepAliveLayout;

效果:

image-20240826092531206

封装Hooks

封装一个hooks,获取当前匹配到的路由信息,以及组件实例,

  • 通过umi内置的useSelectedRoutes这个api,获取所有匹配到的路由。

  • 通过useOutlet获取匹配到的路由组件实例

  • 通过useLocation获取当前url,

layouts/useMatchRoute.tsx:

// /src/layouts/useMatchRoute.tsx代码

import {history, IRoute, useAppData, useLocation, useOutlet, useSelectedRoutes} from '@umijs/max';
import {useEffect, useState} from 'react';

type CustomIRoute = IRoute & {
name: string;
}

interface MatchRouteType {
title: string;
pathname: string; // /user/1
children: any;
routePath: string; // /user/:id
icon?: any;
}

export function useMatchRoute() {
// 获取匹配到的路由
const selectedRoutes = useSelectedRoutes();
// 获取路由组件实例
const children = useOutlet();
// 获取所有路由
const {routes} = useAppData();
// 获取当前url
const {pathname} = useLocation();

const [matchRoute, setMatchRoute] = useState<MatchRouteType | undefined>();

// 处理菜单名称
const getMenuTitle = (lastRoute: any) => {
return lastRoute.route.name;
}

// 监听pathname变了,说明路由有变化,重新匹配,返回新路由信息
useEffect(() => {

// 获取当前匹配的路由
const lastRoute = selectedRoutes.at(-1);

if (!lastRoute?.route?.path) return;

const routeDetail = routes[(lastRoute.route as any).id];

// 如果匹配的路由需要重定向,这里直接重定向
if (routeDetail?.redirect) {
history.replace(routeDetail?.redirect);
return;
}

// 获取菜单名称
const title = getMenuTitle(lastRoute);

setMatchRoute({
title,
pathname,
children,
routePath: lastRoute.route.path,
icon: (lastRoute.route as any).icon, // icon是拓展出来的字段
});

}, [pathname])


return matchRoute;
}

layouts/index.tsx:

// /src/layouts/index.tsx
import { Tabs } from 'antd';
import { useMatchRoute } from './useMatchRoute';

const KeepAliveLayout = () => {

const matchRoute = useMatchRoute();

return (
<Tabs
items={[{
key: matchRoute?.pathname || '',
label: matchRoute?.title,
children: matchRoute?.children,
}]}
/>
)
}

export default KeepAliveLayout;

效果

image-20240826093604275

保存路由数组

接下来,把路由信息保存到数据中:

useKeepAliveTabs.tsx

// /src/layouts/useKeepAliveTabs.tsx
import { useEffect, useState } from 'react';
import { useMatchRoute } from './useMatchRoute';

export interface KeepAliveTab {
title: string;
routePath: string;
key: string; // 这个key,后面刷新有用到它
pathname: string;
icon?: any;
children: any;
}

function getKey() {
return new Date().getTime().toString();
}

export function useKeepAliveTabs() {
const [keepAliveTabs, setKeepAliveTabs] = useState<KeepAliveTab[]>([]);
const [activeTabRoutePath, setActiveTabRoutePath] = useState<string>('');

const matchRoute = useMatchRoute();

useEffect(() => {

if (!matchRoute) return;

const existKeepAliveTab = keepAliveTabs.find(o => o.routePath === matchRoute?.routePath);

// 如果不存在则需要插入
if (!existKeepAliveTab) {
setKeepAliveTabs(prev => [...prev, {
title: matchRoute.title,
key: getKey(),
routePath: matchRoute.routePath,
pathname: matchRoute.pathname,
children: matchRoute.children,
icon: matchRoute.icon,
}]);
}

setActiveTabRoutePath(matchRoute.routePath);
}, [matchRoute])


return {
keepAliveTabs,
activeTabRoutePath,
}
}

index.tsx:

// /src/layouts/index.tsx
import { Tabs } from 'antd';
import { useCallback, useMemo } from 'react';
import { history } from '@umijs/max';

import { useKeepAliveTabs } from './useKeepAliveTabs';


const KeepAliveLayout = () => {

const { keepAliveTabs, activeTabRoutePath } = useKeepAliveTabs();

const tabItems = useMemo(() => {
return keepAliveTabs.map(tab => {
return {
key: tab.routePath,
label: (
<span>
{tab.icon}
{tab.title}
</span>
),
children: (
<div
key={tab.key}
style={{ height: 'calc(100vh - 112px)', overflow: 'auto' }}
>
{tab.children}
</div>
),
closable: false,
}
})
}, [keepAliveTabs]);

const onTabsChange = useCallback((tabRoutePath: string) => {
history.push(tabRoutePath);
}, [])

return (
<Tabs
type="editable-card"
items={tabItems}
activeKey={activeTabRoutePath}
onChange={onTabsChange}
className='keep-alive-tabs'
hideAdd
/>
)
}

export default KeepAliveLayout;

修改/src/global.less样式,添加:

.keep-alive-tabs {
.ant-tabs-nav {
margin: 0;
}
}

:where(.css-dev-only-do-not-override-1e5rcno).ant-pro .ant-pro-layout .ant-pro-layout-content {
padding: 0;
}

效果,此时已实现切换效果

image-20240826093907732

实现刷新关闭

src/layouts/useKeepAliveTabs.tsx文件添加代码

新增的内容是

  • closeTab,
  • refreshTab,
  • closeOtherTab,
// /src/layouts/useKeepAliveTabs.tsx
import {useCallback, useEffect, useState} from 'react';
import {useMatchRoute} from './useMatchRoute';
import {history} from "@umijs/max";

export interface KeepAliveTab {
title: string;
routePath: string;
key: string; // 这个key,后面刷新有用到它
pathname: string;
icon?: any;
children: any;
}

function getKey() {
return new Date().getTime().toString();
}

export function useKeepAliveTabs() {
const [keepAliveTabs, setKeepAliveTabs] = useState<KeepAliveTab[]>([]);
const [activeTabRoutePath, setActiveTabRoutePath] = useState<string>('');

const matchRoute = useMatchRoute();

useEffect(() => {

if (!matchRoute) return;

const existKeepAliveTab = keepAliveTabs.find(o => o.routePath === matchRoute?.routePath);

// 如果不存在则需要插入
if (!existKeepAliveTab) {
setKeepAliveTabs(prev => [...prev, {
title: matchRoute.title,
key: getKey(),
routePath: matchRoute.routePath,
pathname: matchRoute.pathname,
children: matchRoute.children,
icon: matchRoute.icon,
}]);
}

setActiveTabRoutePath(matchRoute.routePath);
}, [matchRoute])

// 关闭tab
const closeTab = useCallback(
(routePath: string = activeTabRoutePath) => {

const index = keepAliveTabs.findIndex(o => o.routePath === routePath);
if (keepAliveTabs[index].routePath === activeTabRoutePath) {
if (index > 0) {
history.push(keepAliveTabs[index - 1].routePath);
} else {
history.push(keepAliveTabs[index + 1].routePath);
}
}
keepAliveTabs.splice(index, 1);

setKeepAliveTabs([...keepAliveTabs]);
},
[activeTabRoutePath],
);

// 关闭其他

const closeOtherTab = useCallback((routePath: string = activeTabRoutePath) => {
setKeepAliveTabs(prev => prev.filter(o => o.routePath === routePath));
}, [activeTabRoutePath]);


// 刷新tab
const refreshTab = useCallback((routePath: string = activeTabRoutePath) => {
setKeepAliveTabs(prev => {
const index = prev.findIndex(tab => tab.routePath === routePath);

if (index >= 0) {
// 这个是react的特性,key变了,组件会卸载重新渲染
prev[index].key = getKey();
}

return [...prev];
});
}, [activeTabRoutePath]);


return {
keepAliveTabs,
activeTabRoutePath,
closeTab,
refreshTab,
closeOtherTab,
}
}

实现刷新方法有个小技巧,react中组件的key属性变化,组件就会卸载重新渲染,我们只要改tab的key就行了。

修改index.tsx,让tabs支持删除功能,同时支持右键菜单,菜单中支持刷新、关闭、关闭其他功能。:

// /src/layouts/index.tsx
import {Dropdown, Tabs} from 'antd';
import {useCallback, useMemo} from 'react';
import {history} from '@umijs/max';

import {KeepAliveTab, useKeepAliveTabs} from './useKeepAliveTabs';
import {ItemType, MenuInfo} from "rc-menu/lib/interface";


enum OperationType {
REFRESH = 'refresh',
CLOSE = 'close',
CLOSEOTHER = 'close-other',
}

type MenuItemType = ItemType & { key: OperationType } | null;

const KeepAliveLayout = () => {
const {
keepAliveTabs,
activeTabRoutePath,
closeTab,
refreshTab,
closeOtherTab,
onHidden,
onShow,
} = useKeepAliveTabs();


const menuItems: MenuItemType[] = useMemo(() => [
{
label: '刷新',
key: OperationType.REFRESH,
},
keepAliveTabs.length <= 1 ? null : {
label: '关闭',
key: OperationType.CLOSE,
},
keepAliveTabs.length <= 1 ? null : {
label: '关闭其他',
key: OperationType.CLOSEOTHER,
},
].filter(o => o), [keepAliveTabs]);

const menuClick = useCallback(({key, domEvent}: MenuInfo, tab: KeepAliveTab) => {
domEvent.stopPropagation();

if (key === OperationType.REFRESH) {
refreshTab(tab.routePath);
} else if (key === OperationType.CLOSE) {
closeTab(tab.routePath);
} else if (key === OperationType.CLOSEOTHER) {
closeOtherTab(tab.routePath);
}
}, [closeOtherTab, closeTab, refreshTab]);

const renderTabTitle = useCallback((tab: KeepAliveTab) => {
return (
<Dropdown
menu={{items: menuItems, onClick: (e) => menuClick(e, tab)}}
trigger={['contextMenu']}
>
<div style={{margin: '-12px 0', padding: '12px 0'}}>
{tab.icon}
{tab.title}
</div>
</Dropdown>
)
}, [menuItems]);

const tabItems = useMemo(() => {
return keepAliveTabs.map(tab => {
return {
key: tab.routePath,
label: renderTabTitle(tab),
children: (
<div
key={tab.key}
style={{height: 'calc(100vh - 112px)', overflow: 'auto'}}
>
{tab.children}
</div>
),
closable: keepAliveTabs.length > 1,
}
})
}, [keepAliveTabs]);

const onTabEdit = (
targetKey: React.MouseEvent | React.KeyboardEvent | string,
action: 'add' | 'remove',
) => {
if (action === 'remove') {
closeTab(targetKey as string);
}
};


const onTabsChange = useCallback((tabRoutePath: string) => {
history.push(tabRoutePath);
}, [])

return (
<Tabs
type="editable-card"
items={tabItems}
activeKey={activeTabRoutePath}
onChange={onTabsChange}
className='keep-alive-tabs'
hideAdd
onEdit={onTabEdit}
/>
)
}

export default KeepAliveLayout;

效果:

image-20240826100548154

做成全局方法

把这些方法做成全局方法,组件中也能调用,这个功能使用react的useContext钩子来实现,新增layouts/context.tsx

import { createContext } from 'react'

interface KeepAliveTabContextType {
refreshTab: (path?: string) => void;
closeTab: (path?: string) => void;
closeOtherTab: (path?: string) => void;
}

const defaultValue = {
refreshTab: () => { },
closeTab: () => { },
closeOtherTab: () => { },
}


export const KeepAliveTabContext = createContext<KeepAliveTabContextType>(defaultValue);

修改index.tsx

import {KeepAliveTabContext} from './context';

const keepAliveContextValue = useMemo(
() => ({
closeTab,
closeOtherTab,
refreshTab,
}),
[closeTab, closeOtherTab, refreshTab]
);

return (
<KeepAliveTabContext.Provider value={keepAliveContextValue}>
<Tabs
type="editable-card"
items={tabItems}
activeKey={activeTabRoutePath}
onChange={onTabsChange}
className='keep-alive-tabs'
hideAdd
animated={false}
onEdit={onTabEdit}
/>
</KeepAliveTabContext.Provider>
)

在welcome中测试:

import { KeepAliveTabContext } from '@/layouts/context';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Input, Space } from 'antd';
import React, { useContext } from 'react';

const Welcome: React.FC = () => {

const {
closeTab,
closeOtherTab,
refreshTab,
} = useContext(KeepAliveTabContext);

return (
<PageContainer>
<Input />
<Space>
<Button onClick={() => { refreshTab() }}>刷新</Button>
<Button onClick={() => { closeTab() }}>关闭</Button>
<Button onClick={() => { closeOtherTab() }}>关闭其他</Button>
</Space>
</PageContainer>
);
};

export default Welcome;

发布订阅模式

在业务组件中监听onShow和onHidden事件,使用发布订阅模式,当业务组价渲染的时候,调用onShow方法,把callback注入进去。路由切换的时候,根据当前路由执行onShow对应事件方法,同时也要执行上一个路由对应的onHidde事件方法

useKeepAliveTabs.tsx

  const keepAliveShowEvents = useRef<Record<string, Array<() => void>>>({});
const keepAliveHiddenEvents = useRef<Record<string, Array<() => void>>>({});

const matchRoute = useMatchRoute();

const onShow = useCallback((cb: () => void) => {
if (!keepAliveShowEvents.current[activeTabRoutePath]) {
keepAliveShowEvents.current[activeTabRoutePath] = [];
}
keepAliveShowEvents.current[activeTabRoutePath].push(cb);
}, [activeTabRoutePath])

const onHidden = useCallback((cb: () => void) => {
if (!keepAliveHiddenEvents.current[activeTabRoutePath]) {
keepAliveHiddenEvents.current[activeTabRoutePath] = [];
}
keepAliveHiddenEvents.current[activeTabRoutePath].push(cb);
}, [activeTabRoutePath])

// 监听路由改变
useEffect(() => {

if (!matchRoute) return;

const existKeepAliveTab = keepAliveTabs.find(o => o.routePath === matchRoute?.routePath);

// 如果不存在则需要插入
if (!existKeepAliveTab) {
setKeepAliveTabs(prev => [...prev, {
title: matchRoute.title,
key: getKey(),
routePath: matchRoute.routePath,
pathname: matchRoute.pathname,
children: matchRoute.children,
icon: matchRoute.icon,
}]);
} else {
// 如果存在,触发组件的onShow的回调
(keepAliveShowEvents.current[existKeepAliveTab.routePath] || []).forEach(cb => {
cb();
});
}

// 路由改变,执行上一个tab的onHidden事件
(keepAliveHiddenEvents.current[activeTabRoutePath] || []).forEach(cb => {
cb();
});

setActiveTabRoutePath(matchRoute.routePath);
}, [matchRoute])

return {
keepAliveTabs,
activeTabRoutePath,
closeTab,
refreshTab,
closeOtherTab,
onShow,
onHidden
}

修改context.tsx

interface KeepAliveTabContextType {
refreshTab: (path?: string) => void;
closeTab: (path?: string) => void;
closeOtherTab: (path?: string) => void;
onShow: (cb: () => void) => void;
onHidden: (cb: () => void) => void;
}

const defaultValue = {
refreshTab: () => { },
closeTab: () => { },
closeOtherTab: () => { },
onShow: () => { },
onHidden: () => { },
}

测试使用welcome.tsx

useEffect(() => {
onHidden(() => {
console.log('hidden');
});
onShow(() => {
console.log('show');
});
}, [])

效果:当切换走welcome时,会打印hidden,当切回welcome的时候,会打印show

详情页参数

不同的列表行也进入详情页,详情页的路由都一样,但是路由参数不一样,切换tab并刷新当前tab,根据路由参数来刷新

route.ts:

{path: '/list', name: '表格', component: './Public/TableList'},
{
name: '信息',
icon: 'table',
path: '/list/detail/:id',
component: './Public/TableList/detail',
hideInMenu: true,
parentKey: ['/list/index'],
},

detail.tsx

import {useParams} from '@umijs/max'

export default () => {
const params = useParams();

return (
<h1>路由参数:{params.id}</h1>
)
}

TableList/index.tsx:

import {PageContainer,} from '@ant-design/pro-components';
import {history} from '@umijs/max';
import React from 'react';
import {Button} from "antd";

const TableList: React.FC = () => {
return (
<PageContainer>
{/*for循环 1-5 个a标签,然后加路径参数/1 /2 /3 */}

{[1, 2, 3, 4, 5].map((item) => (
<Button
key={item}
onClick={() => {
history.push(`/list/detail/${item}`);
}}
>
{item}
</Button>
))}


</PageContainer>
);
};

export default TableList;

改造src/layouts/useKeepAliveTabs.tsx文件

// 监听路由变化
useEffect(() => {

if (!matchRoute) return;

const existKeepAliveTab = keepAliveTabs.find(o => o.routePath === matchRoute?.routePath);

setActiveTabRoutePath(matchRoute.routePath);

// 如果不存在则需要插入
if (!existKeepAliveTab) {
setKeepAliveTabs(prev => [...prev, {
title: matchRoute.title,
key: getKey(),
routePath: matchRoute.routePath,
pathname: matchRoute.pathname,
children: matchRoute.children,
icon: matchRoute.icon,
}]);
} else if (existKeepAliveTab.pathname !== matchRoute.pathname) {
// 如果是同一个路由,但是参数不同,我们只需要刷新当前页签并且把pathname设置为新的pathname, children设置为新的children
setKeepAliveTabs(prev => {
const index = prev.findIndex(tab => tab.routePath === matchRoute.routePath);

if (index >= 0) {
prev[index].key = getKey();
prev[index].pathname = matchRoute.pathname;
prev[index].children = matchRoute.children;
}

delete keepAliveHiddenEvents.current[prev[index].routePath];
delete keepAliveShowEvents.current[prev[index].routePath];

return [...prev];
});
} else {
// 如果存在,触发组件的onShow的回调
(keepAliveShowEvents.current[existKeepAliveTab.routePath] || []).forEach(cb => {
cb();
});
}

// 路由改变,执行上一个tab的onHidden事件
(keepAliveHiddenEvents.current[activeTabRoutePath] || []).forEach(cb => {
cb();
});

}, [matchRoute])

效果:

image-20240826111154777

参考资料

手把手带你基于ant design pro 5实现多tab页(路由keepalive)