React 的 KeepAlive 探索
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚
什么是 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代码,见demo
参考
最后
欢迎关注【袋鼠云数栈UED团队】~ 袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star
转载自:https://juejin.cn/post/7374337202652626985