每次给前端同学分享一些东西,总会看到“学不动了”这句。前端表面上“日新月异”,实质“万变不离其宗”。借用仙逝多年的前端大佬司徒正美的话说,前端一直要遵循三个原则:
- 复杂即错误;
- 数据结构优于算法;
- 出奇制胜。
蚂蚁的东西有人说好,有人嗤之以鼻。自己造个轮子,兼容了所有兼顾了所有,殊途同归。Ant Design Pro 从 v2 开始用到现在的 v6,一路看着它从简单变复杂,又从复杂变简单,核心无非:简单、直接、高效。至少从身型上看 v6 越来越轻盈:
- 删除 dva 的配置;
- 删除区块功能;
- 删除对 IE 的支持。
除了网友动辄诟病的“重”之外,就是无论 Ant Design Pro 功能强大还是去繁就简,多页签的功能官方始终不支持。翻看 GitHub 有很多实现方案,大多围绕 React Activation 实现 <KeepAlive />
组件来包住 <ProLayout />
的 children, 通过 Routes 引用 location.pathname
对应缓存的 Components。
不想在旧版本的多页签功能基础上升级了,重新撸一个。
基本诉求
- 多方式开启和关联:
- 可从左侧菜单栏开启;
- 可从地址栏输入 URL 开启;
- 菜单、地址栏、页签三者关联,任一变动,其它两者自动联动;
- 缓存已开启的页签:
- 页签内的状态和数据不会在切换页签时丢失;
- 刷新浏览器时,保留原开启的和激活的页签;
- 页签标题的国际化:
- 与当前选择的语言一致;
- 切换语言时,页签标题自动刷新。
核心思路
- 以 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,
};
仓库
总结
自始至终只改动了 app.tsx 这一个文件,没有添加任何额外的依赖,后期维护成本低。初步实现了最基本的页签功能,基于此,你可以进一步抽象、扩展和完善。赶紧 Copy,Paste & Run 起来吧~