React没有KeepAlive?自己写一个!(附demo地址)

414 阅读5分钟

前言

某天我正在三角洲战场七进七出的时候,老铁发来了一条消息:

image.png

我看着消息, 略微思考了一下...... 那么——来活了~

说明

众所周知, Vue中的KeepAlive非常好用, 但在React中又没有
之前也有同学反馈苦其久矣,所以手动一个简易版的

实现思路

我将使用自定义Hook、改造后的OutletZustand实现一个简易的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

自定义一个hookuseRouteObserver用来观察路由变化并存储

// @/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

改造 OutletKeepAliveOutlet

// @/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路由管理实现整个路由的保活, 与路由是强关联的, 如果是非路由模式就更简单了(你可以直接用AntdTabs组件)

总结

利用react-router库并加以改造Outlet实现一整个路由的保活与失活,采用样式更改的方式控制路由的渲染达到类似KeepAlive的效果~

⚠️此间事了, 仅作为KeppAlive的简易方案, 我相信各位彦祖们会有更好的实现方案~

最后

我们保卫了我方尖塔,战斗胜利!

Demo体验地址: XX-Admin(可能需要科学上网)

你可以尝试一下在首页更改状态后切换菜单, 内容依旧存在

image.png

检查DOM结构,我相信你已经知道其中所在!