【前端】在react中实现真正的组件缓存

409 阅读6分钟

前言

缓存组件的本质实际上还是缓存html,至于说html使用到的事件和变量,由于无论是Vue还是React都是使用Document API来创建的html元素,所以html元素是可以通过闭包获取到自身使用到的事件和变量,也就是说实际上缓存了html就等同于缓存了组件。Vue中之所以能实现组件的缓存(keep-alive)本质上是因为在Vue底层就支持。虽然我并没有读过React的源码,但是我在之前读过Vue的源码,也自己写过部分的源码,其实二者大致的逻辑应该是大差不差的,都是虚拟DOM + 闭包的方式组成一个组件,react中虚拟DOM是没办法缓存了,但我们可以缓存渲染后的真实DOM节点。由于这些DOM节点是通过JavaScript创建的,我们使用useState等hooks定义的状态变量都会被DOM节点所在的闭包保留。因此,要实现组件缓存,核心逻辑有两点:一是确保组件在不使用时不被销毁;二是将组件渲染出的DOM节点(连同其状态)缓存下来,在需要时重新插入文档中使用。

初步实现

在默认情况下,React项目使用ReactDOM.createRoot创建唯一的根节点,所有组件构成一棵完整的虚拟DOM树。当某个组件不再被使用时,React会以该组件为起点,自上而下地卸载其所有子组件。 要实现组件在不被使用时不被卸载,我们需要通过ReactDOM.createRoot创建第二棵DOM树,使目标组件脱离原有的组件树结构,从而避免被React的正常卸载流程影响,然后把需要缓存的的react节点渲染到这颗树中。

const domCache = new Map<string, HTMLDivElement>(); // 存储根节点
const rootCache = new Map<string, Root>(); // createRoot实例,用于卸载组件。

interface CacheDomProps<T = Record<string, any>> {
  cacheKey: string;
  Component: React.ComponentType<T>;
  props?: T;
}

function CacheDom<T = Record<string, any>>({ cacheKey, Component, props = {} as T }: CacheDomProps<T>): JSX.Element {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const eleContainer = useRef<React.ReactNode>(<div cache-dom-container="true" ref={containerRef} />);

  useLayoutEffect(() => {
    if (!containerRef.current) return;

    const root = createRoot(containerRef.current);
    root.render(<Component {...(props as any)} />);

    rootCache.set(cacheKey, root);
    domCache.set(cacheKey, containerRef.current);
  }, []);

  return eleContainer.current as any;
}

构建一个小小的Demo:


function Comp() {
  const [name, setName] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log("提交的名称:", name);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
      <button type="submit">提交</button>
    </form>
  );
}

export default function App() {
  const [activeTab, setActiveTab] = useState("form");
  return (
    <div>
      <button onClick={() => setActiveTab("form")}>展示form</button>
      <button onClick={() => setActiveTab("about")}>展示about</button>
      {activeTab === "form" && (
        <div className={"form"}>
          <CacheDom cacheKey="form-cache" Component={Comp}></CacheDom>
        </div>
      )}
      {activeTab === "about" && (
        <div className={"about"}>
          <CacheDom cacheKey="about-cache" Component={About}></CacheDom>
        </div>
      )}
    </div>
  );
}

function About() {
  const [num, setNum] = useState(0);
  return (
    <div className={`about`}>
      <div>copyright 2025</div>
      <div>author: jqm</div>
      <div>num: {num}</div>
      <button onClick={() => setNum(num + 1)}>增加</button>
    </div>
  );
}

可以看到,页面可以正常渲染,并且通过性能分析工具可见DOM节点游离在内存中:

image.png

下一步就是把缓存的DOM节点给应用到文档中:

function CacheDom<T = Record<string, any>>({ cacheKey, Component, props = {} as T }: CacheDomProps<T>): JSX.Element {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const eleContainer = useRef<React.ReactNode>(<div cache-dom-container="true" ref={containerRef} />);

  useLayoutEffect(() => {
    if (!rootCache.has(cacheKey)) {
      // 如果未缓存过,则创建根节点
      const root = createRoot(containerRef.current);
      root.render(<Component {...(props as any)} />);
      rootCache.set(cacheKey, root);
      domCache.set(cacheKey, containerRef.current);
    } else {
      // 如果已经缓存过,则将缓存的dom添加到当前dom中
      containerRef.current.appendChild(domCache.get(cacheKey));
    }
  }, []);

  return eleContainer.current as any;
}

现在 我们就成功实现一个最简单react组件缓存实例了:

最简缓存demo.gif 实现流程图: image.png

注意:目前在该流程下会导致最顶层的eleContainer出现两次:

image.png

实现被缓存的组件也可更新

被缓存的组件虽然保持了内部状态和事件处理能力,但无法接收外部props的变化。 解决方案是通过包装器模式实现props的动态更新,对缓存的组件包装一层CacheDomWrapper,该包装器内部保存了props状态并对外暴露更新回调。当外部props发生变化时,通过全局的_FlushCallbacks Map根据cacheKey找到对应的更新回调函数,将新的props传递给包装器并触发组件重新渲染。 流程图:

image.png 代码:

import React, { useRef, useLayoutEffect, useEffect, useState } from "react";
import { createRoot, Root } from "react-dom/client";

const domCache = new Map<string, HTMLDivElement>(); // 存储根节点
const rootCache = new Map<string, Root>(); // createRoot实例,用于卸载组件。

interface CacheDomProps<T = Record<string, any>> {
  cacheKey: string;
  Component: React.ComponentType<T>;
  props?: T;
}

const _FlushCallbacks = new Map<string, (deps: any) => void>();

function areDepsEqual(a: any[], b: any[]) {
  return a.every((value, index) => value === b[index]);
}

export function CacheDomWrapper<T = Record<string, unknown>>({
  Component,
  cacheKey,
  initProps,
}: {
  Component: React.ComponentType<T>;
  cacheKey: string;
  initProps: T;
}): React.ReactElement {
  const [, setUpdate] = useState({});
  const depsRef = useRef<T>(initProps as T);

  useEffect(() => {
    _FlushCallbacks.set(cacheKey, (deps: T) => {
      if (areDepsEqual(Object.values(depsRef.current as any), Object.values(deps as any))) return;
      depsRef.current = { ...deps };
      setUpdate({});
    });

    return () => {
      _FlushCallbacks.delete(cacheKey);
    };
  }, [cacheKey]);

  return <Component {...(depsRef.current as any)} />;
}

function CacheDom<T = Record<string, any>>({ cacheKey, Component, props = {} as T }: CacheDomProps<T>): JSX.Element {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const eleContainer = useRef<React.ReactNode>(<div cache-dom-container="true" ref={containerRef} />);

  useLayoutEffect(() => {
    if (!rootCache.has(cacheKey)) {
      // 如果未缓存过,则创建根节点
      const root = createRoot(containerRef.current);
      root.render(<CacheDomWrapper cacheKey={cacheKey} Component={Component} initProps={props} />);
      rootCache.set(cacheKey, root);
      domCache.set(cacheKey, containerRef.current);
    } else {
      // 如果已经缓存过,则将缓存的dom添加到当前dom中
      containerRef.current.appendChild(domCache.get(cacheKey));
    }
  }, []);

  useLayoutEffect(() => {
    _FlushCallbacks.get(cacheKey)?.(props);
  }, Object.values(props || {}));

  return eleContainer.current as any;
}

export default CacheDom;
export { type CacheDomProps };

测试代码:


import React, { useState } from "react";
import CacheDom from "./components/Cache";
import "./App.css";

function Comp() {
  const [name, setName] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log("提交的名称:", name);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
      <button type="submit">提交</button>
    </form>
  );
}

export default function App() {
  const [activeTab, setActiveTab] = useState("form");

  const [num, setNum] = useState(0);
  return (
    <div>
      <button onClick={() => setActiveTab("form")}>展示form</button>
      <button onClick={() => setActiveTab("about")}>展示about</button>
      <hr />
      <button onClick={() => setNum(num + 1)}>增加outerNum</button>
      {activeTab === "form" && (
        <div className={"form"}>
          <CacheDom cacheKey="form-cache" Component={Comp}></CacheDom>
        </div>
      )}
      {activeTab === "about" && (
        <div className={"about"}>
          <CacheDom cacheKey="about-cache" Component={About} props={{ outerNum: num }}></CacheDom>
        </div>
      )}
    </div>
  );
}

function About({ outerNum }: { outerNum: number }) {
  const [num, setNum] = useState(0);
  return (
    <div className={`about`}>
      <div>copyright 2025</div>
      <div>author: jqm</div>
      <div>num: {num}</div>
      <div>outerNum: {outerNum}</div>
      <button onClick={() => setNum(num + 1)}>增加</button>
    </div>
  );
}

更新测试.gif

实现组件的预渲染

此前,我们提到的缓存方式是一种运行时缓存,在组件被用到时自动缓存到内存中,这里的触发逻辑是"被使用到",也就是被渲染到了页面中。想要实现预渲染,核心逻辑就是把对应组件的"被使用到"的时机提前。所以,直接把需要提前渲染的组件放到页面中也算是一种预渲染。

image.png 但是这种方式未免不够优雅,更优雅的解决方案是创建脱离主DOM树的独立渲染环境,通过document.createElement创建游离的DOM容器,使用createRoot在这个容器中渲染组件。这种方式避免了对页面布局的干扰,不会引起重排重绘。渲染完成后,将DOM容器和React根实例存储到全局RootMap中,当后续通过cacheKey访问时可以直接复用已渲染的结果,实现真正意义上的预渲染缓存。

image.png

组件缓存的另一种简单的实现

上面提到的DOM缓存方式相对脱离React框架本身的设计思路,直接缓存html有点野。其实在React中存在着一种更符合框架特性的缓存实现方式,它基于Suspense机制实现组件的状态保持。 基本思路是利用React Suspense的一个特殊行为:当子组件抛出一个Promise时,Suspense会捕获这个Promise并暂停组件的渲染(显示fallback对应的内容),但关键是不会卸载组件实例。这种暂停状态实际上就是一种缓存形式,组件虽然不再显示,但其内部状态、事件监听器等都被完整保留在内存中。如果这个Promise永远不resolve,那么组件就会一直处于"挂起"状态而不被销毁。

const infiniteThenable = { then() {} }; // 不会结束的Promise

所以我们需要是创建一个永不resolve的Promise对象,当需要缓存组件时就抛出这个Promise,让Suspense捕获并将组件置于挂起状态。当需要恢复显示时,正常返回内容即可。

完整实现:

import React, { Suspense, useEffect } from "react";

const infiniteThenable = { then() {} };

function Halt({
  stasis,
  children,
  onActivate,
  onDeactivate,
}: {
  stasis: boolean;
  children: React.ReactNode;
  onActivate?: () => void;
  onDeactivate?: () => void;
}): React.ReactElement | null {
  useEffect(() => {
    if (stasis) {
      onDeactivate?.();
    } else {
      onActivate?.();
    }
  }, [stasis]);

  if (stasis) {
    throw infiniteThenable;
  }

  return <>{children}</>;
}

export function ReactHalt({
  stasis,
  children,
  onActivate,
  onDeactivate,
}: {
  stasis: boolean;
  children: React.ReactNode;
  onActivate?: () => void;
  onDeactivate?: () => void;
}) {
  return (
    <Suspense fallback={<></>}>
      <Halt stasis={stasis} onActivate={onActivate} onDeactivate={onDeactivate}>
        {children}
      </Halt>
    </Suspense>
  );
}

简单测试一下,可见组件内的状态是被缓存下来了的。详细的案例可见我的github仓库,并且该组件已经发布为了npm包

halt记数.gif

最后

该项目的实现我已经在github开源了,并发布了 npm包,欢迎搭建为我提issue。