React 的 KeepAlive 实战指南:深度解析组件缓存机制

news/2024/11/15 11:04:36/文章来源:https://www.cnblogs.com/DTinsight/p/18330653

Vue 的 Keep-Alive 组件是用于缓存组件的高阶组件,可以有效地提高应用性能。它能够使组件在切换时仍能保留原有的状态信息,并且有专门的生命周期方便去做额外的处理。该组件在很多场景非常有用,比如:

· tabs 缓存页面

· 分步表单

· 路由缓存

在 Vue 中,通过 KeepAlive 包裹内的组件会自动缓存下来, 其中只能有一个直接子组件。

<KeepAlive>// <component 语法相当于 React的{showA ? <A /> : <B />}<component :is="showA ? 'A' : 'B'">
</KeepAlive>

可惜的是 React 官方目前并没有对外正式提供的 KeepAlive 组件,但是我们可以参考 Vue 的使用方式与 API 设计,实现一套 React 版本的 KeepAlive。

下文将为大家详细介绍三种不同的实现方式。

Style 隐藏法

Style 隐藏法是最简单方便的方式,直接使用 display: none 来代替组件的销毁。

封装一个 StyleKeepAlive 组件,传入的 showComponentName 属性表示当前要展示的组件名,同时 children 组件都需要定义下组件名 name。

const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {return (<>{React.Children.map(children, (child) => (<divstyle={{display: child.props.name === showComponentName ? "block" : "none",}}>{child}</div>))}</>);
}// 使用
<StyleKeepAlive showComponentName={counterName}><Counter name="A" /><Counter name="B" />
</StyleKeepAlive>

假如就这样写,勉强能实现要求,但会带来以下问题:

· 第一次挂载时每个子组件都会渲染一遍

· 父组件 render ,会导致子组件 render ,即使该组件目前是隐藏状态

· 对实际 dom 结构具有侵入式,如会为每个子组件包一层 div 用来控制 display 样式

file

我们研究下antd的Tabs 组件,其 TabPane 也是通过 display 来控制显隐的, 动态设置.ant-tabs-tabpane-hidden 类来切换。

可是它并没有一次性就把所有 TabPane 渲染出来,active 过一次后再通过类名来做控制显隐,且切换 tab后,除了第一次挂载会 render ,后续切换 tab 都不会 rerender 。

file

为了实现与 Tabs 一样的效果,我们稍加改造 StyleKeepAlive 组件, 对传入的 children 包裹一层 ShouldRender 组件,该组件实现初次挂载时只渲染当前激活的子组件, 且只有在组件激活时才会进行 rerender 。

const ShouldRender = ({ children, visible }: any) => {// 是否已经挂载const renderedRef = useRef(false);// 缓存子组件,避免不必要的渲染const childRef = useRef();if (visible) {renderedRef.current = true;childRef.current = children();} if (!renderedRef.current) return null;return (<divstyle={{display: visible ? "block" : "none",}}>{childRef.current}</div>);
};const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {return (<>{React.Children.map(children, (child) => {const visible = child.props.name === showComponentName;return (<ShouldRender visible={visible}>{() => child}</ShouldRender>);})}</>);
}

再来看看效果,我们实现了懒加载,但与antd 的 Tabs 不同的是, 父组件 render 时,我们对隐藏的子组件不会再进行 render , 这样能很大程度的减少性能影响。

file

这种方式虽然通过很简易的代码就实现了我们需要的 KeepAlive 功能,但其仍需要保留 dom 元素,在某些大数据场景下可能存在性能问题,并且以下面这种使用方法,会使开发者感觉到它是一次性渲染所有子组件。

<StyleKeepAlive showComponentName={componentName}><Counter name="A" /><Counter name="B" />
</StyleKeepAlive>// API可改写成这种形式更加直观, 且name也不再需要传
<StyleKeepAlive active={isActive}><Counter />
</StyleKeepAlive>
<StyleKeepAlive active={isActive}><Counter />
</StyleKeepAlive>

Suspense 法

Suspense 内部使用了 OffScreen 组件,这是一个类似于 KeepAlive 的组件,如下图所示,Suspense 的 children 会通过 OffScreen 包裹一层,因为 fallback 组件和 children 组件可能会多次进行切换。

file

既然 Offscreen 可以看成 React 内部的 KeepAlive 组件,那我们下面深入研究下它的特性。

由于Offscreen 目前还是unstable状态,我们安装试验性版本的 react 和 react-dom 可以去尝试这个组件。

pnpm add react@experimental react-dom@experimental

在组件中导入,注意:Offscreen 在今年某个版本后统一更名为了 Activity 。更名后其实更能体现出 KeepAlive 激活与失活的状态特性。

import { unstable_Activity as Offscreen } from "react";

Offscreen组件的使用方式也很简单,只有一个参数 mode: “visible” | ”hidden”。

<Offscreen mode={counterName === "A" ? "visible" : "hidden"}><Counter name="A" />
</Offscreen>
<Offscreen mode={counterName === "B" ? "visible" : "hidden"}><Counter name="B" />
</Offscreen>

我们再看看实际的页面效果:

file

第一次组件挂载时,竟然把应该隐藏的组件也渲染出来了,而且也是通过样式来控制显式隐藏的。

这乍看上去是不合理的,我们期望初次挂载时不要渲染失活的组件,否则类似于 Tabs 搭配数据请求的场景就不太适合了,我们不应该一次性请求所有 Tabs 中的数据。

但先别急,我们看看useEffect的执行情况,子组件中加入以下代码debug:

console.log(`${name} rendered`)useEffect(() => {console.log(`${name} mounted`)return () => {console.log(`${name} unmounted`)}
}, [])

file

我们可以观察到,只有激活的组件A执行了 useEffect ,失活的组件B只是进行了一次pre-render 。

切换一次组件后,A组件卸载了,但是它最后又render了一次, 这是因为父组件中的 counterName更新了,导致子组件更新 。

file

我们得出结论:

通过 Offscreen 包裹的组件, useEffect 在每次激活时都会执行一次,且每次父组件更新都会导致其进行render。

虽然激活才会调用 useEffect 的机制解决了副作用会全部执行的问题,但对失活组件的pre-render 是否会造成性能影响?

进行下性能测试,对比使用常规 display 去实现的方法, 其中LongList 渲染20000条数据,且每条数据渲染依赖于参数 value, value 为受控组件控制,那么当我们在父组件进行输入时,是否会有卡顿呢?

const StyleKeepAliveNoPerf: React.FC<any> = ({children, showComponentName}) => {return (<>{React.Children.map(children, (child) => (<divstyle={{display: child.props.name === showComponentName ? "block" : "none",}}>{child}</div>))}</>);
}const LongList = ({value}: any) => {const [list] = useState(new Array(20000).fill(0))return (<ul style={{ height: 500, overflow: "auto" }}>{list.map((_, index) => (<li key={index}>{value}: {index}</li>))}</ul>);
}const PerformanceTest = () => {const [activeComponent, setActiveComponent] = useState('A');const [value, setValue] = useState('');return (<div className="card"><p><buttononClick={() =>setActiveComponent((val) => (val === "A" ? "B" : "A"))}>Toggle Counter</button></p><p>受控组件:<Inputvalue={value}onChange={(e) => setValue(e.target.value)}/></p><div>{/* 1. 直接使用display进行keep-alive */}<StyleKeepAliveNoPerf showComponentName={activeComponent}><Counter name="A" /><LongList value={value} name="B" /></StyleKeepAliveNoPerf>{/* 2. 使用Offscreen */}<Offscreen mode={activeComponent === 'A' ? 'visible' : 'hidden'}><Counter name="A" /></Offscreen><Offscreen mode={activeComponent === 'B' ? 'visible' : 'hidden'}><LongList value={value}/></Offscreen></div></div>);
}

● 使用 StyleKeepAliveNoPerf

file

● 使用 Offscreen

file

我们可以看到,使用Offscreen 下几乎没有任何性能影响,且查看dom树,即使失活的LongList组件也照样被渲染出来了。

file

这样看来,使用 Offscreen 不但不会有性能影响,还有 pre-render 带来的某种意义上的性能提升。

这得益于React的 concurrent 模式,高优先级的组件会打断低优先级的组件的更新,用户输入事件拥有着最高的优先级,而 Offscreen 组件在失活时拥有着最低的优先级。如下为 Lane 模型中的优先级:

file

我们再与优化过的 StyleKeepAlive 组件比较,该组件对失活的组件不会进行 render,所以在进行输入时也非常流畅,但当我们切换组件渲染 LongList 时,出现了明细的卡顿掉帧,毕竟需要重新 render 一个长列表。而 Offscreen 在进行组件切换时就显得非常流畅了,只有 dispaly 改变时产生的重排导致的短暂卡顿感。

因此我们得出结论,使用Offscreen优于第一种Style方案。

由于该组件还是 unstable 的,我们无法直接在项目中使用,所以我们需要利用已经正式发布的 Suspense 去实现 Offscreen 版的 KeepAlive 。

Suspense 需要让子组件内部 throw 一个 Promise 错误来进行 children 与 fallback 间切换,那么我们只需要在激活时渲染 children , 失活时 throw Promise ,就能快速的实现 KeepAlive 。

const Wrapper = ({children, active}: any) => {const resolveRef = useRef();if (active) {resolveRef.current && resolveRef.current();resolveRef.current = null;} else {throw new Promise((resolve) => {resolveRef.current = resolve;})}return children;
}const OffscreenKeepAlive = ({children, active}: any) => {return <Suspense><Wrapper active={active}>{children}</Wrapper></Suspense>
}

我们来看看实际效果。

初次渲染情况:

file

切换组件后渲染情况:

file

这与直接使用 Offscreen 的效果并不一致。

· 初次渲染只会渲染当前激活的组件,这是因为 Suspense 会在 render 时就抛出错误,那么当然不能把未激活的组件也 render 了

· 切换组件后,A组件的 useEffect 没有触发unmount , 也就是说,进行激活状态切换不会再去重新执行 useEffect

· 切换组件后,A组件失活,但没有进行render ,也就是说不会对失活的组件再进行渲染,也就是说没有了 pre-render 的特性

这样一来,虽然实现了 KeepAlive 功能,能够实现与我们的 StyleKeepAlive 完全一致的效果,但丢失了 Offscreen 激活/失活的生命周期,pre-render 预渲染等优点。

接下来,我们为其添加生命周期,由于失活的组件会直接被 throw 出去,子组件中的 useEffect 卸载函数不会被执行,我们需要把两个生命周期函数 useActiveEffect、useDeactiveEffect 中的回调注册给上层组件才能实现, 通过 context 传递注册函数。

const KeepAliveContext = React.createContext<{registerActiveEffect: (effectCallback) => void;registerDeactiveEffect: (effectCallback) => void;
}>({registerActiveEffect: () => void 0,registerDeactiveEffect: () => void 0,
});export const useActiveEffect = (callback) => {const { registerActiveEffect } = useContext(KeepAliveContext);useEffect(() => {registerActiveEffect?.(callback);}, []);
};export const useDeactiveEffect = (callback) => {const { registerDeactiveEffect } = useContext(KeepAliveContext);useEffect(() => {registerDeactiveEffect?.(callback);}, []);
};

我们在上层组件 KeepAlive 中对 effects 进行保存,并监听 active 状态的变化,以执行对应的生命周期函数。

const KeepAlive: React.FC<KeepAliveProps> = ({ active, children }) => {const activeEffects = useRef([]);const deactiveEffects = useRef([]);const registerActiveEffect = (callback) => {activeEffects.current.push(() => {callback();});};const registerDeactiveEffect = (callback) => {deactiveEffects.current.push(() => {callback();});};useEffect(() => {if (active) {activeEffects.current.forEach((effect) => {effect();});} else {deactiveEffects.current.forEach((effect) => {effect();});}}, [active]);return (<KeepAliveContext.Provider value={{ registerActiveEffect, registerDeactiveEffect }}><Suspense fallback={null}><Wrapper active={active}>{children}</Wrapper></Suspense></KeepAliveContext.Provider>);
};

至此,我们实现了一个相对比较完美的基于 Suspense 的 KeepAlive 组件。

DOM 移动法

由于组件的状态保存的一个前提是该组件必须存在于 React组件树 中,也就是说必须把这个组件 render 出来,但 render 并不是意味着这个组件会存在于DOM树中,如 createPortal 能把某个组件渲染到任意一个DOM节点上,甚至是内存中的DOM节点。

那么要实现 KeepAlive ,我们可以让这个组件一直存在于 React组件树 中,但不让其存在于 DOM树中。

社区中两个 KeepAlive 实现使用最多的库都使用了该方法,react-keep-alive, react-activation ,下面以 react-activation 最简单实现为例。完整实现见 react-activation:https://github.com/CJY0208/react-activation/

file

具体实现如下:

· 在某个不会被销毁的父组件(比如根组件)上创建一个 state 用来保存所有需要 KeepAlive 的 children ,并通过 id 标识

· KeepAlive 组件会在首次挂载时将 children 传递给父组件

· 父组件接收到 children,保存至 state 触发重新渲染,在父组件渲染所有KeepAlive children,得到真实DOM节点,将DOM节点移动至实际需要渲染的位置

· KeepAlive 组件失活时,组件销毁,DOM节点也销毁,但 children 是保存在父组件渲染的,所以状态得以保存

· KeepAlive 再次激活时,父组件拿到缓存的 children,重新渲染一编,完成状态切换

import { Component, createContext } from 'react'const KeepAliveContext = createContext({});const withScope = WrappedComponent => props => (<KeepAliveContext.Consumer>{keep => <WrappedComponent {...props} keep={keep} />}</KeepAliveContext.Consumer>
)export class AliveScope extends Component<any> {nodes = {};state = {};keep = (id, children) => {return new Promise((resolve) =>this.setState({[id]: { id, children },},() => resolve(this.nodes[id])));};render() {return (<KeepAliveContext.Provider value={this.keep}>{this.props.children}<div className='keepers-store'>{Object.values(this.state).map(({ id, children }: any) => (<divkey={id}ref={(node) => {this.nodes[id] = node;}}>{children}</div>))}</div></KeepAliveContext.Provider>);}
}class ActivationKeepAlive extends Component {constructor(props) {super(props)}placeholder: HTMLElement | null = null;componentDidMount(): void {this.init(this.props)}init = async ({ id, children, keep }) => {// keep用于向父组件传递最新的children,并返回该children对应的DOM节点const realContent = await keep(id, children)// appendChild为剪切操作this.placeholder?.appendChild(realContent)}// 只渲染占位元素,不渲染childrenrender() {return (<divclassName='keep-placeholder'ref={node => {this.placeholder = node}}/>)}
}export default withScope(ActivationKeepAlive)// 使用
<AliveScope>{counterName === "A" && (<ActivationKeepAlive id="A"><Counter name="A" /></ActivationKeepAlive>)}{counterName === "B" && (<ActivationKeepAlive id="B"><Counter name="B" /></ActivationKeepAlive>)}
</AliveScope>

组件树如下,渲染在了 AliveScope 下,而非 ActivationKeepAlive 下。

file

虽然这种方法理论性可行,但实际上会有很多事情要处理,比如事件流会乱掉,父组件更新渲染也会有问题,因为children 实际渲染在 AliveScope 上, 要让 AliveScope 重新渲染才会使 children 重新渲染。

在 react-activation 中,也还有部分问题有待解决,如果使用 createPortal 方案,也只是 AliveScope 中免去了移动 DOM 的操作(隐藏时渲染在空标签下,显示时渲染在占位节点下)。

《行业指标体系白皮书》下载地址:https://www.dtstack.com/resources/1057?src=szsm

《数栈产品白皮书》下载地址:https://www.dtstack.com/resources/1004?src=szsm

《数据治理行业实践白皮书》下载地址:https://www.dtstack.com/resources/1001?src=szsm

想了解或咨询更多有关大数据产品、行业解决方案、客户案例的朋友,浏览袋鼠云官网:https://www.dtstack.com/?src=szbky

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/773692.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

LangChain补充七:Hub和LangSmith入门

一:Hub简介 https://blog.csdn.net/DEVELOPERAA/article/details/139983286 (一)简介 1.早期 最开始的LangChainHub,类似于github一样,可以理解为LangChain 工具包 或者说 组件中心,里面提供了高质量的组件方便开发者使用。确确实实是一个分享和探索Prompt、链 和Agent的…

借助大语言模型快速升级你的 Java 应用程序

大家都知道我爱小 Q。在我“转码”的征程中,它就像上帝之手,在我本该枯燥漫长的学习进程中拉满快进条。 不仅是我,最近 Amazon Q Developer 还帮助 Amazon 一个由 5 人组成的团队在短短两天内将 1,000 多个生产应用程序从 Java 8 升级到 Java 17(每个应用程序的平均时间不到…

8080端口被占用

微服务项目,启动时发生8080端口占用,但是其他启动了并没有使用到8080端口。 cmd命令窗口查看: netstat -aon | findstr "8080"右键添加需要了解的列表查看8080端口占用的pid比如占用端口的服务是VMware NAT Servic。 在服务管理器(services.msc)中停止VMware NA…

[米联客-安路飞龙DR1-FPSOC] FPGA基础篇连载-20 读写I2C接口的RTC时钟芯片

软件版本:Anlogic -TD5.9.1-DR1_ES1.1 操作系统:WIN10 64bit 硬件平台:适用安路(Anlogic)FPGA 实验平台:米联客-MLK-L1-CZ06-DR1M90G开发板 板卡获取平台:https://milianke.tmall.com/ 登录"米联客"FPGA社区 http://www.uisrc.com 视频课程、答疑解惑! 1概述 …

【YashanDB知识库】如何远程连接、使用YashanDB?

问题现象 在各个项目实施中,我们经常遇到客户、开发人员需要连接和使用YashanDB但不知如何操作的问题,本文旨在介绍远程连接、使用YashanDB的几种方式。 问题的风险及影响 无风险 问题影响的版本 历史版本~23.2 问题发生原因 无 解决方法及规避方式 不需要规避 问题分析和处理…

GIS场景零代码拖拽式编辑,支持TMS/WMS/WMTS等多种GIS协议

在三维GIS领域,编辑场景和处理影像数据通常是一个复杂且费时的过程,但现在有了山海鲸可视化,这一切都变得简单有趣。这款免费可视化工具为您提供了零代码拖拽式编辑的体验,让您无需编程知识就能轻松创建和优化GIS场景。通过直观的界面,您只需动动鼠标就能完成从场景编辑到…

【攻防技术系列+代理转发】ICMPSH 工具

虚拟机环境搭建: 【Kali】,192.168.10.131 【window7】,192.168.10.1工具: ICMPSH(基于网络层) Wireshark实验开始前,确保两台主机可以ping通。如果遇到环境ping不通的情况,可以借鉴以下解决方案。 重启网卡:有来有回的过程,数据长度为74。 【kali】: sysctl -w net…

人大金仓踩坑指南

现在越来越多的项目要求-切换国产数据库系统,或达梦(性能高),或人大金仓(扩展性高,更稳定),跨平台如果上来就干,坑还是不少的,本人有幸踩个遍.1. 软件有效期,人大金仓可不便宜,不少政府采购网上都是标价10万+, 测试时候用专业版玩一玩就好到期后,可以直接下载其他版…

linux shell read 按列读取txt文本

前言全局说明一、说明 通常情况下,如果文本里有多列数据,会先读入,然后用 grep 和 awk 先拆分成行,在拆分成列。这样费时费力,遇到特殊字符行,还不好处理。 在解决别的问题时候,无意发现 read 有直接按列读取的功能。二、文件 2.1 存放两列数据的文件 文件名:list.txt…

什么是云计算?

云计算是一种服务的模式(商业模式):将信息类资源以服务的方式提供给用户使用,用户可以便捷的、按需计费的、弹性的从云端获取到信息技术的服务。 云计算技术栈层级:CPU CPU的组成: 1、运算器(算术逻辑单元) 2、控制器() 3、存储器(命令和需要运算的数据) 1、高速缓…

Linux shell mktemp -d命令生成临时文件

前言全局说明一、说明二、mktemp 命令 2.1 创建临时文件 mktemp 2.1 创建临时目录 mktemp -d三、命令行示例 mktemp ll /tmp/tmp.fvi5gFbDgr四、sh 脚本使用 4.1 创建 tmpfile=$(mktemp)4.2 删除 rm "$tmpfile"免责声明:本号所涉及内容仅供安全研究与教学使用,如出…

Linux shell mktemp命令生成临时文件

前言全局说明一、说明二、mktemp 命令 mktemp三、命令行示例 mktemp ll /tmp/tmp.fvi5gFbDgr四、sh 脚本使用 4.1 创建 tmpfile=$(mktemp)4.2 删除 rm "$tmpfile"免责声明:本号所涉及内容仅供安全研究与教学使用,如出现其他风险,后果自负。参考、来源: ChatGPT 作…