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

打印 上一主题 下一主题

主题 1527|帖子 1527|积分 4581

Vue 的 Keep-Alive 组件是用于缓存组件的高阶组件,可以有用地提高应用性能。它能够使组件在切换时仍能保留原有的状态信息,而且有专门的生命周期方便去做额外的处理。该组件在很多场景非常有用,好比:
· tabs 缓存页面
· 分步表单
· 路由缓存
在 Vue 中,通过 KeepAlive 包裹内的组件会自动缓存下来, 此中只能有一个直接子组件。
  1. <KeepAlive>
  2.   // <component 语法相当于 React的{showA ? <A /> : <B />}
  3.    <component :is="showA ? 'A' : 'B'">
  4. </KeepAlive>
复制代码
假如就这样写,勉强能实现要求,但会带来以下题目:
· 第一次挂载时每个子组件都会渲染一遍
· 父组件 render ,会导致子组件 render ,即使该组件目前是隐藏状态
· 对实际 dom 结构具有侵入式,如会为每个子组件包一层 div 用来控制 display 样式

我们研究下antd的Tabs 组件,其 TabPane 也是通过 display 来控制显隐的, 动态设置.ant-tabs-tabpane-hidden 类来切换。
可是它并没有一次性就把所有 TabPane 渲染出来,active 过一次后再通过类名来做控制显隐,且切换 tab后,除了第一次挂载会 render ,后续切换 tab 都不会 rerender 。

为了实现与 Tabs 一样的效果,我们稍加改造 StyleKeepAlive 组件, 对传入的 children 包裹一层 ShouldRender 组件,该组件实现初次挂载时只渲染当前激活的子组件, 且只有在组件激活时才会进行 rerender 。
  1. const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {
  2.     return (
  3. import { unstable_Activity as Offscreen } from "react";<>
  4. import { unstable_Activity as Offscreen } from "react";    {React.Children.map(children, (child) => (
  5. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";
  6. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    {child}
  7. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";
  8. import { unstable_Activity as Offscreen } from "react";    ))}
  9. import { unstable_Activity as Offscreen } from "react";</>
  10.     );
  11. }
  12. // 使用
  13. <StyleKeepAlive showComponentName={counterName}>
  14.       <Counter name="A" />
  15.       <Counter name="B" />
  16. </StyleKeepAlive>
复制代码
再来看看效果,我们实现了懒加载,但与antd 的 Tabs 差别的是, 父组件 render 时,我们对隐藏的子组件不会再进行 render , 这样能很大程度的减少性能影响。

这种方式虽然通过很简易的代码就实现了我们需要的 KeepAlive 功能,但其仍需要保留 dom 元素,在某些大数据场景下大概存在性能题目,而且以下面这种使用方法,会使开发者感觉到它是一次性渲染所有子组件。
  1. const ShouldRender = ({ children, visible }: any) => {
  2.     // 是否已经挂载
  3.     const renderedRef = useRef(false);
  4.     // 缓存子组件,避免不必要的渲染
  5.     const childRef = useRef();
  6.    
  7.     if (visible) {
  8. import { unstable_Activity as Offscreen } from "react";renderedRef.current = true;
  9. import { unstable_Activity as Offscreen } from "react";childRef.current = children();
  10.     }
  11.     if (!renderedRef.current) return null;
  12.    
  13.     return (
  14. import { unstable_Activity as Offscreen } from "react";
  15. import { unstable_Activity as Offscreen } from "react";    {childRef.current}
  16. import { unstable_Activity as Offscreen } from "react";
  17.     );
  18. };
  19. const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {
  20.     return (
  21. import { unstable_Activity as Offscreen } from "react";<>
  22. import { unstable_Activity as Offscreen } from "react";    {React.Children.map(children, (child) => {
  23. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";const visible = child.props.name === showComponentName;
  24. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";return (
  25. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    <ShouldRender visible={visible}>
  26. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";       {() => child}
  27. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    </ShouldRender>
  28. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";);
  29. import { unstable_Activity as Offscreen } from "react";    })}
  30. import { unstable_Activity as Offscreen } from "react";</>
  31.     );
  32. }
复制代码
Suspense 法

Suspense 内部使用了 OffScreen 组件,这是一个雷同于 KeepAlive 的组件,如下图所示,Suspense 的 children 会通过 OffScreen 包裹一层,由于 fallback 组件和 children 组件大概会多次进行切换。

既然 Offscreen 可以看成 React 内部的 KeepAlive 组件,那我们下面深入研究下它的特性。
由于Offscreen 目前还是unstable状态,我们安装试验性版本的 react 和 react-dom 可以去尝试这个组件。
  1. <StyleKeepAlive showComponentName={componentName}>
  2.       <Counter name="A" />
  3.       <Counter name="B" />
  4. </StyleKeepAlive>
  5. // API可改写成这种形式更加直观, 且name也不再需要传
  6. <StyleKeepAlive active={isActive}>
  7.       <Counter />
  8. </StyleKeepAlive>
  9. <StyleKeepAlive active={isActive}>
  10.       <Counter />
  11. </StyleKeepAlive>
复制代码
在组件中导入,注意:Offscreen 在本年某个版本后统一更名为了 Activity 。更名后其实更能体现出 KeepAlive 激活与失活的状态特性。
  1. pnpm add react@experimental react-dom@experimental
复制代码
Offscreen组件的使用方式也很简单,只有一个参数 mode: “visible” | ”hidden”。
  1. import { unstable_Activity as Offscreen } from "react";
复制代码
我们再看看实际的页面效果:

第一次组件挂载时,竟然把应该隐藏的组件也渲染出来了,而且也是通过样式来控制显式隐藏的。
这乍看上去是不公道的,我们期望初次挂载时不要渲染失活的组件,否则雷同于 Tabs 搭配数据哀求的场景就不太恰当了,我们不应该一次性哀求所有 Tabs 中的数据。
但先别急,我们看看useEffect的执行情况,子组件中加入以下代码debug:
  1. console.log(`${name} rendered`)useEffect(() => {    console.log(`${name} mounted`)    return () => {import { unstable_Activity as Offscreen } from "react";console.log(`${name} unmounted`)    }}, [])
复制代码

我们可以观察到,只有激活的组件A执行了 useEffect ,失活的组件B只是进行了一次pre-render 。
切换一次组件后,A组件卸载了,但是它最后又render了一次,  这是由于父组件中的 counterName更新了,导致子组件更新 。

我们得出结论:
通过 Offscreen 包裹的组件, useEffect 在每次激活时都会执行一次,且每次父组件更新都会导致其进行render。
虽然激活才会调用 useEffect 的机制解决了副作用会全部执行的题目,但对失活组件的pre-render 是否会造成性能影响?
进行下性能测试,对比使用常规 display 去实现的方法, 此中LongList 渲染20000条数据,且每条数据渲染依赖于参数 value,  value 为受控组件控制,那么当我们在父组件进行输入时,是否会有卡顿呢?
  1. const StyleKeepAliveNoPerf: React.FC = ({children, showComponentName}) => {    return (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    {React.Children.map(children, (child) => (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    {child}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    ))}import { unstable_Activity as Offscreen } from "react";    );}const LongList = ({value}: any) => {    const
  2. [list] = useState(new Array(20000).fill(0))    return (import { unstable_Activity as Offscreen } from "react";
  3. [list]import { unstable_Activity as Offscreen } from "react";    {list.map((_, index) => (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";
  4. [*]{value}: {index}import { unstable_Activity as Offscreen } from "react";    ))}import { unstable_Activity as Offscreen } from "react";
  5. [/list]    );}const PerformanceTest = () => {    const [activeComponent, setActiveComponent] = useState('A');    const [value, setValue] = useState('');    return (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    setActiveComponent((val) => (val === "A" ? "B" : "A"))import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    }import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";>import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    Toggle Counterimport { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";   
  6. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    受控组件:import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react"; setValue(e.target.value)}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";/>import { unstable_Activity as Offscreen } from "react";   
  7. import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";    {/* 1. 直接使用display进行keep-alive */}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";{/* 2. 使用Offscreen */}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";);}
复制代码
● 使用 StyleKeepAliveNoPerf

● 使用 Offscreen

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

这样看来,使用 Offscreen 不但不会有性能影响,另有 pre-render 带来的某种意义上的性能提升。
这得益于React的 concurrent 模式,高优先级的组件会打断低优先级的组件的更新,用户输入事件拥有着最高的优先级,而 Offscreen 组件在失活时拥有着最低的优先级。如下为 Lane 模型中的优先级:

我们再与优化过的 StyleKeepAlive 组件比力,该组件对失活的组件不会进行 render,所以在进行输入时也非常流畅,但当我们切换组件渲染 LongList 时,出现了明细的卡顿掉帧,毕竟需要重新 render 一个长列表。而 Offscreen 在进行组件切换时就显得非常流畅了,只有 dispaly 改变时产生的重排导致的短暂卡顿感。
因此我们得出结论,使用Offscreen优于第一种Style方案。
由于该组件还是 unstable 的,我们无法直接在项目中使用,所以我们需要使用已经正式发布的 Suspense 去实现 Offscreen 版的 KeepAlive 。
Suspense 需要让子组件内部 throw 一个 Promise 错误来进行 children 与 fallback 间切换,那么我们只需要在激活时渲染 children , 失活时 throw Promise ,就能快速的实现 KeepAlive 。
  1. const Wrapper = ({children, active}: any) => {    const resolveRef = useRef();    if (active) {import { unstable_Activity as Offscreen } from "react";resolveRef.current && resolveRef.current();import { unstable_Activity as Offscreen } from "react";resolveRef.current = null;    } else {import { unstable_Activity as Offscreen } from "react";throw new Promise((resolve) => {import { unstable_Activity as Offscreen } from "react";   resolveRef.current = resolve;import { unstable_Activity as Offscreen } from "react";})    }    return children;}const OffscreenKeepAlive = ({children, active}: any) => {    returnimport { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";     {children}import { unstable_Activity as Offscreen } from "react";    }
复制代码
我们来看看实际效果。
初次渲染情况:

切换组件后渲染情况:

这与直接使用 Offscreen 的效果并不一致。
· 初次渲染只会渲染当前激活的组件,这是由于 Suspense 会在 render 时就抛出错误,那么当然不能把未激活的组件也 render 了
· 切换组件后,A组件的 useEffect 没有触发unmount , 也就是说,进行激活状态切换不会再去重新执行 useEffect
· 切换组件后,A组件失活,但没有进行render ,也就是说不会对失活的组件再进行渲染,也就是说没有了 pre-render 的特性
这样一来,虽然实现了 KeepAlive 功能,能够实现与我们的 StyleKeepAlive 完全一致的效果,但丢失了 Offscreen 激活/失活的生命周期,pre-render 预渲染等长处。
接下来,我们为其添加生命周期,由于失活的组件会直接被 throw 出去,子组件中的 useEffect 卸载函数不会被执行,我们需要把两个生命周期函数 useActiveEffect、useDeactiveEffect 中的回调注册给上层组件才能实现, 通过 context 通报注册函数。
  1. const Wrapper = ({children, active}: any) => {
  2.     const resolveRef = useRef();
  3.     if (active) {
  4.         resolveRef.current && resolveRef.current();
  5.         resolveRef.current = null;
  6.     } else {
  7.         throw new Promise((resolve) => {
  8.            resolveRef.current = resolve;
  9.         })
  10.     }
  11.     return children;
  12. }
  13. const OffscreenKeepAlive = ({children, active}: any) => {
  14.     return <Suspense>
  15.         <Wrapper active={active}>
  16.             {children}
  17.         </Wrapper>
  18.     </Suspense>
  19. }
复制代码
我们在上层组件 KeepAlive 中对 effects 进行生存,并监听 active 状态的变化,以执行对应的生命周期函数。
  1. const KeepAlive: React.FC = ({ 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) => {import { unstable_Activity as Offscreen } from "react";effect();      });    } else {      deactiveEffects.current.forEach((effect) => {import { unstable_Activity as Offscreen } from "react";effect();      });    }  }, [active]);  return (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";  {children}import { unstable_Activity as Offscreen } from "react";    );};
复制代码
至此,我们实现了一个相对比力完美的基于 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/

具体实现如下:
· 在某个不会被烧毁的父组件(好比根组件)上创建一个 state 用来生存所有需要 KeepAlive 的 children  ,并通过 id 标识
· KeepAlive 组件会在初次挂载时将 children 通报给父组件
· 父组件接收到 children,生存至 state 触发重新渲染,在父组件渲染所有KeepAlive children,得到真实DOM节点,将DOM节点移动至实际需要渲染的位置
· KeepAlive 组件失活时,组件烧毁,DOM节点也烧毁,但 children 是生存在父组件渲染的,所以状态得以生存
· KeepAlive 再次激活时,父组件拿到缓存的 children,重新渲染一编,完成状态切换
  1. import { Component, createContext } from 'react'const KeepAliveContext = createContext({});const withScope = WrappedComponent => props => (  {keep => })export class AliveScope extends Component {  nodes = {};  state = {};  keep = (id, children) => {    return new Promise((resolve) =>      this.setState(import { unstable_Activity as Offscreen } from "react";{import { unstable_Activity as Offscreen } from "react";  [id]: { id, children },import { unstable_Activity as Offscreen } from "react";},import { unstable_Activity as Offscreen } from "react";() => resolve(this.nodes[id])      )    );  };  render() {    return (import { unstable_Activity as Offscreen } from "react";      {this.props.children}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";  {Object.values(this.state).map(({ id, children }: any) => (import { unstable_Activity as Offscreen } from "react"; {import { unstable_Activity as Offscreen } from "react";    this.nodes[id] = node;import { unstable_Activity as Offscreen } from "react";  }}import { unstable_Activity as Offscreen } from "react";  >import { unstable_Activity as Offscreen } from "react";  {children}import { unstable_Activity as Offscreen } from "react";      ))}import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";  );  }}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)  }    // 只渲染占位元素,不渲染children  render() {    return (       {import { unstable_Activity as Offscreen } from "react";  this.placeholder = nodeimport { unstable_Activity as Offscreen } from "react";}}import { unstable_Activity as Offscreen } from "react";/>    )  }}export default withScope(ActivationKeepAlive)  // 使用  {counterName === "A" && (import { unstable_Activity as Offscreen } from "react";import { unstable_Activity as Offscreen } from "react";)}  {counterName === "B" && (import { unstable_Activity as Offscreen } from "react";  )}
复制代码
组件树如下,渲染在了 AliveScope 下,而非 ActivationKeepAlive 下。

虽然这种方法理论性可行,但实际上会有很多事变要处理,好比事件流会乱掉,父组件更新渲染也会有题目,由于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

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

张国伟

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表