react-admin是一个开箱即用的中大型后台管理系统,不仅只有前端解决方案,更是提供了基于nestjs的后端解决方案。
多标签页在后台管理系统中可谓是必不可少的,他可以很方便的给用户在不同的页面间进行切换,而页面缓存可以保留用户浏览的一些信息,比如表单数据,位置信息等。
多标签页
多标签页实现也比较简单,可以直接使用antd的Tabs组件也可以自己写一个。项目使用的是基于rc-tabs组件进行扩展的,rc-tabs组件是antd的Tabs组件的底层组件。主要是增加指示器的显隐、上下/左右滚动按钮、自定义tabbar时会增加传入nodeKey和index参数功能。具体实现代码可查看RaTabs
扩展rc-tabs
这里列出的都是基于rc-tabs的扩展功能,其他功能跟rc-tabs和antd的Tabs组件保持一致。
增加指示器的显隐
在多页标签时不需要指示器需要去掉,这里通过扩展一个showInkBar prop来控制
// src/components/RaTabs/TabNavList/index.tsx
// ......
{showInkBar ? (
<div
className={classNames(`${prefixCls}-ink-bar`, {
[`${prefixCls}-ink-bar-animated`]: animated?.inkBar,
})}
style={indicatorStyle}
/>
) : null}
// ......
上下/左右滚动按钮
在多页标签里面如果页签太多可以提供向左或是向右滚动的功能,这个功能跟本身的滑动不冲突,只是增加了按钮操作。
// src/components/RaTabs/TabNavList/index.tsx
// ......
const smoothScroll = (
setState: React.Dispatch<React.SetStateAction<number>>,
offset: number,
): void => {
// 每帧滚动的距离
const scrollAmount = 20;
let remaining = Math.abs(offset);
const scrollStep = () => {
const scrollOffset =
Math.sign(offset) * Math.min(scrollAmount, remaining);
doMove(setState, scrollOffset);
remaining -= Math.abs(scrollOffset);
if (remaining > 0) {
requestAnimationFrame(scrollStep);
}
};
requestAnimationFrame(scrollStep);
};
const handleNext = () => {
if (tabPositionTopOrBottom) {
smoothScroll(setTransformLeft, -100);
} else {
smoothScroll(setTransformTop, -100);
}
};
const handlePrev = () => {
if (tabPositionTopOrBottom) {
smoothScroll(setTransformLeft, 100);
} else {
smoothScroll(setTransformTop, 100);
}
};
// ......
<ScrollButton
show={needScroll}
prefixCls={prefixCls}
position="left"
disabled={!pingLeft}
onClick={handlePrev}
ref={scrollButtonLeftRef}
/>
// ......
<ScrollButton
show={needScroll}
disabled={!pingRight}
prefixCls={prefixCls}
position="right"
onClick={handleNext}
ref={scrollButtonRightRef}
/>
// ......
增加自定义tabbar时参数
在自定义tab时需要用到tab的nodeKey和index,这里扩展一下。
//src/components/RaTabs/TabNavList/TabNode.tsx
// ......
return renderWrapper
? renderWrapper(node, props, genDataNodeKey(key), index)
: node;
KeepAlive缓存
KeepAlive缓存社区方案有很多种,这里选用的是更接近官方实现的Activity方案,其核心是使用Suspense在匹配缓存时展示缓存,不匹配展示fallback,Suspense具有天生的缓存效果,也更贴近官方的实现。主要扩展了多页签相关的功能及白名单缓存弹出层等相关功能。完整代码可参考RaKeepAlive。这里主要看缓存核心功能。
// src/components/RaKeepAlive/hooks/useKeepAlive.ts
// ......
// 这个key是更新缓存的核心,如果是缓存路由则用传入的key或者url,如果不缓存路由则被包裹在KeepAlive组件里的children必须要有一个全局唯一key,这样在组件卸载再次转载后才能根据全局缓存里面找到她
const resolvedKey = keepRoutes
? cachedKey
? cachedKey
: defaultRouteKey || uuidv4()
: children?.key || uuidv4();
useEffect(() => {
// 当需要缓存时进入if分支
if (shouldCached(resolvedKey)) {
// 这个是修复antd modal、drawer、popover、dropdown等在切换路由时会闪现面板的bug
fixStyle(() => {
setCachedComponents((prev) => {
const newCachedComponents = [...prev];
const index = newCachedComponents.findIndex(
(item) => item.key === resolvedKey,
);
// 如果缓存里面没有
if (index < 0) {
let cachedItem: CachedComponent;
const allCachedItem = allCachedComponentsRef?.current?.find?.(
(i) => i.key === resolvedKey,
);
// 全局的缓存是从Context里面取的,所有的被KeepAlive包裹的组件都会在这备份包括嵌套的
// 为什么存一个全局的cache是因为当不使用路由时组件卸载后,再次mount时需要从这里获取到缓存的组件,如果不使用全局缓存,那么找不到缓存组件会被当做新的组件缓存
// 针对缓存路由时,路由上有modal、popover、drawer等时,需要再次嵌套一个KeepAlive来缓存滚动调位置,而其中的表单则会因为路由缓存而被缓存。
// 如果从全局里面找到则用全局缓存
if (allCachedItem) {
cachedItem = allCachedItem;
} else {
// 新建一个cachedItem
cachedItem = {
component: children,
key: resolvedKey,
// refreshKey就是每个itm的key,刷新时这个key会变化
refreshKey: uuidv4(),
cachedScrollNodes: [],
el: containerRef.current as HTMLDivElement,
// 是否在刷新中,可以根据这个做loading之类的展示
refreshing: false,
};
}
// 更新缓存
newCachedComponents.push(cachedItem);
(
cachedComponentsRef as MutableRefObject<CachedComponent[]>
).current = newCachedComponents;
if (allCachedComponentsRef) {
const allCachedIndex = allCachedComponentsRef.current.findIndex(
(i) => i.key === resolvedKey,
);
// 更新全局存储的缓存
if (allCachedIndex < 0) {
allCachedComponentsRef.current.push(cachedItem);
}
}
return newCachedComponents;
}
// 如果缓存里面有
const oldCachedComponents = [
...(cachedComponentsRef as MutableRefObject<CachedComponent[]>)
.current,
];
if (allCachedComponentsRef) {
const allCachedIndex = allCachedComponentsRef.current.findIndex(
(i) => i.key === resolvedKey,
);
const cachedComponent = oldCachedComponents.find(
(i) => i.key === resolvedKey,
);
// 更新全局缓存,有可能这个缓存是被修改过后的如:滚动,表单输入等
if (allCachedIndex > -1 && cachedComponent) {
allCachedComponentsRef.current.splice(
allCachedIndex,
1,
cachedComponent,
);
}
}
return oldCachedComponents;
});
});
} else {
// 不需要缓存的走这个分支
setExcludeComponents((prev) => {
const newExcludeComponents = [...prev];
const index = newExcludeComponents.findIndex(
(item) => item.key === resolvedKey,
);
const newItem: ExcludeComponent = {
component: children,
key: resolvedKey,
refreshKey: uuidv4(),
};
if (index < 0) {
newExcludeComponents.push(newItem);
} else {
newExcludeComponents.splice(index, 1, newItem);
}
return newExcludeComponents;
});
}
}, [resolvedKey]);
// ......
多标签的KeepAlive缓存
多页签KeepAlive缓存主要就是有关多页签相关的操作如:重新加载页面、关闭当前页面、关闭其他页面等。
重新加载页面
重新加载页面核心就是利用react的key机制,key不同页面就会刷新
// src/components/RaKeepAlive/hooks/useKeepAlive.ts
// ......
const onRefreshCache = useCallback((key: string) => {
setCachedComponents((prev) => {
const newCachedComponents = [...prev];
const index = cachedComponents.findIndex((item) => item.key === key);
const newRefreshKey = uuidv4();
if (index > -1) {
// 重新加载的核心就是更新item的key,refreshing是个刷新标识,可以根据他做loading之类的展示
newCachedComponents[index].refreshKey = newRefreshKey;
newCachedComponents[index].refreshing = true;
}
cachedComponentsRef.current = newCachedComponents;
// 更新全局缓存里面的key
updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
if (index > -1) {
allCachedComponentsRef.current[index].refreshKey = newRefreshKey;
allCachedComponentsRef.current[index].refreshing = true;
}
});
return newCachedComponents;
});
// 更新refreshing标识
setTimeout(() => {
setCachedComponents((prev) => {
const newCachedComponents = [...prev];
const index = cachedComponents.findIndex((item) => item.key === key);
const newRefreshKey = uuidv4();
if (index > -1) {
newCachedComponents[index].refreshKey = newRefreshKey;
newCachedComponents[index].refreshing = false;
}
cachedComponentsRef.current = newCachedComponents;
updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
if (index > -1) {
allCachedComponentsRef.current[index].refreshKey = newRefreshKey;
allCachedComponentsRef.current[index].refreshing = false;
}
});
return newCachedComponents;
});
}, refreshInterval);
}, []);
// ......
关闭当前页
关闭当前页就是从缓存数组里面过滤掉当前页面
// src/components/RaKeepAlive/hooks/useKeepAlive.ts
// ......
// 关闭当前页就是一个数组过滤,把当前key的移除缓存数组
const onRemoveCache = useCallback((key: string) => {
setCachedComponents((prev) => {
const newCachedComponents = [...prev];
const index = cachedComponents.findIndex((item) => item.key === key);
if (index > -1) {
newCachedComponents.splice(index, 1);
}
cachedComponentsRef.current = newCachedComponents;
updateAllCachedComponents(key, (allCachedComponentsRef, index) => {
if (index > -1) {
allCachedComponentsRef.current.splice(index, 1);
}
});
return newCachedComponents;
});
}, []);
// ......
关闭其他页面
关闭其他页面核心就是根据传入的需要过滤的keys返回剩下的页面
// src/components/RaKeepAlive/hooks/useKeepAlive.ts
// ......
// 关闭其他页也是过滤数组,把不需要的keys从缓存数组里移除
const onRemoveCacheByKeys = useCallback((keys: string[]) => {
setCachedComponents((prev) => {
const newCachedComponents = prev.filter(
(item) => !keys.includes(item.key),
);
cachedComponentsRef.current = newCachedComponents;
updateAllCachedComponents(keys, (allCachedComponentsRef, index) => {
if (index) {
allCachedComponentsRef.current =
allCachedComponentsRef.current.filter(
(item) => !keys.includes(item.key),
);
}
});
return newCachedComponents;
});
}, []);
// ......
其他功能
为了能够全局缓存,提供了一个KeepAliveRoot组件,这个组件需要放在缓存路由组件的祖先位置,该组件不能嵌套使用。其核心就是一个Provider,子组件能够拿到里面全局缓存
// src/components/RaKeepAlive/components/KeepAliveRoot/index.tsx
import { useRef } from 'react';
import type { ReactNode } from 'react';
import { KeepAliveContext } from '../../KeepAliveContext';
import type { CachedComponent } from '../../interface';
type KeepAliveRootProps = {
children: ReactNode;
};
const KeepAliveRoot = ({ children }: KeepAliveRootProps) => {
const allCachedComponentsRef = useRef<CachedComponent[]>([]);
return (
<KeepAliveContext.Provider
value={{
allCachedComponentsRef,
}}
>
{children}
</KeepAliveContext.Provider>
);
};
export default KeepAliveRoot;
KeepAliveRoot组件使用,可放在Layout中或是更靠前的位置
// src/layouts/index.tsx
import { Layout } from 'antd';
import { KeepAliveRoot } from '@/components/RaKeepAlive';
import AppContent from './components/Content';
import AppHeader from './components/Header';
import AppSider from './components/Sider';
import useMenu from './hooks/useMenu';
const BasicLayout = () => {
useMenu();
return (
// 注意KeepAliveRoot组件使用位置
<KeepAliveRoot>
<Layout className="h-full" hasSider>
<AppSider />
<Layout className="relative overflow-hidden h-full flex flex-col">
<AppHeader />
<AppContent />
</Layout>
</Layout>
</KeepAliveRoot>
);
};
export default BasicLayout;
其他功能目前还没实现,后续根据需求在进行开发
参考: