体验地址:dbfu.github.io/antd-pro-ke…
前言
前几天在一个前端群里,看到一位群友在吐槽react。事情是这样的,他刚从vue
转react
不久,老板让他基于antd pro v5
实现多页签功能,但是他发现react不像vue一样支持keepalive
,这个他实现不了,就在群里吐槽react垃圾,不如vue好用。作为练习时长4年半的reacter,我不能忍,谁说react不支持keepalive就实现不了多页签功能,我以前就基于antd pro v4实现过这个功能,并且后面我也基于微前端实现了多页签。他既然说react实现不了,那我就实现出来给他看下,所以就有了这篇文章。
上面说的多页签就是类似于这样的功能,很多管理系统都支持这个功能。
实现思路
用过antd的,应该都用过antd Tabs组件吧(antd Tabs组件文档),这个组件支持每个tab绑定一个组件,并且在切换tab的时候,其他组件不会卸载(别把destroyInactiveTabPane
这个属性设置为true就行),回来后还能保持原来的状态。我们就用这个组件来实现菜单的多页签,路由切换的时候,拿到当前匹配的菜单路由信息,以及匹配到的组件实例,放到tabs数组中,这样就能借助antd Tabs组件实现多页签了。antd Tabs实现的源码也比较简单,后面单独出一期文章说如何实现Tabs组件。
具体实现
初始化antd pro项目
安装脚手架
npm i @ant-design/pro-cli -g
选择合适的目录,使用脚手架创建项目
pro create antd-pro-keepalive-demo
选择umi版本,我这里选umi4
? 🐂 使用 umi@4 还是 umi@3 ? (Use arrow keys)
❯ umi@4
umi@3
使用vscode打开生成的项目,然后使用pnpm安装依赖,也可以使用npm和yarn。
pnpm i
启动项目
npm start
启动成功后,访问
http://localhost:8000
会进入登录页面,到此项目初始化成功了。
实现功能
新增/src/layouts/index.tsx
文件
因为我们需要获取到当前路由对应的组件实例,所以我们要自定义一个layout,在umi4中/src/layouts.index.tsx
这个文件会被默认为全局layout。
测试layout文件是否生效
在/src/layouts/index.tsx
文件中写入下面代码测试一下效果
const KeepAliveLayout = () => {
return (
<div>KeepAliveLayout</div>
)
}
export default KeepAliveLayout;
登录进去后,无论怎么切换路由,都只显示KeepAliveLayout
这个文本。
引入tabs组件
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,获取当前匹配到的路由信息,以及组件实例
通过umi内置的useSelectedRoutes
这个api,获取所有匹配到的路由。
通过useOutlet
获取匹配到的路由组件实例
通过
useLocation
获取当前url,
// /src/layouts/useMatchRoute.tsx代码
import { IRoute, history, useAppData, useIntl, 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 { formatMessage } = useIntl();
const [matchRoute, setMatchRoute] = useState<MatchRouteType | undefined>();
// 处理菜单名称
const getMenuTitle = (lastRoute: any) => {
let curRoute = lastRoute.route;
let names = ['menu'];
while (curRoute.parentId && !curRoute.isLayout) {
if ((routes[curRoute.parentId] as CustomIRoute).name) {
names.push((routes[curRoute.parentId] as CustomIRoute).name);
} else {
break;
}
curRoute = routes[curRoute.parentId];
}
names.push(lastRoute.route.name);
return formatMessage({ id: names.join('.') });
}
// 监听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;
}
// /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;
初步效果已经实现,接下来,把匹配到的路由信息存到数组中,保存起来,这样我们就可以切换了。
新增/src/layouts/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,
}
}
// /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
文件添加代码
// 关闭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]);
实现刷新方法有个小技巧,react中组件的key属性变化,组件就会卸载重新渲染,我们只要改tab的key就行了。
改造src/layouts/index.tsx
文件,让tabs支持删除功能,同时支持右键菜单,菜单中支持刷新、关闭、关闭其他功能。
让tabs支持删除功能,Tabs的items属性支持closable,如果为true则表示可以删除,这里加了个判断,如果只剩最后一个了,就不能删除了。
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]);
给tabs组件绑定onEdit方法
const onTabEdit = (
targetKey: React.MouseEvent | React.KeyboardEvent | string,
action: 'add' | 'remove',
) => {
if (action === 'remove') {
closeTab(targetKey as string);
}
};
改造items里面的label属性,支持右键菜单功能
enum OperationType {
REFRESH = 'refresh',
CLOSE = 'close',
CLOSEOTHER = 'close-other',
}
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]);
看下效果
接下来把这些方法做成全局方法,组件中也能调用,这个功能使用react的useContext钩子来实现,新增src/layouts/context.tsx
文件
// src/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);
改造src/layouts/index
文件,使用KeepAliveTabContext
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>
)
在业务组件中测试一下,改造src/pages/Welcome.tsx
文件
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事件方法。具体实现:
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]);
业务组件使用
// src/pages/Welcome.tsx
useEffect(() => {
onHidden(() => {
console.log('hidden');
});
onShow(() => {
console.log('show');
});
}, [])
效果测试
到此基本功能都已经实现了,还有一些细节需要完善一下。
详情页
不同的列表行也进入详情页,详情页的路由都一样,但是路由参数不一样,这种情况我想到两种方案,第一个方案是我们用url当tab的key,列表进详情只要路由参数不一样就打开新页签,还有一种是路由一样,但是参数不一样,切换tab并刷新当前tab,本文实现第二种方案。
改造路由文件,添加一个详情路由
{
name: 'list.table-list',
icon: 'table',
path: '/list/index',
component: './TableList',
},
{
name: 'list.detail',
icon: 'table',
path: '/list/detail/:id',
component: './TableList/detail',
hideInMenu: true,
parentKey: ['/list/index'],
}
添加详情业务组件,代码很简单,把路由参数id显示出来
// src/pages/TableList/detail.tsx
import { useParams } from '@umijs/max'
export default () => {
const params = useParams();
return (
<h1>路由参数:{params.id}</h1>
)
}
改造src/layouts/useKeepAliveTabs.tsx
文件
...
// 如果不存在则需要插入
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();
});
}
...
效果展示
基于微前端实现多页签
由于篇幅有限,我会在下篇文章中实现这个,对这个感兴趣的,可以关注我一下。
总结
本人文笔有限,很多细节写不出来,大家见谅。这种代码教学文章好难写,感觉还是录视频比较好,后面会尝试录视频。文中写的代码已经上传到github了,对应地址github.com/dbfu/antd-p…。如果本文对你有帮助,麻烦给个赞,谢谢。
文中demo体验地址:dbfu.github.io/antd-pro-ke…