基于AntDesignPro实现多Tab页
实现步骤
基本布局
umi会默认读取/src/layouts/index.tsx
作为全局layout,因此我们新建一个/src/layouts/index.tsx
文件
const KeepAliveLayout = () => {
return (
<div>KeepAliveLayout</div>
)
}
export default KeepAliveLayout;
效果:无论怎么切换路由都会显示这个文本
引入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;
效果:
封装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;
效果
保存路由数组
接下来,把路由信息保存到数据中:
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;
}
效果,此时已实现切换效果
实现刷新关闭
在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;
效果:
做成全局方法
把这些方法做成全局方法,组件中也能调用,这个功能使用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])
效果: