100 行代码实现 react keep alive 功能

react为什么不做keepalive,在Dan认为这是一种内存泄漏,不利于掌控

但是其实在真实的项目中还是有很多场景需求的。

keep alive 的适用场景

  1. 多Tabs后台,在一个Tabs下修改状态,然后跳到另外一个Tabs下面,然后在跳回去,状态不变
  2. 移动端经常有列表页面,点击详情之后跳到另一页面,返回之后还维持原来的分页和查询参数等信息

如何实现

看了很多实现,个人认为 这个实现方案 是比较优雅的,原作者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);
};
相关推荐
老华带你飞1 小时前
音乐网站|基于SprinBoot+vue的音乐网站(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·音乐网站
是程序喵呀1 小时前
uni-app使用web-view组件APP实现返回上一页
前端·uni-app
Joker Zxc2 小时前
【前端基础】9、CSS的动态伪类(hover、visited、hover、active、focus)【注:本文只有几个粗略说明】
前端·css
2401_837088502 小时前
CSS flex:1
前端·css
发呆小天才yy6 小时前
uniapp 微信小程序使用图表
前端·微信小程序·uni-app·echarts
@PHARAOH7 小时前
HOW - 在 Mac 上的 Chrome 浏览器中调试 Windows 场景下的前端页面
前端·chrome·macos
月月大王9 小时前
easyexcel导出动态写入标题和数据
java·服务器·前端
JC_You_Know10 小时前
多语言网站的 UX 陷阱与国际化实践陷阱清单
前端·ux
Python智慧行囊10 小时前
前端三大件---CSS
前端·css
Jinuss11 小时前
源码分析之Leaflet中Marker
前端·leaflet