有不少兄弟想让我实现多页签功能,这一期我们就来实现一下。多页签功能我以前实现过,大家可以先看下我以前的文章。以前的文章内容是基于antd pro
框架实现多页签,这次基于react-router v6
来实现,原理差不多,稍微改点代码就行了。
手把手带你基于ant design pro 5实现多tab页(路由keepalive)
借助antd
的Tabs
组件能够缓存组件的能力,我们只需要把浏览过的路由缓存到一个数组中,交给Tabs渲染就行了。
这里以前是直接渲染页面组件,现在需要给这里加上tabs组件
写死一个假数据测试一下,因为我们children绑定的时候Outlet组件,所以我们切路由,内容会跟着变化。
样式有点丑,使用Tabs组件的额card类型,并调一下样式这样就好看多了
多页签的内容可能会有点多,所以把这一块抽出来做成一个单独的组件,
新建src/layouts/tabs-layout.tsx
文件
// src/layouts/tabs-layout.tsx
import React from "react";
import { Tabs } from 'antd';
import { Outlet } from 'react-router-dom';
const TabsLayout: React.FC = () => {
return (
<Tabs
defaultActiveKey='test'
items={[{
label: '测试',
key: 'test',
children: (
<div className='px-[16px]'>
<Outlet />
</div>
)
}]}
type='card'
/>
)
}
export default TabsLayout;
上面我们tab的label和key都是写死的假数据,现在我们写一个hook获取当前匹配到的路由的名称、路由、图标信息。
在动态添加路由的时候,把这些信息可以存到handle属性里。
前面文章有说过,reactr-router v6提供了一个hook可以获取匹配到的路由,正好我们可以使用这个hook,不过这个hook返回的数组,默认最后一个就是当前路由,需要处理一下。
// src/hooks/use-match-router/index.tsx
import { useEffect, useState } from 'react';
import { useLocation, useMatches, useOutlet } from 'react-router-dom';
interface MatchRouteType {
// 菜单名称
title: string;
// tab对应的url
pathname: string;
// 要渲染的组件
children: any;
// 路由,和pathname区别是,详情页 pathname是 /:id,routePath是 /1
routePath: string;
// 图标
icon?: string;
}
export function useMatchRoute(): MatchRouteType | undefined {
// 获取路由组件实例
const children = useOutlet();
// 获取所有路由
const matches = useMatches();
// 获取当前url
const { pathname } = useLocation();
const [matchRoute, setMatchRoute] = useState<MatchRouteType | undefined>();
// 监听pathname变了,说明路由有变化,重新匹配,返回新路由信息
useEffect(() => {
// 获取当前匹配的路由
const lastRoute = matches.at(-1);
if (!lastRoute?.handle) return;
setMatchRoute({
title: (lastRoute?.handle as any)?.name,
pathname,
children,
routePath: lastRoute?.pathname || '',
icon: (lastRoute?.handle as any)?.icon,
});
}, [pathname])
return matchRoute;
}
在tabs-layout引入测试一下
// src/layouts/tabs-layout.tsx
import React from "react";
import { Tabs } from 'antd';
import { useMatchRoute } from '@/hooks/use-match-router';
import { antdIcons } from '@/assets/antd-icons';
const TabsLayout: React.FC = () => {
const matchRoute = useMatchRoute();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
return (
<Tabs
defaultActiveKey='test'
items={matchRoute ? [{
label: (
<>
{getIcon(matchRoute.icon)}
{matchRoute.title}
</>
),
key: matchRoute.routePath,
children: (
<div className='px-[16px]'>
{matchRoute.children}
</div>
)
}] : []}
type='card'
/>
)
}
export default TabsLayout;
菜单名称、icon、内容可以正常的显示了
上面永远只能展示最新一个菜单内容,现在我们封装一个hook来管理打开过的页面。使用刚才封装的hook获取当前匹配到的路由信息,发现缓存里已经有了,就不往缓存里添加了,没有则添加到缓存里。
// /src/layouts/useTabs.tsx
import { useMatchRoute } from '@/hooks/use-match-router';
import { useEffect, useState } from 'react';
export interface KeepAliveTab {
title: string;
routePath: string;
key: string;
pathname: string;
icon?: any;
children: any;
}
function getKey() {
return new Date().getTime().toString();
}
export function useTabs() {
// 存放页面记录
const [keepAliveTabs, setKeepAliveTabs] = useState<KeepAliveTab[]>([]);
// 当前激活的tab
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 {
tabs: keepAliveTabs,
activeTabRoutePath,
}
}
改造tabs-layout文件,引入上面封装的hook
// src/layouts/tabs-layout.tsx
import React, { useCallback, useMemo } from "react";
import { Tabs } from 'antd';
import { antdIcons } from '@/assets/antd-icons';
import { useTabs } from '@/hooks/use-tabs';
import { router } from '@/router';
const TabsLayout: React.FC = () => {
const { activeTabRoutePath, tabs } = useTabs();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
const tabItems = useMemo(() => {
return tabs.map(tab => ({
label: (
<>
{getIcon(tab.icon)}
{tab.title}
</>
),
key: tab.routePath,
children: (
<div className='px-[16px]'>
{tab.children}
</div>
),
closable: tabs.length > 1, // 剩最后一个就不能删除了
}))
}, [tabs]);
const onTabsChange = useCallback((tabRoutePath: string) => {
router.navigate(tabRoutePath);
}, []);
return (
<Tabs
activeKey={activeTabRoutePath}
items={tabItems}
type='card'
onChange={onTabsChange}
/>
)
}
export default TabsLayout;
效果展示
useTabs中实现关闭tab功能
在后面导出
在tabs-layout中使用closeTab方法
改造useTabs增加刷新tab和关闭其它tab方法
改造tab的label字段支持右键菜单功能,这个可以使用antd的Dropdown
组件。
// src/layouts/tabs-layout.tsx
import React, { useCallback, useMemo } from "react";
import { Dropdown, Tabs } from 'antd';
import { antdIcons } from '@/assets/antd-icons';
import { KeepAliveTab, useTabs } from '@/hooks/use-tabs';
import { router } from '@/router';
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
enum OperationType {
REFRESH = 'refresh',
CLOSE = 'close',
CLOSEOTHER = 'close-other',
}
const TabsLayout: React.FC = () => {
const { activeTabRoutePath, tabs, closeTab, refreshTab, closeOtherTab } = useTabs();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
const menuItems: MenuItemType[] = useMemo(
() => [
{
label: '刷新',
key: OperationType.REFRESH,
},
tabs.length <= 1 ? null : {
label: '关闭',
key: OperationType.CLOSE,
},
tabs.length <= 1 ? null : {
label: '关闭其他',
key: OperationType.CLOSEOTHER,
},
].filter(o => o !== null) as MenuItemType[],
[tabs]
);
const menuClick = useCallback(({ key, domEvent }: any, 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' }}>
{getIcon(tab.icon)}
{tab.title}
</div>
</Dropdown>
)
}, [menuItems]);
const tabItems = useMemo(() => {
return tabs.map(tab => {
return {
key: tab.routePath,
label: renderTabTitle(tab),
children: (
<div
key={tab.key}
className='px-[16px]'
>
{tab.children}
</div>
),
closable: tabs.length > 1, // 剩最后一个就不能删除了
}
})
}, [tabs]);
const onTabsChange = useCallback((tabRoutePath: string) => {
router.navigate(tabRoutePath);
}, []);
const onTabEdit = (
targetKey: React.MouseEvent | React.KeyboardEvent | string,
action: 'add' | 'remove',
) => {
if (action === 'remove') {
closeTab(targetKey as string);
}
};
return (
<Tabs
activeKey={activeTabRoutePath}
items={tabItems}
type="editable-card"
onChange={onTabsChange}
hideAdd
onEdit={onTabEdit}
/>
)
}
export default TabsLayout;
右键tab菜单效果展示
这个可以使用react中useContext
,可以跨组件使用变量
新建src/layouts/tabs-context.tsx
文件
/* eslint-disable @typescript-eslint/no-empty-function */
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);
在tabs-layout文件中使用这个context,并注入上面那些方法。在业务组件中,可以导入这些方法,然后使用
antd官网有Tabs组件支持拖拽的例子,可以直接拿来用。
封装一个可拖拽排序的Tabs组件
// src/components/draggable-tab/index.tsx
import type { DragEndEvent } from '@dnd-kit/core';
import { DndContext, PointerSensor, useSensor } from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { useEffect, useState } from 'react';
import { Tabs, TabsProps } from 'antd';
import {
restrictToHorizontalAxis,
} from '@dnd-kit/modifiers';
interface DraggableTabPaneProps extends React.HTMLAttributes<HTMLDivElement> {
'data-node-key': string;
}
const DraggableTabNode = (props: DraggableTabPaneProps) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: props['data-node-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleX: 1 }),
transition,
};
return React.cloneElement(props.children as React.ReactElement, {
ref: setNodeRef,
style,
...attributes,
...listeners,
});
};
const DraggableTab: React.FC<TabsProps & { onItemsChange?: (items: TabsProps['items']) => void }> = ({ onItemsChange, ...props }) => {
const [items, setItems] = useState(props.items || []);
const sensor = useSensor(PointerSensor, { activationConstraint: { distance: 10 } });
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (active.id !== over?.id) {
setItems((prev) => {
const activeIndex = prev.findIndex((i) => i.key === active.id);
const overIndex = prev.findIndex((i) => i.key === over?.id);
return arrayMove(prev, activeIndex, overIndex);
});
}
};
useEffect(() => {
setItems(props.items || []);
}, [props.items]);
useEffect(() => {
if (onItemsChange) {
onItemsChange(items);
}
}, [items]);
return (
<Tabs
renderTabBar={(tabBarProps, DefaultTabBar) => (
<DndContext sensors={[sensor]} onDragEnd={onDragEnd} modifiers={[restrictToHorizontalAxis]}>
<SortableContext items={items.map((i) => i.key)} strategy={horizontalListSortingStrategy}>
<DefaultTabBar {...tabBarProps}>
{(node) => (
<DraggableTabNode {...node.props} key={node.key}>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
)}
{...props}
items={items}
className='tab-layout'
/>
);
};
export default DraggableTab;
需要安装几个依赖
pnpm i @dnd-kit/core
pnpm i @dnd-kit/utilities
pnpm i @dnd-kit/modifiers
官网给的例子中,card类型的Tabs,拖拽时很卡,调试一段时间,发现editable-card
类型Tabs会给tab加上transition
css属性,这个用来添加和删除tab动画用的。但是我们这里拖拽排序的原理是改变元素的transform属性的位移,每次位移都会触发动画,所以就不流畅了,这边写了个样式给transition
设置成了none
就行了。
效果展示
如果文章对你有帮助,帮忙点个赞,谢谢。
阅读量:2012
点赞量:0
收藏量:0