前言
某天我正在三角洲战场七进七出的时候,老铁发来了一条消息:
我看着消息, 略微思考了一下...... 那么——来活了~
说明
众所周知,
Vue中的KeepAlive非常好用, 但在React中又没有
之前也有同学反馈苦其久矣,所以手动一个简易版的
实现思路
我将使用自定义Hook、改造后的Outlet和Zustand实现一个简易的KeepAlive。
所用到的技术栈版本: React v18 Zustand v5 react-router v6
当然, 你也可以使用 useContext 去管理路由状态
遵守原则, 尽可能的使你的代码变得非常有序!
Demo体验地址: XX-Admin(可能需要科学上网)
开干
Directory Structure
整个目录结构大概是这样的
src/
├── components/
│ ├── ErrorBoundary
│ ├── Layout/
│ └── KeepAliveOutlet
├── hooks/
│ └── useRouteObserver
├── pages/
├── stores/
│ └── routes
└── routes
1. The Custom Hook
自定义一个hook
useRouteObserver用来观察路由变化并存储
// @/hooks/useRouteObserver
import { useEffect } from "react";
import { useMatches, useOutlet, useLocation } from "react-router-dom";
import { get, last } from "lodash-es";
import { useRouteActions } from "@/stores/routes";
const useRouteObserver = () => {
const { updateActiveRoute, pushRoute } = useRouteActions();
/** 获取需要渲染的路由 */
const children = useOutlet();
/** 获取当前路由匹配项, 末项为当前打开的路由 */
const matches = useMatches();
// 获取当前路由路径
const { pathname, search } = useLocation();
/** 完整路径: pathname + search */
// const path = useMemo(() => `${pathname}${search}`, [pathname, search]);
useEffect(() => {
updateActiveRoute(pathname);
/** 获取当前匹配的路径 */
const activeRoute = last(matches);
/** 是否需要缓存路由 */
const keepAlive = get(activeRoute, 'handle.keepAlive', false);
/** 路由name和标题, 一般用于多页签时的展示 */
const name = get(activeRoute, 'handle.name', '');
const title = get(activeRoute, 'handle.title', '');
const route = { pathname, search, keepAlive, name, title, children };
pushRoute(route);
}, [pathname, search]);
return [pathname];
};
代码小结:
useOutlet获取需要渲染的路由useMatches获取当前所匹配到的路由(末项为最新打开的路由)- 这里的
handle是你路由的自定义数据, 你可以存储任意参数 - 自定义一个
keepAlive用来确定是否需要缓存
- 这里的
useLocation获取路由参数- 路由参数变化时存储当前渲染的路由与其参数
2. Set Up Your Store
路由的状态管理,你也可以用其他方式去管理(比如
useContext)
// @/stores/routes
import { JSXElementConstructor, ReactElement } from "react";
import { create } from "zustand";
export type RouteType = {
id?: string;
pathname: string;
search?: string;
name?: string;
title?: string;
keepAlive?: boolean;
children: ReactElement<any, string | JSXElementConstructor<any>> | null;
};
type RouteStore = {
/**
* @description 当前选中的路由pathname
*/
activeRoute: string;
/**
* @description 缓存的路由列表(当前打开的路由页签)
*/
cacheRoutes: RouteType[];
actions: {
/** @description 更新当前选中路由 */
updateActiveRoute: (pathname: string) => void;
/** @description 向路由组末尾添加数据 */
pushRoute: (route: RouteType) => void;
/** @description 向路由组前面添加数据 */
unshiftRoute: (route: RouteType) => void;
/** @description 更新指定路由数据 */
updateRoute: (route: RouteType) => void;
/** @description 移除指定路由 */
removeRoute: (route: RouteType) => void;
/** @description 清空路由 */
clearRoute: () => void;
};
};
/**
* 路由缓存管理
* - 路由变化时执行`push`
* - TODO: 若路由数量 > 10, 则清理过期路由
* - 页面刷新时本地化缓存页签省略`children`, 重新打开页面时更新`children`
*/
export const useRouteStore = create<RouteStore>((set, get) => ({
activeRoute: "",
cacheRoutes: [],
actions: {
updateActiveRoute: (activeRoute) => set({ activeRoute }),
pushRoute: (route) => {
// 查询现有路由
const existingRoute = get().cacheRoutes.find(
(item) => item.pathname === route.pathname
);
// 不存在时添加
if (!existingRoute) {
set((s) => ({ cacheRoutes: [...s.cacheRoutes, route] }));
return;
}
/**
* 现有路由`children`不存在时, 更新`children`
* search变化时, 更新`search`
*/
if (existingRoute.search !== route.search) {
get().actions.updateRoute(route);
}
},
unshiftRoute: (route) => set((s) => ({ cacheRoutes: [route, ...s.cacheRoutes] })),
updateRoute: (route) =>
set((s) => ({ cacheRoutes: s.cacheRoutes.map((item) =>item.pathname === route.pathname ? route : item) })),
removeRoute: (route) => {
set((s) => {
const cacheRoutes = s.cacheRoutes.filter((item) =>!(item.pathname === route.pathname && item.search === route.search));
return { cacheRoutes };
});
},
clearRoute: () => set({ cacheRoutes: [] }),
},
}));
export const useRouteActions = () => useRouteStore((state) => state.actions);
代码小结:
路由的增删改查罢了 此处需注意避免缓存过多路由,应适当清理(自己实现)
3. Create Your Outlet
改造
Outlet为KeepAliveOutlet
// @/components/KeepAliveOutlet
import { PropsWithChildren, ReactNode } from "react";
import styled from "styled-components";
import { useRouteStore } from "@/stores/routes";
import useRouteObserver from '@/hooks/useRouteObserver'
import ErrorBoundary from "@/components/ErrorBoundary";
/** 控制激活路由与保活路由的样式 */
const KeeperWrapper = styled.div<{ $visible: boolean; children?: ReactNode }>`
/* 解决盒子模型问题 */
${({ $visible }) => $visible && "display: contents;"}
// 调用渲染过的缓存保证切换时的渲染速度
${({ $visible }) => !$visible && "content-visibility: hidden;"}
@supports not (content-visibility: hidden) {
${({ $visible }) => !$visible && "display: none;"}
}
`;
/** 匹配路由项 */
const KeepAliveItem = ({ children, keepAlive, visible }: PropsWithChildren<KeepAliveItemProps>) => {
/** 非keepalive的元素, 直接卸载 */
if (!keepAlive) return visible ? children : null;
/** 对于keepalive元素需要保持dom的存在 */
return (
<KeeperWrapper className="keeper" $visible={visible}>
{children}
</KeeperWrapper>
);
};
/** Outlet的方式输出路由 */
export const KeepAliveOutlet = () => {
const [activePathname] = useRouteObserver();
const cacheRoutes = useRouteStore((s) => s.cacheRoutes);
return (cacheRoutes || []).map(({ pathname, keepAlive, children }) => (
<KeepAliveItem
key={pathname}
keepAlive={keepAlive}
visible={pathname === activePathname}
>
<ErrorBoundary>{children}</ErrorBoundary>
</KeepAliveItem>
));
};
代码小结:
在此处调用 useRouteObserver 并渲染存储的路由页面
利用控制样式 content-visibility: hidden; 的方式实现保活
4. Create Your Layout
创建你自己的主题框架
// @/components/Layout
import { PropsWithChildren, Suspense } from "react";
import ErrorBoundary from "@/components/ErrorBoundary";
import Fallback from "@/components/FallBack";
import { AsideLayout } from "./aside";
import { HeaderLayout } from "./header";
/** 页面基本骨架 */
const Layout = ({ children }: PropsWithChildren) => {
return (
<div className="h-screen flex overflow-hidden bg-standard-2">
<AsideLayout />
<div className="flex flex-col flex-1 overflow-hidden rounded-l-3xl bg-standard-1">
<div className="relative z-10 flex-shrink-0 flex">
<HeaderLayout />
</div>
<main className="flex-1 relative overflow-y-auto focus:outline-none">
<ErrorBoundary>
<Suspense fallback={<Fallback />}>
{children}
</Suspense>
</ErrorBoundary>
</main>
</div>
</div>
);
};
export default Layout;
代码小结:
你的页面主体框架 也就是Logo、Aside、Header、Main、Footer的组合罢了
4. Create Your Route
在你的路由系统中使用
// @/routes/
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import Layout from "@/components/Layout";
import KeepAliveOutlet from "@/components/KeepAliveOutlet";
const App = () => {
return (
<Layout>
<KeepAliveOutlet />
</Layout>
);
};
/** 通用路由体系 */
export const Routes = [
{
path: "/",
element: <App />,
children: [
{
path: "library",
element: <Library />,
// 这里存储你的路由参数
handle: { keepAlive: true, name: "Library", title: '图书馆' },
},
{
path: "notFund",
element: <NotFound />,
// 这里存储你的路由参数
handle: { keepAlive: true, name: "NotFund", title: '404' },
},
],
},
];
export const AppRoutes = () => {
const router = createBrowserRouter(Routes);
return <RouterProvider router={router} />;
};
// main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppRoutes />
</StrictMode>,
)
代码小结:
借助了react-router路由管理实现整个路由的保活, 与路由是强关联的, 如果是非路由模式就更简单了(你可以直接用Antd的Tabs组件)
总结
利用react-router库并加以改造Outlet实现一整个路由的保活与失活,采用样式更改的方式控制路由的渲染达到类似KeepAlive的效果~
⚠️此间事了, 仅作为KeppAlive的简易方案, 我相信各位彦祖们会有更好的实现方案~
最后
我们保卫了我方尖塔,战斗胜利!
Demo体验地址: XX-Admin(可能需要科学上网)
你可以尝试一下在首页更改状态后切换菜单, 内容依旧存在
检查DOM结构,我相信你已经知道其中所在!