项目主要技术栈及版本:
- 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的节点数据中
- 用Provider把注册函数和缓存数据全局共享
2. useOffScreen组件暴露需要缓存的组件ref注册,创建context
- 监听鼠标事件
- 弹出层组件中里注册
- 离屏保存滚动位置
- 回退时,滚动还原
看下效果
可恶,第二次缓存没存上,是因为之前离屏时清空了注册的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和变量语义等,如果还有其它场景可以留言评论,公司项目不变提供源码
参考: