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);
};
相关推荐
passerby606133 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了40 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅43 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc