react为什么不做keepalive,在Dan认为这是一种内存泄漏,不利于掌控
但是其实在真实的项目中还是有很多场景需求的。
keep alive 的适用场景
- 多Tabs后台,在一个Tabs下修改状态,然后跳到另外一个Tabs下面,然后在跳回去,状态不变
- 移动端经常有列表页面,点击详情之后跳到另一页面,返回之后还维持原来的分页和查询参数等信息
如何实现
看了很多实现,个人认为 这个实现方案 是比较优雅的,原作者git地址
不过这边方便理解和练习,实现了一个简易版,代码地址
一句话概括,如何实现keepalive,劫持每次访问的children,维护一个中间层缓存访问过的children,然后先把这个children通过createportal渲染到一个真实的dom节点上,但是这个节点并不挂到真实的父元素上,仅放置在内存中,当匹配规则命中之后,把内存中的dom元素挂到父dom元素上。
首先简单说下为什么切换路由不能缓存路由组件的状态,比如这里有一个根组件(渲染后生成一个div dom元素),三个路由组件(渲染后分别生成span1, span2, span3)

在路由切换时,react tree只会根据路由匹配到一个路由组件,然后生成对应的dom tree(其实中间还有fiber tree的构建,组件的状态其实是挂在fiber节点的hooks链表上的,这里就不解释了)
切换和渲染过程如下

实现 keep-alive 的核心,其实就是缓存(缓存的意思其实就是要挂在react tree上)访问过的组件,但是又不能把每个路由组件直接全部挂载到父元素上(挂上去也行,有的keep-alive方案就是挂上去 然后使用display:none隐藏,缓存的dom太多就会有严重的性能问题)。通常遇到想要缓存的问题,就会想到加一层中间层(我们将要实现的keep-alive就是一个中间层)
那么这个中间层的作用一句话概括就是:劫持 children 并将其缓存放入cache list,遍历cache list然后渲染,选择匹配到的组件,把这个组件对应的dom元素,挂载到父元素上。
注意这句话遍历cache list然后渲染
,其实就是要在keep-alive的返回值中包含这些 cacheitme,否则在react的render阶段,这些组件就被卸载了。过程如下图,是访问router1,router2,router3,router2的示意图

注意在黄色的cache list下方分别是react router节点生成span1,然后把span1挂在cache1真实DOM元素上,最后又将cache1挂载到div上面。为什么要这么做,这么做就是为了不让真实的dom元素挂载很多的DOM节点,而是先将router对应的dom挂在自己缓存内部的一个dom上(这里使用了createProtal非常方便),根据激活状态决定要不要挂载父元素上。
代码实现
tsx
import {
ReactElement,
ReactNode,
RefObject,
useLayoutEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
export interface KeepAliveAPI {
refresh: (key: string) => void;
[key: string]: (key: string) => void;
}
export interface IKeepAlive {
// 子元素
children: ReactNode | ReactElement | null;
// 当前激活的key
activeKey: string;
// 暴露给外部的ref
keepRef: RefObject<KeepAliveAPI>;
}
export interface ICacheItem {
// 劫持到的元素
element: ReactNode | ReactElement | null;
// 缓存的key值
cacheKey: string;
// 上次更新时间,可以为后续的lru做准备
lastModified: number;
// 渲染次数,真实渲染时,可以根据cacheKey + renderTime作为react渲染时的key,这样方便刷新内部状态
renderTime: number;
}
export const KeepAlive: React.FC<IKeepAlive> = ({
children,
activeKey,
keepRef,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [cacheNodes, setCacheNodes] = useState<ICacheItem[]>([]);
useImperativeHandle(keepRef, () => ({
refresh: (key: string) => {
const newCacheNodes = [...cacheNodes];
const cacheIndex = newCacheNodes.findIndex(
(cache) => cache.cacheKey === key
);
if (cacheIndex !== -1) {
const cacheNode = newCacheNodes[cacheIndex];
newCacheNodes[cacheIndex] = {
...cacheNode,
lastModified: new Date().getTime(),
renderTime: cacheNode.renderTime + 1,
};
setCacheNodes(newCacheNodes);
}
},
}));
useLayoutEffect(() => {
const cacheNodeIndex = cacheNodes.findIndex(
(cache) => cache.cacheKey === activeKey
);
if (cacheNodeIndex !== -1) {
// 直接更新cacheNode的更新时间,
setCacheNodes((pre) => {
return [
...pre.slice(0, cacheNodeIndex),
{
...cacheNodes[cacheNodeIndex],
lastModified: new Date().getTime(),
// 注意这里的element也要重新赋值,否则在children又props的情况下,一直放置的时历史props的reactnode,导致无法更新
// 比如: <KeepAlive>
// <Test outCont={count} />
// </KeepAlive>
// 不赋值children的话,Test组件的outCont不会更新
element: children,
},
...pre.slice(cacheNodeIndex + 1),
];
});
} else {
setCacheNodes((pre) => [
...pre,
{
element: children,
cacheKey: activeKey,
lastModified: new Date().getTime(),
renderTime: 1,
},
]);
}
}, [activeKey, children]);
return (
<div ref={containerRef}>
{containerRef.current
? cacheNodes.map((cache) => (
<CacheElement
key={cache.cacheKey}
isActive={activeKey === cache.cacheKey}
element={cache.element}
renderKey={cache.cacheKey + cache.renderTime}
containerRef={
containerRef as React.MutableRefObject<HTMLDivElement>
}
/>
))
: null}
</div>
);
};
interface ICacheElementProps {
isActive: boolean;
element: ReactNode | ReactElement | null;
containerRef: React.MutableRefObject<HTMLDivElement>;
renderKey: string;
}
const CacheElement: React.FC<ICacheElementProps> = ({
isActive,
element,
containerRef,
renderKey,
}) => {
const portalRef = useRef(document.createElement("div"));
useLayoutEffect(() => {
if (isActive) {
containerRef.current.appendChild(portalRef.current);
} else {
containerRef.current.removeChild(portalRef.current);
}
}, [containerRef, isActive]);
return createPortal(element, portalRef.current, renderKey);
};