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

11,828 阅读8分钟

体验地址:dbfu.github.io/antd-pro-ke…

前言

前几天在一个前端群里,看到一位群友在吐槽react。事情是这样的,他刚从vuereact不久,老板让他基于antd pro v5实现多页签功能,但是他发现react不像vue一样支持keepalive,这个他实现不了,就在群里吐槽react垃圾,不如vue好用。作为练习时长4年半的reacter,我不能忍,谁说react不支持keepalive就实现不了多页签功能,我以前就基于antd pro v4实现过这个功能,并且后面我也基于微前端实现了多页签。他既然说react实现不了,那我就实现出来给他看下,所以就有了这篇文章。

image.png 上面说的多页签就是类似于这样的功能,很多管理系统都支持这个功能。

实现思路

用过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

image.png 启动成功后,访问http://localhost:8000会进入登录页面,到此项目初始化成功了。

实现功能

新增/src/layouts/index.tsx文件

因为我们需要获取到当前路由对应的组件实例,所以我们要自定义一个layout,在umi4中/src/layouts.index.tsx这个文件会被默认为全局layout。

image.png

测试layout文件是否生效

/src/layouts/index.tsx文件中写入下面代码测试一下效果

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

export default KeepAliveLayout;

登录进去后,无论怎么切换路由,都只显示KeepAliveLayout这个文本。

image.png

引入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;

image.png

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

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

image.png

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

image.png 通过useLocation获取当前url,

image.png

// /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;

01.gif 初步效果已经实现,接下来,把匹配到的路由信息存到数组中,保存起来,这样我们就可以切换了。

新增/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;
}

看下效果

02.gif

上面基本功能已经实现,下面实现刷新关闭关闭其他功能

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]);

看下效果

03.gif

接下来把这些方法做成全局方法,组件中也能调用,这个功能使用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;

效果展示

04.gif

在业务组件中监听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');
    });
  }, [])

效果测试

05.gif

到此基本功能都已经实现了,还有一些细节需要完善一下。

详情页

不同的列表行也进入详情页,详情页的路由都一样,但是路由参数不一样,这种情况我想到两种方案,第一个方案是我们用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();
      });
    }
...

效果展示

06.gif

基于微前端实现多页签

由于篇幅有限,我会在下篇文章中实现这个,对这个感兴趣的,可以关注我一下。

总结

本人文笔有限,很多细节写不出来,大家见谅。这种代码教学文章好难写,感觉还是录视频比较好,后面会尝试录视频。文中写的代码已经上传到github了,对应地址github.com/dbfu/antd-p…。如果本文对你有帮助,麻烦给个赞,谢谢。

文中demo体验地址:dbfu.github.io/antd-pro-ke…