- A+
什么是 KeepAlive?
用过 Vue 的童鞋都知道 Vue 官方自带了 Keep-Alive
组件,它能够使组件在切换时仍能保留原有的状态信息,并且有专门的生命周期方便去做额外的处理。该组件在很多场景非常有用,比如:
- tabs 缓存页面
- 分步表单
- 路由缓存
我们先看看 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>
假如就这样写,勉强能实现要求,但会带来以下问题
- 第一次挂载时每个子组件都会渲染一遍。
- 父组件
render
,会导致子组件render
,即使该组件目前是隐藏状态。 - 对实际 dom 结构具有侵入式,如会为每个子组件包一层
div
用来控制display
样式。
我们研究下antd的Tabs
组件,其 TabPane
也是通过 display
来控制显隐的, 动态设置.ant-tabs-tabpane-hidden
类来切换。
可是它并没有一次性就把所有 TabPane
渲染出来,active
过一次后再通过类名来做控制显隐,且切换 tab后,除了第一次挂载会 render
,后续切换 tab 都不会 rerender
。
为了实现与 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
, 这样能很大程度的减少性能影响。
这种方式虽然通过很简易的代码就实现了我们需要的 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
的组件,如下图所示,Suspense
的 children
会通过 OffScreen
包裹一层,因为 fallback
组件和 children
组件可能会多次进行切换。
既然 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>
我们再看看实际的页面效果
第一次组件挂载时,竟然把应该隐藏的组件也给渲染出来了,而且也是通过样式来控制显式隐藏的。
这乍眼看上去是不合理的,我们期望初次挂载时不要渲染失活的组件,否则类似于 Tabs
搭配数据请求的场景就不太适合了,我们不应该一次性请求所有 Tabs
中的数据。
但我们先别急,我们看看useEffect
的执行情况,子组件中加入以下代码debug:
console.log(`${name} rendered`) useEffect(() => { console.log(`${name} mounted`) return () => { console.log(`${name} unmounted`) } }, [])
我们可以观察到,只有激活的组件A
执行了 useEffect
,失活的组件B
只是进行了一次pre-render
。
切换一次组件后,A组件
卸载了,但是它最后又render
了一次, 这是因为父组件中的 counterName
更新了,导致子组件更新 。
我们得出结论:
通过 **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> ); }
- 使用
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
。
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> }
我们看看实际效果
初次渲染情况:
切换组件后渲染情况:
这与直接使用 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
组件。
3. DOM移动法
由于组件的状态保存的一个前提是该组件必须存在于React组件树
中,也就是说必须把这个组件 render
出来,但 render
并不是意味着这个组件会存在于DOM树中,如 createPortal
能把某个组件渲染到任意一个DOM节点上,甚至是内存中的DOM节点。
那么要实现 KeepAlive,我们可以让这个组件一直存在于 React 组件树中,但不让其存在于 DOM树中。
社区中两个KeepAlive实现使用最多的库都使用了该方法,react-keep-alive
, react-activation
,下面以 react-activation
最简单实现为例。完整实现见react-activation
具体实现:
- 在某个不会被销毁的父组件(比如根组件)上创建一个
state
用来保存所有需要 KeepAlive 的children
,并通过id
标识 KeepAlive
组件会在首次挂载时将children
传递给父组件- 父组件接收到
children
,保存至state
触发重新渲染,在父组件渲染所有KeepAlivechildren
, 得到真实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) => ( <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
下
虽然这种方法理论性可行,但实际上会有很多事情要处理,比如事件流会乱掉,父组件更新渲染也会有问题,因为children 实际渲染在 AliveScope
上, 要让 AliveScope
重新渲染才会使 children 重新渲染。
在 react-activation
中,也还有部分问题有待解决,如果使用 createPortal
方案,也只是 AliveScope
中免去了移动 DOM 的操作(隐藏时渲染在空标签下,显示时渲染在占位节点下)。
以上所有demo代码,见https://stackblitz.com/~/github.com/JackWang032/react-keep-alive-demo
参考
https://v3.ice.work/docs/guide/advanced/keep-alive#缓存路由组件
https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023
https://github.com/StructureBuilder/react-keep-alive
https://github.com/CJY0208/react-activation/