React-Router实现Keep-alive的探索
我大部分的工作是进行中后台系统的开发,客户的需求往往都会要求实现多页签功能,方便切换与数据对比。这个功能由公司的框架组实现,公司各个部门的各个团队只要使用就行。出于好奇我问过很多开发的伙伴,但是没人真的想要了解它的实现方式。所以我打算尝试一下,利用React-Router 最新的7版本打造一个Tabs路由库。
前一段我写了一篇深入React-Router的文章。也尝试了实现keep-alive的功能,但是完全不能上到生产环境,只能作为一个demo。现在我继续沿着这条路进行探索,争取作为一个能上生产的版本。
明确需求
- 缓存页面,页面切换后,页面中的状态不消失。
- 关闭页面后再次打开是全新页面。
- 点击页签切换路由。
- 页签有限制范围,超出限制范围,前面的会删掉。
- 页签超出一定范围,出现全部删除的按钮,点击后只保留一个。
- 区分缓存区和非缓存区。
- 同时将React-Router暴露出来,避免因为用户安装不同版本的问题导致冲突。
缓存页面
缓存页面作为核心功能支撑了整个库,也是最难的部分。通过我不断的查血资料,询问AI,在我尝试了三四种方式,之后终于发现了解决问题的方法。
过程的难点是,开始我利用自定义hooks存储数据和一些处理路由的方法,在需要的地方调用获取。
想法很简单,利用useOutlet获取到当前页面的实体,利用Map存储路由的信息。但是进入后发现,所有方法必须在RouterProvider内才生效,我必须有状态管理库才能实现。为了减轻包的体积,我使用了useReducer和useContext搭建了一个小型的redux。包裹在最外层。进入的时候先保存所有数据,通过dispatch进行修改数据,这样所有的组件都能拿到最新的数据。
export const initialState = {
cacheMap: new Map(), // 缓存的页面数据
routerConfig: {}, // 页面加载器列表,用于缓存页面数据
};
export const RouterStoreContext = createContext<{
state: RouterState;
dispatch: Dispatch<RouterAction>;
}>({
state: initialState,
dispatch: () => null,
});
export const RouterSroreProvider = ({
children,
routerConfig,
}: {
children: ReactNode;
routerConfig: routerConfigTy;
}) => {
const [state, dispatch] = useImmerReducer(routerReducer, initialState);
useEffect(() => {
dispatch({ type: "SAVE_ROUTER_CONFIG", payload: routerConfig });
}, [routerConfig]);
return (
<RouterStoreContext.Provider value={{ state, dispatch }}>
{children}
</RouterStoreContext.Provider>
);
};
export const useRouterStore = () => useContext(RouterStoreContext);
有了数据之后,就可以搭建缓存页面,这里也有出现的难点是,如果缓存的是嵌套路由怎么办。我的第一个想法是,只缓存最外层页面,里面的不缓存。想法很好但是和代码逻辑产生了冲突。
我通过遍历Map对象中的数据进行存储页面,但是子路由又是单独的路由,这样切换后很难做到数据的保留。
Array.from(state.cacheMap.entries()).map(([cacheKey, cacheItem])=><div></div>
所以我默认缓存路由应该是Layout的子路由,而不需要缓存的是与Layout同一级的路由。之后问题就好解决了。核心就是监听location.pathname,当它变化就跳转。
useEffect(() => {
// 页面加载对象存在,允许打开标签页
if (state.routerConfig) {
if (location.pathname === "/") {
openTab(state.enter);
return;
}
const targetRouter = state.routerConfig[location.pathname];
if (targetRouter) {
openTab(location.pathname);
} else {
openTab(state.notFound);
}
}
}, [location.pathname, state.routerConfig]);
这里我封装了openTab作用是跳转路由,并将信息存到Map对象中。
还有一点是,进入后得检查是否是已经缓存过的,如果没有缓存,通过懒加载的方式进行加载。
const KeepOutlet: React.FC = () => {
const { state, dispatch } = useRouterStore();
const { openTab } = useTabsRouter();
const location = useLocation();
useEffect(() => {
// 页面加载对象存在,允许打开标签页
if (state.routerConfig) {
if (location.pathname === "/") {
openTab(state.enter);
return;
}
const targetRouter = state.routerConfig[location.pathname];
if (targetRouter) {
openTab(location.pathname);
} else {
openTab(state.notFound);
}
}
}, [location.pathname, state.routerConfig]);
const isPathMatch = (pathPattern: string) =>
matchPath(pathPattern, location.pathname);
return (
<>
{Array.from(state.cacheMap.entries()).map(([cacheKey, cacheItem]) => {
const isActive = isPathMatch(cacheItem.path);
return (
<div key={cacheKey} style={{ display: isActive ? "block" : "none" }}>
{cacheItem.element ? (
cacheItem.element
) : (
<Suspense fallback={<div>Loading...</div>}>
<LazyLoader
cacheItem={cacheItem}
onMount={(element) => {
dispatch({
type: "MOUNT_COMPONENT",
payload: {
path: cacheItem.path,
element,
},
});
}}
/>
</Suspense>
)}
</div>
);
})}
</>
);
};
export default KeepOutlet;
页面懒加载
这里就是将基础的懒加载扩展了一下,使其更健壮。
type LazyLoaderProps = {
cacheItem: CacheItem;
onMount: (element: React.ReactElement) => void;
};
const LazyLoader: React.FC<LazyLoaderProps> = ({ cacheItem, onMount }) => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
setIsLoading(true);
setError(null);
const loadComponent = async () => {
try {
const module = await cacheItem.loader();
const Component = module.default;
if (isMounted) {
onMount(<Component />);
setIsLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err as Error);
setIsLoading(false);
}
}
};
// 如果元素尚未加载,则加载组件
if (!cacheItem.element) {
loadComponent();
} else {
setIsLoading(false);
}
return () => {
isMounted = false;
};
}, [cacheItem, onMount]);
if (isLoading) {
return <div className="loader">加载组件...</div>;
}
if (error) {
return (
<div className="error">
<p>组件加载失败</p>
<pre>{error.message}</pre>
<button onClick={() => window.location.reload()}>重试</button>
</div>
);
}
// 如果组件已加载,直接返回缓存元素
return cacheItem.element || null;
};
export default LazyLoader;
useTabsRouter hooks
这里主要是新增和删除标签的方法,为什么要封装一个自定义hooks处理呢,因为能大大减少重复的逻辑,比如说无论是新增还是删除都需要先获取navigate。使用hooks就能解决所有问题。
const useTabsRouter = () => {
const navigate = useNavigate();
const { state, dispatch } = useRouterStore();
const openTab = (path: string) => {
const routerItem = state.routerConfig && state.routerConfig[path];
if (!routerItem) {
console.warn(`找不到路径为 ${path} 的菜单项`);
return;
}
navigate(path);
if (!state.cacheMap.has(path)) {
dispatch({
type: "ADD_TAB",
payload: {
routerItem,
path,
loader: state.routerConfig?.[path]?.loader as () => Promise<{
default: React.ComponentType;
}>,
},
});
}
};
const closeTab = (path: string, currentPath: string) => {
const { tabsList } = state;
if (tabsList.length === 1) return;
if (currentPath === path) {
const currentIndex = tabsList.findIndex((item) => item.path === path);
let targetPath = "";
if (currentIndex === 0) {
targetPath = tabsList[1]?.path || "/";
} else if (currentIndex === tabsList.length - 1) {
targetPath = tabsList[tabsList.length - 2]?.path || "/";
} else {
targetPath = tabsList[currentIndex - 1]?.path || "/";
}
navigate(targetPath);
dispatch({ type: "REMOVE_TAB", payload: path });
} else {
dispatch({ type: "REMOVE_TAB", payload: path });
}
};
const closeAllTabs = () => dispatch({ type: "REMOVE_ALL_TABS" });
return {
openTab,
closeTab,
closeAllTabs,
state,
dispatch,
};
};
export default useTabsRouter;
tabs标签页
这里我选择了自己封装,没有使用现成的组件库,主要原因还是希望减少体积。因为没有引入样式库,所以全都使用了行内样式。
const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose }) => {
const { openTab } = useTabsRouter();
return (
<div
style={{
minWidth: 80,
maxWidth: 140,
...
}}
>
<div onClick={() => !isActive && openTab(tab.path)}>
{tab.icon && (
<span style={{ height: "auto", width: 18 }}>{tab.icon}</span>
)}
<span style={{ maxWidth: 100, overflow: "hidden" }}>{tab.label}</span>
</div>
<button
style={{
background: "none",
border: "none",
width: "auto",
height: "auto",
color: "#c0c0c0",
}}
title="关闭标签页"
onClick={() => onClose()}
>
<HiX />
</button>
</div>
);
};
const TabsRouter: React.FC<TabsRouterProps> = ({ clearAllThreshold = 5 }) => {
const { state } = useRouterStore();
const { closeTab, closeAllTabs } = useTabsRouter();
const location = useLocation();
const tabsWrapperRef = useRef<HTMLDivElement>(null);
//路由标签移动
const { tabsRef, dir, speed, handleMouseMove, handleMouseLeave } =
useScrollAuto();
useEffect(() => {
const el = tabsWrapperRef.current;
if (!el) return;
const uid = `slider-${Math.random().toString(36).slice(2)}`;
el.classList.add(uid);
const style = document.createElement("style");
style.innerHTML = `
.${uid}::-webkit-scrollbar { display: none; }
.${uid} { scrollbar-width: none; -ms-overflow-style: none; }
`;
document.head.appendChild(style);
return () => {
el.classList.remove(uid);
style.remove();
};
}, []);
useEffect(() => {
if (!tabsWrapperRef.current || dir === 0) return;
let frameId: number;
const scroll = () => {
tabsWrapperRef.current!.scrollLeft += dir * speed;
frameId = requestAnimationFrame(scroll);
};
frameId = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frameId);
}, [dir, speed]);
return (
<div
style={{ width: "100%", overflow: "hidden", position: "relative" }}
ref={tabsRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<div
style={{
display: "flex",
width: "clac(100% - 100px)",
...
}}
ref={tabsWrapperRef}
className="tabs-router-tabs-wrapper"
>
<div style={{ display: "flex", marginLeft: 5 }}>
{state.tabsList.map((tab) => (
<TabItem
key={tab.path}
tab={tab}
isActive={location.pathname === tab.path}
onClose={() => closeTab(tab.path, location.pathname)}
/>
))}
</div>
{state.tabsList?.length > clearAllThreshold && (
<button
style={{
position: "absolute",
right: 0,
bottom: 3,
...
}}
onClick={closeAllTabs}
>
关闭全部
</button>
)}
</div>
</div>
);
};
export default TabsRouter;
为了解决标签页可能过长的问题,我利用获取tabs的ref,并监听鼠标的位置,做到了鼠标放置在左边,向左移动,放置在右边,向右移动。想要了解可以到我的gitHub查看源码,如果对你有帮助,可以标星。
重定向
此时我已经到了我2.5版本,但是还是有没解决的问题,在react-router 7,处理入口文件是通过加index的方式,但是到这里就不灵了,还有封装的404页面也无法使用。
想到这儿我在配置对象中添加了enter和notFound,这两个的路由需要先在定义好,填写enter和notFound的路径,通过监听的方式进行处理就能很好的解决问题。
使用方式
封装过的路由库区分两种模式,分别是Tabs和Routes模式,Routes和React-Router完全一致,关键部分在于如果选择tabs模式,需要传递Layout组件和配置对象。
配置对象主要分为page和content,page存放不需要缓存的,content会成为Layout的<Outlet/>
const routeronfig = {
enter: "/a2",
notFound: "/404",
page: {
"/a1": {
path: "/a1",
loader: () => import("./A1"),
label: "a1",
},
},
"*": {
path: "*",
loader: () => import("./404"),
},
content: {
"/a2": {
path: "/a2",
loader: () => import("./a2"),
label: "a2",
},
"/a2/b1": {
path: "/a2/b1",
loader: () => import("./a2/b1"),
label: "b1",
},
"/404": {
path: "/404",
loader: () => import("./404"),
label: "找不到",
},
},
};
createRoot(document.getElementById("root")!).render(
<StrictMode>
<NaiadRouter mode="tabs" layout={<A0 />} routerConfig={routeronfig} />
</StrictMode>
);
总结
其实非常简单用一句话说就是Map对象缓存,通过当前路由和循环遍历的页面做匹配,匹配上了display:block匹配不是display:none。
路由库是我去年就想做的,但是奈何时间不够,因为今年计划重整博客、组件库、中后台模版,才将其重新提上日程。