0%

基于 ant design pro 2.3.1 页面标签化展示的研究与实现

如果暂时不关心实现可直接克隆该仓库 ant-design-pro-v2-plus

前言

之前有根据别人的实现做过一些研究(具体可见[仅供参考]基于 ant design pro 2.1.1 页面标签化展示的研究与实现),其实现原理是根据注入到 BasicLayoutmenuData 取得所有可用的页签组件 ,然后再根据点击事件来添加或者删除相关页签。

由于项目需求,需要一个自带标签页功能的 ant-design-pro (以下简称 pro ) 脚手架,所以重新克隆了仓库来实现该功能,快速在仓库中基于之前的实践实现了该功能,随后发现了一个比较严重的问题:像分步表单和个人中心这两个页面是使用的子路由切换子组件,导致了无法切换的 bug 。通过路由切换子组件功能还是很重要的,虽然之前的项目没有使用这种方式。然后就陷入了沉思,应该怎么办呢?

苦思冥想一番后,突然灵光一闪, pro 自带没有便签页功能,是通过传入 children 来渲染 BasicLayout 下的页面的,那岂不是可以通过判断传入的 children 来更新标签页。说干就干,一番操作之后,果不其然,理论还是行得通的,这样就保留了通过路由切换子组件的功能。

研究与实现

src/pages/Authorized.js

由于之前的实践,需要判断一个用户的 rootTabKey,不同菜单权限的用户可能 rootTabKey 也不同 ,且用户不可删除该 tab 。所以将请求菜单的 action 提升到了这里,通过给 BasicLayout 注入 rootTabKey 实现。

最近发现登录页面使用了 menu 这个全局 model 来生成页面标题,所以如果在 BasicLayout 中请求菜单信息时会先获取到登录的相关路由配置,从而导致标签页异常。又因为菜单可能会涉及用户权限,所以将该请求移到了 Authorized 中是有必要的。

ChildrenTabs 组件

基于 Tabs 组件封装而成,可根据传入的 activeKeyactivetTitlechildren 实现 children 的标签化。

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
export interface ChildrenTab {
/** tab's title */
tab: string;
key: string;
content: React.ReactChildren | JSX.Element;
/** used to indicate the tab need refresh */
refresh?: boolean;
/** used to extends tab's properties */
[k: string]: any;
}

function addTab(newTab: ChildrenTab, activedTabs: ChildrenTab[]) {
/**
* filter 过滤路由 为 '/' 的 children
* map 添加第一个 tab 不可删除
*/
return [...activedTabs, newTab]
.filter(item => item.path !== '/')
.map((item, index) =>
activedTabs.length === 0 && index === 0
? { ...item, closable: false }
: { ...item, closable: true }
);
}

function switchAndUpdateTab(
activeIndex: number,
tabName: string,
extraTabProperties: any,
children: any,
activedTabs: ChildrenTab[]
) {
const { path, content, refresh, ...rest } = activedTabs[activeIndex];
activedTabs.splice(activeIndex, 1, {
tab: tabName,
content: refresh ? content : children,
...rest,
...extraTabProperties,
});
/** map 删除后的 activedTabs 长度为 1 时不可删除 */
return activedTabs.map(item => (activedTabs.length === 1 ? { ...item, closable: false } : item));
}

export interface ChildrenTabsProps {
activeKey: string;
activeTitle: string;
handleTabChange: (keyToSwitch: string, activedTabs: any[]) => void;
extraTabProperties?: {};
tabsConfig?: TabsProps;
afterRemoveTab?: (removeKey: string, nextTabKey: string, activedTabs: ChildrenTab[]) => void;
/** children is used to create tab, switch and update tab */
children: React.ReactChildren;
}

interface ChildrenTabsState {
activedTabs: ChildrenTab[];
activeKey: string | null;
nextTabKey: string | null;
}

// lifecycle
static getDerivedStateFromProps(props: ChildrenTabsProps, state: ChildrenTabsState) {
const { children, activeKey, activeTitle, extraTabProperties } = props;
const { activedTabs, nextTabKey } = state;
// return state and set nextTabKey to `null` after delete tab
if (nextTabKey) {
return {
activedTabs,
activeKey: nextTabKey,
nextTabKey: null,
};
}

const activedTabIndex = _findIndex(activedTabs, { key: activeKey });
// return state after switch or update tab
if (activedTabIndex > -1) {
return {
activedTabs: switchAndUpdateTab(
activedTabIndex,
activeTitle,
extraTabProperties,
children,
activedTabs
),
activeKey,
};
}
// return state to add tab
const newTab = {
tab: activeTitle,
key: activeKey,
content: children,
...extraTabProperties,
};
return {
activedTabs: addTab(newTab, activedTabs),
activeKey,
};
}

实现添加 tab 的功能无疑是最简单的,但是需要注意过滤掉根路由且当添加第一个路由时,不可删除。删除功能重构了好几次,最后是通过在 getDerivedStateFromProps 中判断是否有删除 tab 的相邻 tab key ,即 nextTabKey 实现。如果存在,直接设置 activeKeynextTabKey ,并还原 nextTabKeynull切换并更新的功能可能涉及页面子路由,所以统一更新 tab 组件。

PageTabs 组件

决定系统页签的展示方式。

首先如果是系统根路由 proRootPath (默认 '/'),传入的 children 会重定向到新页面,所以需要直接返回 children 组件。如果不返回 children 的话,会导致重定向失败,页面空白异常。

其次,由于系统使用的 menuData 是经过过滤的,像一些隐藏的页面也是需要页签展示的,所以需要保存并使用 menu model 中的 originalMenuData 来获取页签的 idname

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// result: [pathID, pathName]
function getMetadataOfTab(
childrenPathname: string,
originalMenuData: MenuItem[]
): [string, string] {
function getMetadata(path: string, menuData: MenuItem[], parent: MenuItem | null) {
let result: [string, string];
menuData.forEach(item => {
/** match prefix iteratively */
if (pathToRegexp(`${item.path}(.*)`).test(path)) {
if (!parent && item.name) {
result = [item.path, item.name];
} else if (parent && !parent.component && item.component && item.name) {
/** create new tab if item has name and item's parant route has not component */
result = [item.path, item.name];
}
/** get children pathID, pathName, shouldUpdate recursively */
if (item.children) {
result = getMetadata(path, item.children, item) || result;
}
}
});
return result;
}
return getMetadata(childrenPathname, originalMenuData, null) || ['404', 'Error'];
}

由于使用了页面路径作为页签的 tab id ,所以页签的展示完全是由 tab id 决定的。

src/layouts/BasicLayout.js

核心代码

1
2
3
4
5
6
7
8
9
10
const renderMenuData = transferMenuData(menuLoading, menuData);
const renderContent = () => {
if (pageTabs) {
if (renderMenuData) {
return <PageTabs {...this.props} />;
}
return <PageLoading />;
}
return children;
};

renderMenuData : 根据 menuLoadingmenuData 判断是否需要展示的菜单数据(由于用户登录相关页面也会修改 menuData )。

renderContent : 根据 pageTabsrenderMenuData 渲染页面。

得益于采用了路由控制的方式,所以不需要像之前的方案一样向 SiderMenu 和 Header 传入点击处理事件。标签页的功能都在 BasicLayout 中实现,新增标签页通过路由切换的方式即可。

注意事项

  1. 由于使用了标签页的方式布局,所以还需要修改 PageHeaderWrapper component 根节点的 style 属性。
  2. 性能问题,标签页切换可能导致已打开的标签页的重复渲染,可使用高级组件 withRoutePageimport { withRoutePage } from '@/utils/enhanceUtils') 对页面做性能优化。

后记

为什么不用 v4 ?

在实现该功能的时候, v4 已经正式推出了,本来也计划直接用 v4 的,奈何 npm run fetch:blocks 安装所有区块总是失败,加之也已经比较熟悉 v2 了,所以依然选择了 v2 。

已知 v2 和 v4 的不同

像分步表单这种通过路由控制表单进度的方式,按理说应该做到如果没有执行前一步需要重定向到第一步。发现了 v4 是通过 dva 的状态判断表单进度的,所以根本不会改变路由。v2 由于没有使用区块的方式,分步表单可以灵活的使用路由切换,但是没有做进度的重定向,所以这构成了在两个版本在行为上的不同。为什么说这个呢?大概是当时眼花了,看到 v2 切换进度时路由并没有变化,还以为有什么改变路由不改变 location 的黑科技呢 _(:3J∠)_