react离屏缓存(keep-alive)实现

项目主要技术栈及版本:

  • react@18.2.20
  • react-dom@18.2.7
  • react-router@6.3.0

背景:项目需要实现离屏缓存,即路由前进时加载对应路由最新的页面,路由后退时展示对应路由页面上一次离开时的状态,点击菜单总是加载最新的页面。页面内容可能有tab、表格跳转、抽屉弹出层跳转,总之跳转页面时这些状态统统都要缓存下来,所以需要解决的有两点:一是组件状态,二是组件内所有有滚动状态的组件的滚动位置。

要实现这个功能,那么首先想到的肯定上谷歌了撒,找了两个现有的相对比较好的解决方案:

  • react-activation 之前项目用过,作者大大还是挺厉害的了,插件也已经完全达到生产环境使用级别了,但还是有一些坑存在,感兴趣的可以去它issue里面瞅瞅。
  • 官方文档里的Offscreen(上个月已经改名为Alive了),虽然官方说了今年年底会出个canary版本,但。。。嗯。。。 所以去大概看了下源码,发现貌似用Suspense也能实现,那试下吧。。。试试就试试。

----分割线---------------

建议先看食用上面两个缓存的实现,有个大致了解再往下观看

一、对路由进行缓存处理

针对整个页面情况,这个就相对简单点,因为只需要记录layout的滚动位置就行。

对layout组件里的Outlet进行魔法处理,用KeepAliveOutlet:

KeepAliveOutlet.tsx 复制代码
import { Offscreen } from '@/components/lib/OffScreen';
import useOffScreen from '@/components/lib/OffScreen/useOffScreen';
import { useLocation } from '@umijs/max';
import { memo } from 'react';

function KeepAliveOutlet() {
  const { outlets } = useOffScreen();
  const { key } = useLocation();
  return (
    <>
      {outlets?.map((o) => (
        <Offscreen
          key={o.key}
          mode={key === o.key ? 'visible' : 'hidden'}
          {...o}
        >
          {o.outlet}
        </Offscreen>
      ))}
    </>
  );
}
export default memo(KeepAliveOutlet);

对于是否用的umi框架的童鞋们没有影响,它用的路由就是react-router,获取路由的钩子都可直接从react-router里拿。这儿主要的就是useOffScreen和Offscreen,那去看下这个hook和组件:

Offscreen.tsx 复制代码
import { FC, Suspense, memo } from 'react';
import { Repeater } from './Repeater';
import type { IProps } from './type';

const Offscreen: FC<IProps> = (props) => {
  const { mode, children } = props;

  return (
    <Suspense fallback={null}>
      <Repeater mode={mode}>{children}</Repeater>
    </Suspense>
  );
};

export default memo(Offscreen);

这个hook主要做的就是:路由跳转(PUSH)缓存跳转前的outlet和layout滚动位置,点击菜单路由切换(REPLACE)清空所有的缓存并重置layout滚动位置,路由回退(POP)渲染缓存的outlet并滚动到缓存位置。注意这里缓存里的数据不是以location作为key,而是以location中的key作为key,这样做的目的就算是id相同的详情页 也是两个单独的缓存页面。

useOffScreen.ts 复制代码
import { history, useLocation, useOutlet } from '@umijs/max';
import { useMemoizedFn, useScroll } from 'ahooks';
import { remove } from 'lodash';
import { createContext, useEffect, useRef } from 'react';
import { useImmer } from 'use-immer';

const useOffScreen = () => {
  const [outlets, setOutlets] = useImmer<any>([]);
  const { pathname, key } = useLocation();
  const topRef = useRef<number>();
  const outlet = useOutlet();

  // layout容器滚动位置
  const layoutScrollTo = useMemoizedFn(
    (top) => document.getElementById('tz-container')?.scrollTo?.({ top }),
  );

  const { top = 0 } = useScroll(document.getElementById('tz-container')) ?? {};
  topRef.current = top;

  useEffect(() => {
    setOutlets((prev) => {
      const index = prev.findIndex((v) => v.key === key);

      const initItem = {
        scrollOffset: 0,
        key,
        pathname,
        outlet,
      };
      if (history.action === 'REPLACE') {
        prev.splice(0, prev.length);
        prev.push(initItem);
        return;
      }
      if (index < 0) {
        prev.push(initItem);
        return;
      }
      if (history.action !== 'POP') {
        remove(prev, (v) => v.key === key);
        prev.push(initItem);
        return;
      }
    });
    return () => {
      setOutlets((prev) => {
        const index = prev.findIndex((v) => v.key === key);
        if (index !== -1) {
          prev[index].scrollOffset = topRef.current;
        }
      });
    };
  }, [pathname, key]);

  useEffect(() => {
    const index = outlets.findIndex((v) => v.key === key);
    if (index > -1) {
      if (history.action !== 'POP') {
        layoutScrollTo(0);
      } else {
        layoutScrollTo(outlets[index].scrollOffset);
      }
    }
  }, [outlets, history.action]);

  return { outlets };
};

export default useOffScreen;

下面这个参考:juejin.cn/post/726866... 这儿就不展开说明了

Repeater.tsx 复制代码
import { useMemoizedFn } from 'ahooks';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import type { IProps } from './type';

export const Repeater: FC<IProps> = (props) => {
  // props
  const { mode, children } = props;
  // refs
  const resolveRef = useRef<() => void>();

  // methods
  const resolvePromise = useMemoizedFn((ignoreMode?: boolean) => {
    if (
      (ignoreMode || mode === 'visible') &&
      typeof resolveRef.current === 'function'
    ) {
      resolveRef.current();
      resolveRef.current = void 0;
    }
  });
  // effect
  useEffect(() => () => resolvePromise(true), []);

  // if (mode === 'hidden' && typeof resolveRef.current === 'undefined') {
  if (mode === 'hidden') {
    throw new Promise<void>((resolve) => (resolveRef.current = resolve));
  }

  // warning
  // if (mode === 'hidden') {
  //   console.error(
  //     navigator.language === 'zh-CN'
  //       ? `
  //         由于react的限制,由startTransition或者useDeferredValue触发的更新引起的组件挂起不会渲染回退,具体可以参考
  //         https://zh-hans.react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding
  //       `
  //       : `
  //       Due to the limitations of react, component suspension caused by updates triggered by startTransition or useDeferredValue will not render the rollback. For details, please refer to
  //       https://zh-hans.react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding
  //       `,
  //   );
  // }
  resolvePromise();

  return <>{children}</>;
};

好了看下效果吧!

二、抽屉等弹出层里的跳转缓存

抽屉总的来说也是当前路由下的组件,只是比上面的多一步,就是要记住抽屉中的所有有滚动状态的组件滚动位置,因为弹出层默认是渲染到body下的,所有layout记不住它之外的组件滚动状态。

来个相对复杂1点的demo吧,demo页面有tabs、能跳转的表格,滚动的模块(tab2下的背景红色部分)、弹出层... 看下样子,是不是发现layout内的都被缓存下来了,然而抽屉里的内容是被缓存下来了 但滚动位置却没有缓存下来,这。。。

所以就来决解下脱离根节点情况组件的滚动位置吧 解决思路:可以看到之前layout页面的滚动位置用了ahooks的useScroll来实时记录,那弹出层里差不多也是这个思路:

步骤:弹出层打开时就注册其ref,然后获取其所有子节点,监听鼠标事件实时记录所有子节点滚动位置,离屏时再保存到outlets中当前路由key的节点数据中

  1. 用Provider把注册函数和缓存数据全局共享

2. useOffScreen组件暴露需要缓存的组件ref注册,创建context

  1. 监听鼠标事件
  1. 弹出层组件中里注册
  1. 离屏保存滚动位置
  1. 回退时,滚动还原

看下效果

可恶,第二次缓存没存上,是因为之前离屏时清空了注册的ref,那就把ref也保存进outlet,回退时再还原监听

再看下效果

弹出层里每个节点都实时记录,这儿参考了react-activation,具体感兴趣的可以去探探它的源码

ini 复制代码
const saveCompScrollInOutlets = useMemoizedFn(() => {
    cacheNodesScrollRef.current = Array.apply(
      null,
      cacheNodeRef.current?.querySelectorAll('*'),
    )
      ?.filter((node) => {
        const bb =
          node.scrollWidth > node.clientWidth + 2 ||
          node.scrollHeight > node.clientHeight + 2;

        return bb;
      })
      .map((node) => [node, { x: node.scrollLeft, y: node.scrollTop }]);

  });

+2的目的是组件里有些组件高度是写si的,有时有边框有时没边框,就会有2像素的差距,这儿就索性统一减去。

记录下来后,离屏时再保存到outlets中当前路由key的节点数据中

结束语: 至此,离屏缓存功能就全部实现了,最终代码还需优化ts和变量语义等,如果还有其它场景可以留言评论,公司项目不变提供源码

参考:

相关推荐
独上归州1 小时前
Vue与React的Suspense组件对比
前端·vue.js·react.js·suspense
秦时明月之君临天下3 小时前
React和Next.js的相关内容
前端·javascript·react.js
米奇妙妙wuu4 小时前
React中 setState 是同步的还是异步的?调和阶段 setState 干了什么?
前端·javascript·react.js
李刚大人4 小时前
react-amap海量点优化
前端·react.js·前端框架
GISer_Jing1 天前
React核心功能详解(一)
前端·react.js·前端框架
FØund4041 天前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
疯狂的沙粒1 天前
如何在 React 项目中应用 TypeScript?应该注意那些点?结合实际项目示例及代码进行讲解!
react.js·typescript
鑫宝Code1 天前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
沉默璇年2 天前
react中useMemo的使用场景
前端·react.js·前端框架
红绿鲤鱼2 天前
React-自定义Hook与逻辑共享
前端·react.js·前端框架