几十行实现 Ant Design Pro v6 的多页签功能

2,930 阅读4分钟

image.png

每次给前端同学分享一些东西,总会看到“学不动了”这句。前端表面上“日新月异”,实质“万变不离其宗”。借用仙逝多年的前端大佬司徒正美的话说,前端一直要遵循三个原则:

  • 复杂即错误;
  • 数据结构优于算法;
  • 出奇制胜。

蚂蚁的东西有人说好,有人嗤之以鼻。自己造个轮子,兼容了所有兼顾了所有,殊途同归。Ant Design Pro 从 v2 开始用到现在的 v6,一路看着它从简单变复杂,又从复杂变简单,核心无非:简单直接高效。至少从身型上看 v6 越来越轻盈:

  • 删除 dva 的配置;
  • 删除区块功能;
  • 删除对 IE 的支持。

除了网友动辄诟病的“重”之外,就是无论 Ant Design Pro 功能强大还是去繁就简,多页签的功能官方始终不支持。翻看 GitHub 有很多实现方案,大多围绕 React Activation 实现 <KeepAlive /> 组件来包住 <ProLayout /> 的 children, 通过 Routes 引用 location.pathname 对应缓存的 Components。

不想在旧版本的多页签功能基础上升级了,重新撸一个。

基本诉求

  • 多方式开启和关联:
    • 可从左侧菜单栏开启;
    • 可从地址栏输入 URL 开启;
    • 菜单、地址栏、页签三者关联,任一变动,其它两者自动联动;
  • 缓存已开启的页签:
    • 页签内的状态和数据不会在切换页签时丢失;
    • 刷新浏览器时,保留原开启的和激活的页签;
  • 页签标题的国际化:
    • 与当前选择的语言一致;
    • 切换语言时,页签标题自动刷新。

image.png

核心思路

  • 以 location.pathname 作为每个页签唯一的 Key;
  • 找到路由的上下文,构建对每个页面组件的引用;
  • 页签数据的任何变动,更新本地缓存。

实现步骤

  • 在 app.tsx 中的 childrenRender 方法内重写原 {children} 输出的部分;
<Tabs
  type="editable-card"
  hideAdd
  onChange={switchTab}
  activeKey={activeTab}
  onEdit={removeTab}
>
  {tabItems.length > 0 &&
    tabItems.map((tabItem) => {
      return (
        <TabPane
          tab={tabItem.title}
          key={tabItem.id}
          closable={tabItems.length > 1}
        >
          {/* 替换原来直接输出的 children */}
          {tabContents[tabItem.pathname]}
        </TabPane>
      );
    })}
</Tabs>
  • 实现 Tabs 的基本交互:切换、删除、激活;
const [activeTab, setActiveTab] = useState();
const [tabItems, setTabItems] = useState(JSON.parse(localStorage.getItem('tabPages') || '[]'));
const getCurrTab = (newActiveTab) => tabItems.find((item) => item.id === newActiveTab);

// 切换 Tab
const switchTab = (newActiveTab) => {
  const currTab = getCurrTab(newActiveTab);
  if (currTab) {
    history.push(currTab.pathname);
    setActiveTab(newActiveTab);
  }
};

// 移除 Tab
const removeTab = (tabKey: string) => {
  let newActiveTab = activeTab;
  let lastIndex = -1;
  tabItems.forEach((item, i) => {
    if (item.id === tabKey) {
      lastIndex = i - 1;
    }
  });
  const newPanes = tabItems.filter((item) => item.id !== tabKey);
  if (newPanes.length && newActiveTab === tabKey) {
    if (lastIndex >= 0) {
      newActiveTab = newPanes[lastIndex].id;
    } else {
      newActiveTab = newPanes[0].id;
    }
  }
  setTabItems(newPanes);
  switchTab(newActiveTab);
};

// 激活 Tab
const activateTab = () => {
  const { location } = history;
  const currTab: any = tabItems.find((item) => item.pathname === location.pathname);
  if (currTab) {
    setActiveTab(currTab.id);
  }
};
  • 监听页签数据的变动,触发更新;
// 任何 Tab 变动,激活正确的 Tab,并更新缓存
useEffect(() => {
  activateTab();
  localStorage.setItem('tabPages', JSON.stringify(tabItems));
}, [tabItems]);
  • 最核心的部分:找到路由的上下文
// 参考:https://procomponents.ant.design/components/layout#routecontext
import { RouteContext, RouteContextType } from '@ant-design/pro-components';

const Page = () => (
  <RouteContext.Consumer>
    {(value: RouteContextType) => {
      return value.title;
    }}
  </RouteContext.Consumer>
);
  • app.tsx 完整的代码
// app.tsx 原文件 https://github.com/ant-design/ant-design-pro/blob/master/src/app.tsx

// 以下为作者改造后的 app.tsx
import Footer from '@/components/Footer';
import RightContent from '@/components/RightContent';
import { LinkOutlined } from '@ant-design/icons';
import { Settings as LayoutSettings } from '@ant-design/pro-components';
import { SettingDrawer, RouteContext } from '@ant-design/pro-components';
import { history, Link } from '@umijs/max';
import defaultSettings from '../config/defaultSettings';
import { errorConfig } from './requestErrorConfig';
import { currentUser as queryCurrentUser } from './services/ant-design-pro/api';
import React, { useState, useEffect } from 'react';
import Tabs from 'antd/lib/tabs';
const isDev = process.env.NODE_ENV === 'development';
const loginPath = '/user/login';
const TabPane = Tabs.TabPane;
const tabTitles: any = {};

/**
 * @see  https://umijs.org/zh-CN/plugins/plugin-initial-state
 * */
export async function getInitialState(): Promise<{
  settings?: Partial<LayoutSettings>;
  currentUser?: API.CurrentUser;
  loading?: boolean;
  fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
  const fetchUserInfo = async () => {
    try {
      const msg = await queryCurrentUser({
        skipErrorHandler: true,
      });
      return msg.data;
    } catch (error) {
      // history.push(loginPath);
    }
    return undefined;
  };
  // 如果不是登录页面,执行
  const { location } = history;
  if (location.pathname !== loginPath) {
    const currentUser = await fetchUserInfo();
    return {
      fetchUserInfo,
      currentUser,
      settings: defaultSettings,
    };
  }
  return {
    fetchUserInfo,
    settings: defaultSettings,
  };
}

// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
  const [activeTab, setActiveTab] = useState();
  const [tabItems, setTabItems] = useState(JSON.parse(localStorage.getItem('tabPages') || '[]'));
  const getCurrTab = (newActiveTab) => tabItems.find((item) => item.id === newActiveTab);

  // 切换 Tab
  const switchTab = (newActiveTab) => {
    const currTab = getCurrTab(newActiveTab);
    if (currTab) {
      history.push(currTab.pathname);
      setActiveTab(newActiveTab);
    }
  };

  // 移除 Tab
  const removeTab = (tabKey: string) => {
    let newActiveTab = activeTab;
    let lastIndex = -1;
    tabItems.forEach((item, i) => {
      if (item.id === tabKey) {
        lastIndex = i - 1;
      }
    });
    const newPanes = tabItems.filter((item) => item.id !== tabKey);
    if (newPanes.length && newActiveTab === tabKey) {
      if (lastIndex >= 0) {
        newActiveTab = newPanes[lastIndex].id;
      } else {
        newActiveTab = newPanes[0].id;
      }
    }
    setTabItems(newPanes);
    switchTab(newActiveTab);
  };

  // 激活 Tab
  const activateTab = () => {
    const { location } = history;
    const currTab: any = tabItems.find((item) => item.pathname === location.pathname);
    if (currTab) {
      setActiveTab(currTab.id);
    }
  };

  // 任何 Tab 变动,激活正确的 Tab,并更新缓存
  useEffect(() => {
    activateTab();
    localStorage.setItem('tabPages', JSON.stringify(tabItems));
  }, [tabItems]);

  return {
    rightContentRender: () => <RightContent />,
    waterMarkProps: {
      content: initialState?.currentUser?.name,
    },
    footerRender: () => <Footer />,
    onPageChange: () => {
      const { location } = history;
      // 如果没有登录,重定向到 login
      if (!initialState?.currentUser && location.pathname !== loginPath) {
        // history.push(loginPath);
      }
      const pathname = location.pathname;
      const currtabItem = {
        id: location.key,
        title: tabTitles[pathname],
        pathname,
      };
      if (pathname !== '/') {
        // 构建开启的 Tab 列表,并更新国际化的 Tab 标题
        setTabItems((prev: any) => {
          const next = prev.find((item) => item.pathname === pathname)
            ? prev
            : [...prev, currtabItem];
          return next.map((item) => ({ ...item, title: tabTitles[item.pathname] }));
        });
      } else {
        history.push('/welcome');
      }
      activateTab();
    },
    layoutBgImgList: [
      {
        src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr',
        left: 85,
        bottom: 100,
        height: '303px',
      },
      {
        src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr',
        bottom: -68,
        right: -45,
        height: '303px',
      },
      {
        src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr',
        bottom: 0,
        left: 0,
        width: '331px',
      },
    ],
    links: isDev
      ? [
          <Link key="openapi" to="/umi/plugin/openapi" target="_blank">
            <LinkOutlined />
            <span>OpenAPI 文档</span>
          </Link>,
        ]
      : [],
    menuHeaderRender: undefined,
    // 自定义 403 页面
    // unAccessible: <div>unAccessible</div>,
    // 增加一个 loading 的状态
    childrenRender: () => {
      // if (initialState?.loading) return <PageLoading />;
      return (
        <>
          <RouteContext.Consumer>
            {(ctx) => {
              // 从上下文的 routes 中构建 Map,引用各页面的 children
              const tabContents = {};
              const getTabContents = (arr = []) => {
                arr.forEach((ele) => {
                  tabContents[ele.path] = ele.element;
                  if (ele.children) {
                    getTabContents(ele.children);
                  }
                });
              };
              getTabContents(ctx.route.routes);

              // 从上下文构建 Map,缓存国际化的 Tab 标题
              const getTabTitles = (arr = []) => {
                arr.forEach((ele) => {
                  if (ele.name) {
                    tabTitles[ele.path] = ele.name;
                  }
                  if (ele.children) {
                    getTabTitles(ele.children);
                  }
                });
              };
              getTabTitles(ctx.menuData);

              return (
                <Tabs
                  type="editable-card"
                  hideAdd
                  onChange={switchTab}
                  activeKey={activeTab}
                  onEdit={removeTab}
                >
                  {tabItems.length > 0 &&
                    tabItems.map((tabItem) => {
                      return (
                        <TabPane
                          tab={tabItem.title}
                          key={tabItem.id}
                          closable={tabItems.length > 1}
                        >
                          {/* 替换原来直接输出的 children */}
                          {tabContents[tabItem.pathname]}
                        </TabPane>
                      );
                    })}
                </Tabs>
              );
            }}
          </RouteContext.Consumer>
          <SettingDrawer
            disableUrlParams
            enableDarkTheme
            settings={initialState?.settings}
            onSettingChange={(settings) => {
              setInitialState((preInitialState) => ({
                ...preInitialState,
                settings,
              }));
            }}
          />
        </>
      );
    },
    ...initialState?.settings,
  };
};

/**
 * @name request 配置,可以配置错误处理
 * 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
 * @doc https://umijs.org/docs/max/request#配置
 */
export const request = {
  ...errorConfig,
};

仓库

gitee.com/itpretty/an…

总结

自始至终只改动了 app.tsx 这一个文件,没有添加任何额外的依赖,后期维护成本低。初步实现了最基本的页签功能,基于此,你可以进一步抽象、扩展和完善。赶紧 Copy,Paste & Run 起来吧~