likes
comments
collection
share

React 的 KeepAlive 探索

作者站长头像
站长
· 阅读数 67

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:佳岚

什么是 KeepAlive?

用过 Vue 的童鞋都知道 Vue 官方自带了 Keep-Alive 组件,它能够使组件在切换时仍能保留原有的状态信息,并且有专门的生命周期方便去做额外的处理。该组件在很多场景非常有用,比如:

  1. tabs 缓存页面
  2. 分步表单
  3. 路由缓存

我们先看看 Vue 中是如何使用的, 通过 KeepAlive 包裹内的组件自动就会缓存下来, 其中只能有一个直接子组件。

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

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

实现方式

1. Style暴力隐藏法

这是最简单方便的方式,直接使用 display: none 来代替组件的销毁 封装一个 StyleKeepAlive 组件,传入的 showComponentName 属性表示当前要展示的组件名,同时 children 组件都需要定义下组件名 name

const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {
    return (
        <>
            {React.Children.map(children, (child) => (
                <div
                    style={{
                        display: child.props.name === showComponentName ? "block" : "none",
                    }}
                >
                    {child}
                </div>
            ))}
        </>
    );
}

// 使用
<StyleKeepAlive showComponentName={counterName}>
      <Counter name="A" />
      <Counter name="B" />
</StyleKeepAlive>

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

  1. 第一次挂载时每个子组件都会渲染一遍。
  2. 父组件 render ,会导致子组件 render ,即使该组件目前是隐藏状态。
  3. 对实际 dom 结构具有侵入式,如会为每个子组件包一层 div 用来控制 display 样式。

React 的 KeepAlive 探索

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

React 的 KeepAlive 探索

为了实现与 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 (
        <div
            style={{
                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 , 这样能很大程度的减少性能影响。

React 的 KeepAlive 探索

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

<StyleKeepAlive showComponentName={componentName}>
      <Counter name="A" />
      <Counter name="B" />
</StyleKeepAlive>

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

2. Suspense法

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

React 的 KeepAlive 探索

既然 Offscreen 可以看成 React 内部的 KeepAlive 组件,那我们下面深入研究下它的特性。 由于Offscreen 目前还是unstable状态,我们安装试验性版本的 react 和 react-dom 可以去尝试这个组件。

pnpm add react@experimental react-dom@experimental

在组件中导入,注意:Offscreen 在今年某个版本后统一更名为了 Activity , 关联 blog 。更名后其实更能体现出 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>

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

React 的 KeepAlive 探索

第一次组件挂载时,竟然把应该隐藏的组件也给渲染出来了,而且也是通过样式来控制显式隐藏的。 这乍眼看上去是不合理的,我们期望初次挂载时不要渲染失活的组件,否则类似于 Tabs 搭配数据请求的场景就不太适合了,我们不应该一次性请求所有 Tabs 中的数据。 但我们先别急,我们看看useEffect的执行情况,子组件中加入以下代码debug:

console.log(`${name} rendered`)

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

React 的 KeepAlive 探索

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

React 的 KeepAlive 探索

我们得出结论: 通过 **Offscreen** 包裹的组件, **useEffect** 在每次激活时都会执行一次,且每次父组件更新都会导致其进行**render** 虽然激活才会调用 useEffect 的机制解决了副作用会全部执行的问题,但对失活组件的pre-render 是否会造成性能影响? 我们进行下性能测试,对比使用常规 display 去实现的方法, 其中LongList 渲染20000条数据,且每条数据渲染依赖于参数 value,  value 为受控组件控制,那么当我们在父组件进行输入时,是否会有卡顿呢?

const StyleKeepAliveNoPerf: React.FC<any> = ({children, showComponentName}) => {
    return (
        <>
            {React.Children.map(children, (child) => (
                <div
                    style={{
                        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>
                <button
                    onClick={() =>
                        setActiveComponent((val) => (val === "A" ? "B" : "A"))
                    }
                >
                    Toggle Counter
                </button>
            </p>
            <p>
                受控组件:
                <Input
                    value={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>
    );
}
  1. 使用 StyleKeepAliveNoPerf

    React 的 KeepAlive 探索

  2. 使用Offscreen

    React 的 KeepAlive 探索

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

React 的 KeepAlive 探索

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

React 的 KeepAlive 探索

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

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

由于该组件还是 unstable 的,我们无法直接在项目中使用,所以我们需要利用已经正式发布的 Suspense 去实现 Offscreen 版的 KeepAliveSuspense 需要让子组件内部 throw 一个 Promise 错误来进行 childrenfallback 间切换,那么我们只需要在激活时渲染 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>
}

我们看看实际效果 初次渲染情况:

React 的 KeepAlive 探索

切换组件后渲染情况:

React 的 KeepAlive 探索

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

  1. 初次渲染只会渲染当前激活的组件,这是因为 Suspense 会在 render 时就抛出错误,那么当然不能把未激活的组件也 render 了。
  2. 切换组件后,A组件useEffect 没有触发unmount , 也就是说,进行激活状态切换不会再去重新执行 useEffect
  3. 切换组件后,A组件失活,但没有进行render ,也就是说不会对失活的组件再进行渲染,也就是说没有了 pre-render 的特性

这样一来,虽然实现了 KeepAlive 功能,能够实现与我们的 StyleKeepAlive 完全一致的效果,但丢失了 Offscreen 激活/失活的生命周期,pre-render 预渲染等优点。 接下来,我们为其添加生命周期,由于失活的组件会直接被 throw 出去,子组件中的 useEffect 卸载函数不会被执行,我们需要把两个生命周期函数 useActiveEffectuseDeactiveEffect 中的回调注册给上层组件才能实现, 通过 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>
  );
};

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

3. DOM移动法

由于组件的状态保存的一个前提是该组件必须存在于React组件树中,也就是说必须把这个组件 render 出来,但 render 并不是意味着这个组件会存在于DOM树中,如 createPortal 能把某个组件渲染到任意一个DOM节点上,甚至是内存中的DOM节点。 那么要实现 KeepAlive,我们可以让这个组件一直存在于 React 组件树中,但不让其存在于 DOM树中。 社区中两个KeepAlive实现使用最多的库都使用了该方法,react-keep-alive, react-activation ,下面以 react-activation 最简单实现为例。完整实现见react-activation

React 的 KeepAlive 探索

具体实现:

  1. 在某个不会被销毁的父组件(比如根组件)上创建一个 state 用来保存所有需要 KeepAlive 的 children  ,并通过 id 标识
  2. KeepAlive 组件会在首次挂载时将 children 传递给父组件
  3. 父组件接收到 children,保存至 state 触发重新渲染,在父组件渲染所有KeepAlive children, 得到真实DOM节点,将DOM节点移动至实际需要渲染的位置。
  4. KeepAlive 组件失活时,组件销毁,DOM节点也销毁,但 children 是保存在父组件渲染的,所以状态得以保存。
  5. 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) => (
        <div
          key={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)
  }
  
  // 只渲染占位元素,不渲染children
  render() {
    return (
      <div
        className='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

React 的 KeepAlive 探索

虽然这种方法理论性可行,但实际上会有很多事情要处理,比如事件流会乱掉,父组件更新渲染也会有问题,因为children 实际渲染在 AliveScope 上, 要让 AliveScope 重新渲染才会使 children 重新渲染。 在 react-activation 中,也还有部分问题有待解决,如果使用 createPortal 方案,也只是 AliveScope 中免去了移动 DOM 的操作(隐藏时渲染在空标签下,显示时渲染在占位节点下)。

以上所有demo代码,见demo

参考

最后

欢迎关注【袋鼠云数栈UED团队】~ 袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

转载自:https://juejin.cn/post/7374337202652626985
评论
请登录