什么是 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 样式。

我们研究下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 的组件,如下图所示,Suspensechildren 会通过 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>
);
}
  1. 使用 StyleKeepAliveNoPerf

  1. 使用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 错误来进行 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>
}

我们看看实际效果

初次渲染情况:

切换组件后渲染情况:

这与直接使用 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

具体实现:

  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

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

React 的 KeepAlive 探索的更多相关文章

  1. react源码探索

    react核心部分为 虚拟dom对象 虚拟dom差异化算法 单向数据流渲染 组件生命周期 事件处理 1) 虚拟dom对象: reactDOM.render(args,element); 这个方法第一个 ...

  2. 关于clojurescript+phantomjs+react的一些探索

    这两天需要使用phantomjs+react生成些图片 React->Clojurescript: 最开始发现clojurescript中包裹react的还挺多: https://github. ...

  3. mk-js,一个基于react、nodejs的全栈框架

    前言 在这个前端技术爆炸的时代,不自己写套开源框架出门都不好意思跟别人说自己搞前端.去年年初接触的react,16年7月份在github开源了一套针对react.redux探索的项目,近期和伙伴们一起 ...

  4. Webpack笔记(三)——一款破产版脚手架的开发

    前些天一直在学习入门Webpack,后来尝试了自己搭建一下一个简单的React开发环境,后来就在想可不可以自己写一个简单的脚手架,以免每次搭建一个简单的开发环境都需要自己一个个的配置,这样很麻烦,使用 ...

  5. React Native APP结构探索

    APP结构探索 我在Github上找到了一个有登陆界面,能从网上获取新闻信息的开源APP,想来研究一下APP的结构. 附上原网址:我的第一个React Native App 具体来讲,就是研究一个复杂 ...

  6. 腾讯优测优分享 | 探索react native首屏渲染最佳实践

    腾讯优测是专业的移动云测试平台,旗下的优分享不定时提供大量移动研发及测试相关的干货~ 此文主要与以下内容相关,希望对大家有帮助. react native给了我们使用javascript开发原生app ...

  7. 探索react native首屏渲染最佳实践

    文 / 腾讯 龚麒 0.前言 react native给了我们使用javascript开发原生app的能力,在使用react native完成兴趣部落安卓端发现tab改造后,我们开始对由react n ...

  8. React+Three.js——PerspectiveCamera透视相机camera参数以及属性值探索

    因项目问题,对webgl进行了探索,当进行到3d相机时,对camera的up,position属性有部分难以理解的地方,因此做下了记录. 代码如下: import React, {Component} ...

  9. React Native探索(五)使用fetch进行网络请求

    相关文章 React Native探索系列 前言 React Native可以使用多种方式来请求网络,比如fetch.XMLHttpRequest以及基于它们封装的框架,fetch可以说是替代XMLH ...

  10. React Native探索(四)Flexbox布局详解

    相关文章 React Native探索系列 前言 在Android开发中我们有很多种布局,比如LinearLayout和RelativeLayout,同样在React Native也有它的布局,这个布 ...

随机推荐

  1. nginx重新整理——————http请求的11个阶段中的日志阶段[十九]

    前言 简单介绍一下access log 阶段. 正文 日志模块是 ngx_http_log_module,这个模块无法禁用,内置的. 结 上面是日志的用法.主要的一个内容是日志如果是变量的话,那么需要 ...

  2. redis 简单整理——redis 的列表基本结构和命令[四]

    前言 简单整理一下redis的列表. 正文 列表(list)类型是用来存储多个有序的字符串,如图2-18所示,a. b.c.d.e五个元素从左到右组成了一个有序的列表,列表中的每个字符串 称为元素(e ...

  3. 深度解读《深度探索C++对象模型》之默认构造函数

    接下来我将持续更新"深度解读<深度探索C++对象模型>"系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,主动获得推文. 提到默认构造函数,很多文章和书籍 ...

  4. 【笔记】go语言--字符与字符串处理

    [笔记]go语言--字符与字符串处理 rune相当于go的char 使用range遍历pos,rune对(遍历出来是不连续的) 使用utf8.RuneCountInString获得字符数量 使用len ...

  5. 牛客网-SQL专项训练10

    ①SQL语句中与Having子句同时使用的语句是:group by 解析: SQL语法中,having需要与group by联用,起到过滤group by后数据的作用. ②下列说法错误的是?C 解析: ...

  6. [GPT] ./ssh/known_hosts 是什么

      ~/.ssh/known_hosts 是一个SSH客户端用来存储已知的远程主机的公钥的文件,这些公钥用于验证连接到远程主机时它们是否为真实可信的主机. 当你首次通过SSH连接到一个新的远程主机时, ...

  7. [Go] golang 去除 URI 链接中的 query string 参数

    思路是使用 golang 的 net/url 包提供的方法解析url各部分,其中 URL.RawQuery 为查询参数部分,格式如 :a=b&c=d 然后我们再通过 strings.Repla ...

  8. WPF 已知问题 dotnet 6 设置 InvariantGlobalization 之后将丢失默认绑定转换导致 XAML 抛出异常

    在设置了 InvariantGlobalization 为 true 之后,将会发现原本能正常工作的 XAML 可能就会抛出异常.本文将告诉大家此问题的原因 这是有开发者在 WPF 仓库上给我报告的 ...

  9. 读书笔记 为什么要有R5G6B5颜色格式

    在 Windows 下,颜色的格式有很多,我好奇为什么要设计出 R5G6B5 这样的格式?通过阅读一些书和官方的文档,似乎了解了为什么,我在本文记录一下 颜色的格式上,常用的就是 16 位和 32 位 ...

  10. win10 uwp 选择文本转语音的机器人

    在 UWP 里,可以非常方便将某个文本转换为音频语音,转换时,将会根据输入的内容以及本机所安装的语言库选择一位机器人帮忙将输入的文本转换为语音.本文来告诉大家如何切换文本转语音的机器人,例如从默认的女 ...