基于react-router v6实现多页签功能。从零开始搭建一个高颜值后台管理系统全栈框架(十四)-灵析社区

lucky0-0

前言

有不少兄弟想让我实现多页签功能,这一期我们就来实现一下。多页签功能我以前实现过,大家可以先看下我以前的文章。以前的文章内容是基于antd pro框架实现多页签,这次基于react-router v6来实现,原理差不多,稍微改点代码就行了。

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

实现思路

借助antdTabs组件能够缓存组件的能力,我们只需要把浏览过的路由缓存到一个数组中,交给Tabs渲染就行了。

实战

改造src/layouts/index.tsx文件

这里以前是直接渲染页面组件,现在需要给这里加上tabs组件

写死一个假数据测试一下,因为我们children绑定的时候Outlet组件,所以我们切路由,内容会跟着变化。

样式有点丑,使用Tabs组件的额card类型,并调一下样式
这样就好看多了

新加tabs-layout组件

多页签的内容可能会有点多,所以把这一块抽出来做成一个单独的组件,

新建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;

封装获取当前路由信息hook

上面我们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来管理打开过的页面。使用刚才封装的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;

效果展示

实现删除tab功能

useTabs中实现关闭tab功能

在后面导出

在tabs-layout中使用closeTab方法

增加右键tab菜单功能,支持关闭、关闭其它、刷新操作

改造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加上transitioncss属性,这个用来添加和删除tab动画用的。但是我们这里拖拽排序的原理是改变元素的transform属性的位移,每次位移都会触发动画,所以就不流畅了,这边写了个样式给transition设置成了none就行了。

效果展示

最后

如果文章对你有帮助,帮忙点个赞,谢谢。

阅读量:2012

点赞量:0

收藏量:0