前言
缓存组件的本质实际上还是缓存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节点游离在内存中:
下一步就是把缓存的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组件缓存实例了:
实现流程图:
注意:目前在该流程下会导致最顶层的eleContainer出现两次:
实现被缓存的组件也可更新
被缓存的组件虽然保持了内部状态和事件处理能力,但无法接收外部props的变化。 解决方案是通过包装器模式实现props的动态更新,对缓存的组件包装一层CacheDomWrapper,该包装器内部保存了props状态并对外暴露更新回调。当外部props发生变化时,通过全局的_FlushCallbacks Map根据cacheKey找到对应的更新回调函数,将新的props传递给包装器并触发组件重新渲染。 流程图:
代码:
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>
);
}
实现组件的预渲染
此前,我们提到的缓存方式是一种运行时缓存,在组件被用到时自动缓存到内存中,这里的触发逻辑是"被使用到",也就是被渲染到了页面中。想要实现预渲染,核心逻辑就是把对应组件的"被使用到"的时机提前。所以,直接把需要提前渲染的组件放到页面中也算是一种预渲染。
但是这种方式未免不够优雅,更优雅的解决方案是创建脱离主DOM树的独立渲染环境,通过document.createElement创建游离的DOM容器,使用createRoot在这个容器中渲染组件。这种方式避免了对页面布局的干扰,不会引起重排重绘。渲染完成后,将DOM容器和React根实例存储到全局RootMap中,当后续通过cacheKey访问时可以直接复用已渲染的结果,实现真正意义上的预渲染缓存。
组件缓存的另一种简单的实现
上面提到的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包