多页签开发记录
功能效果
1、基本的多页签展示及切换功能
2、页签切换保留滚动位置与数据
3、刷新浏览器,页签依旧保留
4、可配置项(关闭页签/刷新浏览器时的提示、是否可关闭页签、是否打开新页签)
5、右键菜单(关闭选中标签、关闭右侧标签页、关闭其他标签页)
使用(开启多页签模式)
defaultSettings.js 配置
{
...,
multipleTabs: true, // 开启多页签模式
}
routes.js 配置
{
...
{
path: '/templates/step-form',
name: '分步表单',
// group: '应用',
component: './Templates/StepForm',
menuKey: 'step',
access: 'normalRouteFilter',
// 多页签相关配置
tabProps: {
closeTip: true, // 关闭页签/刷新浏览器时的提示,默认为false
},
},
...
}
tabProps 配置
参数 | 说明 | 默认值 |
---|---|---|
closeTip | 关闭页签/刷新浏览器时的提示 | false |
openNew | 页面是否在新页签打开。例:首次登录时需要修改初始密码,则从首页跳转到修改初始密码,而不是打开新页签,需要配置 openNew 为 false。 | true |
closable | 页签是否可关闭 | true |
skipNew | 跳转其他页面时是否打开新页签。例:完成首次登录初始密码修改后,在当前页签跳转到首页,而不是打开新页签,配置 skipNew 为 false。 | true |
开发注意事项
1、在同一个页签跳转详情页的路由配置
当业务需要页面都在同一个页签展示时,注意路由配置需要存在包含关系
正确示例:/system/role 和 /system/role/add(存在包含关系,页面跳转处于同一页签)
错误示例:/system/role/list 和 /system/role/add(不存在包含关系,跳转时打开新页签)
2、二级页返回上一页
例:从列表页打开详情页后,此时记录的上一页为列表页。此时多次切换页签再回到详情页时,记录的上一页则变成了最后一次切换到详情页前的页面路由,而非列表页,此时返回页面则会出现逻辑错误。
解决:当需要返回上一页时,将history.go(-1)
修改成goBack()
引入及使用:
import { useGoBack } from '@/utils/hooks';
const { goBack } = useGoBack(); // 引入自定义hooks中的返回上一页方法
goBack(); // 解决因多页签切换导致返回的上一页面不正确的问题
3、给 PageContainer 中配置了 footer 的页面加配置 footerToolBarProps={{ portalDom: false }}
用于解决打开有 footer 的页面后,切换到其他页签,footer 依然存在的问题
4、在当前页面自动关闭当前页签
import { useModel } from '@umijs/max';
const { closeTab } = useModel('tabs');
closeTab(); // 关闭当前页签
调研
antd pro v5对于多页签的支持:
antd pro v5中已内嵌多页签解决方式,但是github issue中官方人员却称官方没有多页签方案(应该是是内置了第三方插件alitajs,官方文档没写这个功能),且不推荐
PS:试了一下开启多页签的方式,一个项目直接卡住了,另一个项目正常,感觉不太稳定,且默认配置下的功能不太符合项目需求(每个页面都单独一个页签,如各种详情页)
config.js中配置
// /./ 表示全匹配,也就是全部的路由都保持
keepalive: [/./],
// hasDropdown 表示是否使用默认的右侧功能,关闭左侧,右侧其它等
tabsLayout: {
hasDropdown: true,
}
但是我看很多开源的vue后台管理模板中都集成了多页签功能,有时间看看
具体实现
核心思路
- React Activation 实现
<KeepAlive />
组件来包住 children - 找到路由的上下文,构建对每个页面组件的引用
- 页签数据的任何变动,更新本地缓存
1、在 app.jsx 中的 childrenRender 方法内重写原 {children}
输出的部分
通过defaultSettings中的
multipleTabs
配置,判断是否开启多页签在 layout 配置的
childrenRender
方法内重写原{children}
输出的部分通过
RouteContext.Consumer
获取路由的上下文核心逻辑封装在
SwitchTabs
组件中
app.jsx
import SwitchTabs from '@/components/SwitchTabs';
export const layout = ({
initialState,
// setInitialState
}) => {
...
return {
...
childrenRender: (children, props) => {
// if (initialState?.loading) return <PageLoading />;
return initialState?.settings?.multipleTabs ? (
<RouteContext.Consumer>
{(ctx) => <SwitchTabs menuData={ctx?.menuData} />}
</RouteContext.Consumer>
) : (
<ConfigProvider
input={{ autoComplete: 'off', placeholder: '请输入' }}
select={{ allowclear: true, placeholder: '请选择' }}
locale={zhCN}
{...props}
>
<ErrorBoundary>{children}</ErrorBoundary>
</ConfigProvider>
);
},
...,
};
};
2、核心:SwitchTabs组件
1.通过路由上下文内容进行数据处理
const tabTitles = {}; // 各路由对应页签名称map
const tabContents = {}; // 各路由对应页面内容map
const allMenuObj = {}; // 所有路由相关信息
const getTabObj = useMemoizedFn((arr = [], parent = {}) => {
arr.forEach((item) => {
tabContents[item.id] = item.element;
if (!tabTitles[item.id]) {
tabTitles[item.id] = item?.name || parent?.name;
}
if (item?.path && !allMenuObj[item?.path]) {
allMenuObj[item?.path] = item;
}
if (!isEmptyArray(item?.children)) {
getTabObj(item.children, item);
}
});
});
getTabObj(menuData);
2.使用antd中的Tabs
组件实现多页签样式
使用上一步处理产生的
tabContents
来展示页面内容使用keepalive组件包裹内容进行页面内容缓存(具体在下面第5点说明)
onEdit中配置关闭页签前的提示(tabProps配置-closeTip)
<Tabs
type="editable-card"
hideAdd
onChange={switchTab}
activeKey={activeTab}
onEdit={(tabKey) => {
const currTab = getCurrTab(tabKey);
if (currTab?.tabProps?.closeTip) {
Modal.confirm({
title: '请注意,关闭页签将丢失您当前输入的内容,是否继续?',
okText: '确定',
cancelText: '取消',
icon: <InfoCircleFilled />,
onOk: () => {
removeTab(tabKey);
},
});
} else {
removeTab(tabKey);
}
}}
items={tabItems
?.filter((item) => item?.id && tabContents[item?.id])
?.map((item, index) => ({
// 自定义右键菜单(具体看第7小点)
label: setTab(item?.title || '', item.id, index),
key: item?.id,
closable: tabItems?.length > 1 && String(item?.tabProps?.closable) !== 'false',
children: (
<ConfigProvider
input={{ autoComplete: 'off', placeholder: '请输入' }}
select={{ allowclear: true, placeholder: '请选择' }}
locale={zhCN}
>
<ErrorBoundary>
{activeTab === item?.id ? (
<KeepAlive name={item?.id}>{tabContents[item?.id]}</KeepAlive>
) : (
''
)}
</ErrorBoundary>
</ConfigProvider>
), // 替换原来直接输出的 children
}))}
tabBarStyle={{
position: 'sticky',
top: 64,
zIndex: 99,
background: '#f3f5fa',
padding: '10px 0 0 10px',
}}
className="pageTabs"
/>
3.实现tabs的基本交互:切换、删除、激活
通过sessionStorage
缓存页签列表信息
const [activeTab, setActiveTab] = useState(); // 选中页签id
const oldTab = usePrevious(activeTab); // 上一个被选中的页签id
const [tabItems, setTabItems] = useState(
JSON.parse(sessionStorage.getItem(`${PROJECT_KEY}-tabPages`) || '[]'),
); // 打开的页签列表信息
const oldScrollTop = useRef(); // 历史滚动高度
// 获取当前激活页签的信息
const getCurrTab = (newActiveTab) => tabItems.find((item) => item.id === newActiveTab);
// 切换 Tab
const switchTab = useMemoizedFn((newActiveTab) => {
const currTab = getCurrTab(newActiveTab);
if (currTab) {
// 切换前记录滚动高度
oldScrollTop.current = document.documentElement.scrollTop;
history.push(currTab.pathname);
setActiveTab(newActiveTab);
}
});
// 移除 Tab
const removeTab = useMemoizedFn((tabKey) => {
aliveController.drop(tabKey); // 删除对应缓存
let newActiveTab = activeTab;
let lastIndex = -1;
tabItems.forEach((item, i) => {
if (item.id === tabKey) {
lastIndex = i - 1;
}
});
const newTabItems = tabItems.filter((item) => item.id !== tabKey);
if (!isEmptyArray(newTabItems) && newActiveTab === tabKey) {
if (lastIndex >= 0) {
newActiveTab = newTabItems[lastIndex].id;
} else {
newActiveTab = newTabItems[0].id;
}
}
setTabItems(newTabItems); // 更新页签列表
switchTab(newActiveTab); // 更新当前激活页签
});
// 激活 Tab
const activateTab = useMemoizedFn(() => {
// 通过当前路由查询到对应的路由信息
const pathRouteProps = getPathRouteProps(
location.pathname,
Object.keys(allMenuObj)
?.map((key) => allMenuObj[key])
?.filter((item) => !item?.path?.includes('*')),
);
const currTab = tabItems.find((item) => item.id === pathRouteProps?.id);
if (currTab) {
setActiveTab(currTab.id);
}
});
4.通过监听location.pathname
, location.search
变化,对页签进行变更操作
通过sessionStorage
缓存页签列表信息
tabProps配置项实现:
1、skipNew:跳转其他页面时是否打开新页签。
2、openNew:页面是否在新页签打开。
// 监听路由变化,更新页签列表信息,激活页签
useEffect(() => {
if (location.pathname && initialState?.settings?.multipleTabs) {
const { pathname, search } = location;
// 通过当前路由查询到对应的路由信息
const pathRouteProps = getPathRouteProps(
location.pathname,
Object.keys(allMenuObj)
?.map((key) => allMenuObj[key])
?.filter((item) => !item?.path?.includes('*')),
);
// 刷新前提示(第6小点有细说)
if (pathRouteProps?.tabProps?.closeTip) {
window.onbeforeunload = beforeunload;
} else {
window.onbeforeunload = null;
}
// tabItems中的具体信息
const currTabItem = {
id: pathRouteProps?.id,
title: tabTitles[pathRouteProps?.id],
pathname: pathname + search,
tabProps: pathRouteProps?.tabProps, // 相关自定义页签配置
scrollTop: 0, // 对应页签滚动高度
};
const oldTabItems = JSON.parse(JSON.stringify(tabItems));
// 防止开启两个首页tab
if (pathname !== '/') {
setTabItems((prev) => {
let next = [...prev];
// 判断页签列表中是否存在新页签的父/子路由
const preIndex = prev.findIndex((item) => {
const prePathname = item?.pathname?.split('?')?.[0];
return (
(pathname.includes(prePathname) || prePathname.includes(pathname)) &&
prePathname !== pathname
);
});
// 判断页签列表中是否存在新页签
const currIndex = prev.findIndex((item) => item.id === activeTab);
// 获取页签列表中上一个激活的页签
const lastIndex = prev.findIndex((item) => item.id === oldTab);
// 若存在历史激活的页签,保存/更新其scrollTop
if (lastIndex !== -1) {
next[lastIndex].scrollTop = oldScrollTop.current || document.documentElement.scrollTop;
}
// 若新页签已在页签列表,更新scrollTop
if (currIndex !== -1) {
currTabItem.scrollTop = next[currIndex]?.scrollTop || 0;
}
/**
* 更新已有页签信息:
* 1、新页签的父/子路由已在页签列表中
* 2、页签已在页签列表中,且当前页签配置项openNew(是否打开新页签)为false
* 3、上一个激活的页签配置skipNew(跳转其他页面时打开新 tab)为false
*/
if (
preIndex !== -1 ||
(currIndex !== -1 && String(currTabItem?.tabProps?.openNew) === 'false') ||
String(next[lastIndex]?.tabProps?.skipNew) === 'false'
) {
const index = preIndex !== -1 ? preIndex : currIndex;
next[index] = currTabItem;
// 清除上个路由的页面缓存
aliveController.drop(oldTab);
} else {
// 新页签加入页签列表
next = [...prev, currTabItem];
}
return uniqueFunc(next, 'id'); // 对象数组通过id属性进行去重
});
} else {
history.push('/templates/list');
}
const currTab = oldTabItems?.find((item) => item.pathname === currTabItem.pathname);
// 更新完页签列表信息后激活最新选中的页签
setTimeout(() => {
document.documentElement.scrollTop = currTab?.scrollTop || 0;
activateTab();
}, 200);
}
}, [location.pathname, location.search]);
// 任何 Tab 变动,激活正确的 Tab,并更新缓存
useEffect(() => {
if (!isEmptyArray(tabItems)) {
activateTab();
sessionStorage.setItem(`${PROJECT_KEY}-tabPages`, JSON.stringify(tabItems));
}
}, [tabItems]);
// 当前激活页签id更新后,同步数据到model中
useEffect(() => {
setTabObj({
removeTab,
activeTab,
removePageCache: () => aliveController.drop(activeTab),
});
}, [activeTab]);
5.页签切换保留位置与数据
const oldScrollTop = useRef(); // 记录上一个页面的滚动高度
// 切换 Tab
const switchTab = useMemoizedFn((newActiveTab) => {
const currTab = getCurrTab(newActiveTab);
if (currTab) {
// 切换页签前记录上个tab页的滚动高度
oldScrollTop.current = document.documentElement.scrollTop;
history.push(currTab.pathname);
setActiveTab(newActiveTab);
}
});
// 监听路由变化,更新页签列表信息,激活页签
useEffect(() => {
if (location.pathname && initialState?.settings?.multipleTabs) {
...
// tabItems中的具体信息
const currTabItem = {
id: pathRouteProps?.id,
title: tabTitles[pathRouteProps?.id],
pathname: pathname + search,
tabProps: pathRouteProps?.tabProps, // 相关自定义页签配置
scrollTop: 0, // 对应页签滚动高度
};
...
// 若存在历史激活的页签,保存/更新其scrollTop
if (lastIndex !== -1) {
next[lastIndex].scrollTop = oldScrollTop.current || document.documentElement.scrollTop;
}
// 若新页签已在页签列表,更新scrollTop
if (currIndex !== -1) {
currTabItem.scrollTop = next[currIndex]?.scrollTop || 0;
}
setTimeout(() => {
// 激活新页签同时设置新页签的滚动高度
document.documentElement.scrollTop = currTab?.scrollTop || 0;
activateTab();
}, 200);
}
}, [location.pathname, location.search]);
6.配置刷新浏览器前的提示
浏览器窗口关闭或者刷新时触发:onbeforeunload事件
// 拦截判断是否离开当前页面
const beforeunload = (e) => {
let confirmationMessage = '请注意,刷新页签将丢失您当前输入的内容,是否继续?';
(e || window.event).returnValue = confirmationMessage;
return confirmationMessage;
};
// 监听路由变化时通过tabProps配置项closeTip判断对应页面是否需要绑定onbeforeunload事件
useEffect(() => {
if (location.pathname && initialState?.settings?.multipleTabs) {
const { pathname, search } = location;
const pathRouteProps = getPathRouteProps(
location.pathname,
Object.keys(allMenuObj)
?.map((key) => allMenuObj[key])
?.filter((item) => !item?.path?.includes('*')),
);
// 刷新前提示
if (pathRouteProps?.tabProps?.closeTip) {
window.onbeforeunload = beforeunload;
} else {
window.onbeforeunload = null;
}
}
}, [location.pathname, location.search]);
// 销毁时清空事件
useEffect(() => {
return () => {
window.onbeforeunload = null;
};
}, []);
7.实现右键菜单功能
右键菜单功能
- 关闭选中标签
- 关闭右侧标签页
- 关闭其他标签页
// 移除 Tab removeTab 在上面第3小点
// 关闭其他tab页
const removeOtherTabs = useMemoizedFn((tabKey) => {
const newTabItems = tabItems.filter(
(item) => item.id === tabKey || String(item?.tabProps?.closable) === 'false',
);
setTabItems(newTabItems);
switchTab(tabKey);
});
// 关闭右侧tab页
const removeRightTabs = useMemoizedFn((tabKey) => {
const prevTabs = [...tabItems];
const findIndex = tabItems?.findIndex((item) => item?.id === tabKey);
const newTabs = prevTabs.slice(0, findIndex + 1);
setTabItems(
newTabs.concat(prevTabs?.filter((item) => String(item?.tabProps?.closable) === 'false')),
);
switchTab(tabKey);
});
自定义右键菜单具体实现
1、禁用浏览器弹出默认菜单的行为,通过阻止contextMenu事件的默认行为,并同时触发自定义菜单的显示
2、通过Dropdown实现菜单样式,触发器trigger
配置contextMenu
,从而实现鼠标右键触发下拉菜单
const RIGHT_MENU_CONFIG = {
CURRENT: {
code: 'CURRENT',
desc: '关闭标签页',
},
OTHER: {
code: 'OTHER',
desc: '关闭其他标签页',
},
RIGHT: {
code: 'RIGHT',
desc: '关闭右侧标签页',
},
};
const handleTabsMenuClick = useMemoizedFn((tabKey) => (event) => {
const { key, domEvent } = event;
domEvent.stopPropagation();
switch (key) {
case RIGHT_MENU_CONFIG.CURRENT.code:
removeTab(tabKey);
break;
case RIGHT_MENU_CONFIG.OTHER.code:
removeOtherTabs(tabKey);
break;
case RIGHT_MENU_CONFIG.RIGHT.code:
removeRightTabs(tabKey);
break;
}
});
const setMenu = useMemoizedFn((tabKey, index) => {
const currTab = getCurrTab(tabKey);
return (
<Menu onClick={handleTabsMenuClick(tabKey)}>
<Menu.Item
disabled={tabItems.length === 1 || String(currTab?.tabProps?.closable) === 'false'}
key={RIGHT_MENU_CONFIG.CURRENT.code}
>
{RIGHT_MENU_CONFIG.CURRENT.desc}
</Menu.Item>
<Menu.Item disabled={tabItems.length === 1} key={RIGHT_MENU_CONFIG.OTHER.code}>
{RIGHT_MENU_CONFIG.OTHER.desc}
</Menu.Item>
<Menu.Item disabled={tabItems.length === index + 1} key={RIGHT_MENU_CONFIG.RIGHT.code}>
{RIGHT_MENU_CONFIG.RIGHT.desc}
</Menu.Item>
</Menu>
);
});
// 定义右键菜单组件
const setTab = useMemoizedFn((tabTitle, tabKey, index) => (
<span onContextMenu={(event) => event.preventDefault()}>
<Dropdown overlay={setMenu(tabKey, index)} trigger={['contextMenu']}>
<span className={styles.tabTitle}>{tabTitle}</span>
</Dropdown>
</span>
));
3、自定义hookuseGoBack
替换history.replace
方法
src/utils/hooks.js
通过查询存储浏览器浏览记录,拿到当前页的上一个路由信息并进行存储
返回上一页时通过存储的上级路由信息进行返回
返回后删除对应记录
解决了因多次切换页签再回到详情页时,返回页面会出现的逻辑错误问题
/**
* 返回上一页
*/
export function useGoBack() {
const { location } = usePageProps();
const [lastPathObj, setLastPathObj] = useState(
JSON.parse(sessionStorage.getItem(`${PROJECT_KEY}-lastPathMap`)) || {},
);
const routeHistory = localStorage.getItem(`${window.projectKey}-route-history`);
useMount(() => {
const routePathArr =
routeHistory && JSON.parse(routeHistory) instanceof Array ? JSON.parse(routeHistory) : [];
const newLastPathObj = { ...lastPathObj };
if (!newLastPathObj[location.pathname] && routePathArr?.length !== 0) {
newLastPathObj[location.pathname] = routePathArr[routePathArr?.length - 1];
}
setLastPathObj(newLastPathObj);
sessionStorage.setItem(`${PROJECT_KEY}-lastPathMap`, JSON.stringify(newLastPathObj));
});
return {
goBack: () => {
if (lastPathObj[location.pathname]) {
history.replace(lastPathObj[location.pathname]);
const newLastPathObj = { ...lastPathObj };
delete newLastPathObj[location.pathname];
sessionStorage.setItem(`${PROJECT_KEY}-lastPathMap`, JSON.stringify(newLastPathObj));
} else {
history.go(-1);
}
},
};
}
4、src/models/tabs.js
暴露出SwitchTabs中的关闭页签和移除当前页签缓存的方法
用于在组件中关闭当前页签的操作
import { useState } from 'react';
export default () => {
const [tabObj, setTabObj] = useState();
const closeTab = () => tabObj?.removeTab(tabObj?.activeTab);
const removePageCache = () => tabObj?.removePageCache();
return {
tabObj,
setTabObj,
closeTab,
removePageCache,
};
};
5、使用KeepAlive插件实现页面缓存
使用react-activation的keepalive包裹元素进行页面缓存,关闭页签或在同一页签路由跳转时清除对应的缓存
安装keepalive插件
npm i react-activation
config.js
npm i umi-plugin-keep-alive
import defaultSettings from './defaultSettings';
...
export default defineConfig({
...
// keep-alive插件
plugins: ['umi-plugin-keep-alive'],
});
SwitchTabs
import KeepAlive, { useAliveController } from 'react-activation';
// KeepAlive包裹内容
<KeepAlive name={item?.id}>{tabContents[item?.id]}</KeepAlive>
// 更新或移除tab时删除对应缓存
const aliveController = useAliveController();
aliveController.drop(tabKey); // 删除对应缓存
6、登录、退出登录前清空页面缓存
config/config.js
import { defineConfig } from '@umijs/max';
export default defineConfig({
...
define: {
...
// 定义常量
MULTIPLE_TABS: defaultSettings.multipleTabs,
},
});
登录、退出登录
import { useAliveController } from 'react-activation';
const aliveController = useAliveController();
// 登录前/退出登录前
MULTIPLE_TABS && aliveController.clear(); // 多页签模式-清空页面缓存
开发中遇到的问题记录
1、单个菜单中 有多个可跳转页面时,会出现两个tab,如/news和/news/list
已解决:通过includes判断当前页和历史tab的关系,并进行替换
2、例:从列表页打开详情页后,此时记录的上一页为列表页。此时多次切换页签再回到详情页时,记录的上一页则变成了最后一次切换到详情页前的页面路由,而非列表页,此时返回页面则会出现逻辑错误。
已解决:
1.使用 localStorage.getItem(${window.projectKey}-route-history
) 获取进入详情页时的上个页面路由并记录;
2.在sessionStorage中存储首次进入详情页时的上个页面,在需要返回上一页时使用该记录中的路由记录进行返回,返回后清除该记录(已封装hook:useGoBack)
3、打开有footer的页面时,切换其他页面,footer依然存在
已解决:给有footer配置的pageContainer
里加配置 footerToolBarProps={{ portalDom: false }}
4、列表页点击查看详情页时,会打开新tab页
已解决:routes.js需要修改配置:列表和详情页命名需要有包含关系
5、多页签在监听路由id变化时,会先用上一个页面的路由id进行请求,然后才会去请求当前页面的那个路由id,于是会出现接口请求报错的问题
解决方法1:在监听id变化时,同时判断当前路由
解决方法2:多页签修改逻辑
使用react-activation的keepalive包裹元素进行页面缓存,关闭页签或在同一页签路由跳转时清除对应的缓存
6、从二级页返回列表页时,列表页不会更新
SwitchTabs组件中逻辑优化:父子路由跳转前,清除当前页的缓存