写点什么

如何用 React + Rxjs 实现一个虚拟滚动组件?

  • 2019-01-23
  • 本文字数:6020 字

    阅读完需:约 20 分钟

如何用React + Rxjs实现一个虚拟滚动组件?

为什么使用虚拟列表

在我们的业务场景中遇到这么一个问题,有一个商户下拉框选择列表,我们简单的使用 antd 的 select 组件,发现每次点击下拉框,从点击到弹出会存在很严重的卡顿,在本地测试时,数据库只存在 370 条左右数据,这个量级的数据都能感到很明显的卡顿了(开发环境约 700+ms),更别提线上 2000+ 的数据了。


Antd 的 select 性能确实不敢恭维,它会简单的将全部数据 map 出来,在点击的时候初始化并保存在 document.body 下的一个 DOM 节点中缓存起来,这又带来了另一个问题,我们的场景中,商户选择列表很多模块都用到了,每次点击之后都会新生成 2000+ 的 DOM 节点,如果把这些节点都存到 document 下,会造成 DOM 节点数量暴涨。


虚拟列表就是为了解决这种问题而存在的。

虚拟列表原理

虚拟列表本质就是使用少量的 DOM 节点来模拟一个长列表。如下图左所示,不论多长的一个列表,实际上出现在我们视野中的不过只是其中的一部分,这时对我们来说,在视野外的那些 item 就不是必要的存在了,如图左中 item 5 这个元素)。即使去掉了 item 5 (如右图),对于用户来说看到的内容也完全一致。



下面我们来一步步将步骤分解,具体 DEMO 示例可以查看 Online Demo。


这里是我通过这种思想实现的一个库,功能会更完善些:


https://github.com/musicq/vist

创建适合容器高度的 DOM 元素

以上图为例,想象一个拥有 1000 元素的列表,如果使用上图左的方式的话,就需要创建 1000 个 DOM 节点添加在 document 中,而其实每次出现在视野中的元素,只有 4 个,那么剩余的 996 个元素就是浪费。而如果就只创建 4 个 DOM 节点的话,这样就能节省 996 个 DOM 节点的开销。

解题思路

真实 DOM 数量 = Math.ceil(容器高度 / 条目高度)


定义组件有如下接口:


interface IVirtualListOptions {  height: number}interface IVirtualListProps {  data$: Observable<string[]>  options$: Observable<IVirtualListOptions>}

复制代码


首先需要有一个容器高度的流来装载容器高度:


private containerHeight$ = new BehaviorSubject<number>(0)

复制代码


需要在组件 mount 之后,才能测量容器的真实高度。可以通过一个 ref 来绑定容器元素,在 componentDidMount 之后,获取容器高度,并通知 containerHeight$。


this.containerHeight$.next(virtualListContainerElm.clientHeight)

复制代码


获取了容器高度之后,根据上面的公式来计算视窗内应该显示的 DOM 数量。


const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(    map(([ch, { height }]) => Math.ceil(ch / height)))
复制代码


通过组合 actualRows 两个流,来获取到应当出现在视窗内的数据切片。


const dataInViewSlice$ = combineLatest(this.props.data$, actualRows$).pipe(    map(([data, actualRows]) => data.slice(0, actualRows)))
复制代码


这样,一个当前时刻的数据源就获取到了,订阅它来将列表渲染出来。


dataInViewSlice$.subscribe(data => this.setState({ data }))
复制代码

效果


给定的数据有 1000 条,只渲染了前 7 条数据出来,这符合预期。


现在存在另一个问题,容器的滚动条明显不符合 1000 条数据该有的高度,因为我们只有 7 条真实 DOM,没有办法将容器撑开。

撑开容器

在原生的列表实现中,我们不需要处理任何事情,只需要把 DOM 添加到 document 中就可以了,浏览器会计算容器的真实高度,以及滚动到什么位置会出现什么元素。但是虚拟列表不会,这就需要我们自行解决容器的高度问题。


为了能让容器看起来和真的拥有 1000 条数据一样,就需要将容器的高度撑开到 1000 条元素该有的高度。这一步很容易,参考下面公式。

解题思路

真实容器高度 = 数据总数 * 每条 item 的高度


将上述公式换成代码:


const scrollHeight$ = combineLatest(this.props.data$, this.props.options$).pipe(    map(([data, { height }]) => data.length * height))
复制代码

效果


以真实高度撑开容器


这样看起来就比较像有 1000 个元素的列表了。


但是滚动之后发现,下面全是空白的,由于列表只存在 7 个元素,空白是正常的。而我们期望随着滚动,元素能正确的出现在视野中。

滚动列表

这里有三种实现方式,而前两种基本一样,只有细微的差别,我们先从最初的方案说起。


###完全重刷列表


这种方案是最简单的实现,我们只需要在列表滚动到某一位置的时候,去计算出当前的视窗中列表的索引,有了索引就能得到当前时刻的数据切片,从而将数据渲染到视图中。


为了让列表效果更好,我们将渲染的真实 DOM 数量多增加 3 个。


const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(    map(([ch, { height }]) => Math.ceil(ch / height) + 3))
复制代码


首先定义一个视窗滚动事件流:


const scrollWin$ = fromEvent(virtualListElm, 'scroll').pipe(    startWith({ target: { scrollTop: 0 } }))
复制代码


在每次滚动的时候去计算当前状态的索引:


const shouldUpdate$ = combineLatest(    scrollWin$.pipe(map(() => virtualListElm.scrollTop)),    this.props.options$,    actualRows$).pipe(    // 计算当前列表中最顶部的索引    map(([st, { height }, actualRows]) => {        const firstIndex = Math.floor(st / height)        const lastIndex = firstIndex + actualRows - 1        return [firstIndex, lastIndex]    }))

复制代码


这样就能在每一次滚动的时候得到视窗内数据的起止索引了,接下来只需要根据索引算出 data 切片就好了。


const dataInViewSlice$ = combineLatest(this.props.data$, shouldUpdate$).pipe(    map(([data, [firstIndex, lastIndex]]) => data.slice(firstIndex, lastIndex + 1)));
复制代码


拿到了正确的数据,还没完,想象一下,虽然我们随着滚动的发生计算出了正确的数据切片,但是正确的数据却没有出现在正确的位置,因为他们的位置是固定不变的。


因此还需要对元素的位置做位移(逮虾户)的操作,首先修改一下传给视图的数据结构。


const dataInViewSlice$ = combineLatest(    this.props.data$,    this.props.options$,    shouldUpdate$).pipe(    map(([data, { height }, [firstIndex, lastIndex]]) => {        return data.slice(firstIndex, lastIndex + 1).map(item => ({            origin: item,            // 用来定位元素的位置            $pos: firstIndex * height,            $index: firstIndex++        }))    }));
复制代码


接下把 HTML 结构也做一下修改,将每一个元素的位移添加进去。


this.state.data.map(data => (  <div    key={data.$index}    style={{      position: 'absolute',      width: '100%',      // 根据计算出的元素位移定位元素位置      transform: `translateY(${data.$pos}px)`    }}  >    {(this.props.children as any)(data.origin)}  </div>))

复制代码


这样就完成了一个虚拟列表的基本形态和功能了。

效果如下


v1 版 - 完全重刷列表


但是这个版本的虚拟列表并不完美,它存在以下几个问题


1.计算浪费


2.DOM 节点的创建和移除

计算浪费

每次滚动都会使得 data 发生计算,虽然借助 virtual DOM 会将不必要的 DOM 修改拦截掉,但是还是会存在计算浪费的问题。


实际上我们确实应该触发更新的时机是在当前列表的索引发生了变化的时候,即开始我的列表索引为 [0, 1, 2],滚动之后,索引变为了 [1, 2, 3],这个时机是我们需要更新视图的时机。借助于 rxjs 的操作符,可以很轻松的搞定这个事情,只需要把 shouldUpdate$ 流做一次过滤操作即可。


const shouldUpdate$ = combineLatest(  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),  this.props.options$,  actualRows$).pipe(  // 计算当前列表中最顶部的索引  map(([st, { height }, actualRows]) => [Math.floor(st / height), actualRows]),  // 如果索引有改变,才触发重新 render  filter(([curIndex]) => curIndex !== this.lastFirstIndex),  // update the index  tap(([curIndex]) => this.lastFirstIndex = curIndex),  map(([firstIndex, actualRows]) => {    const lastIndex = firstIndex + actualRows - 1    return [firstIndex, lastIndex]  }))
复制代码


####效果



只在必要时渲染

DOM 节点的创建和移除

如果仔细对比会发现,每次列表发生更新之后,是会发生 DOM 的创建和删除的,如下图所示,在滚动了之后,原先位于列表中的第一个节点被移除了。



而我期望的理想的状态是,能够重用 DOM,不去删除和创建它们,这就是第二个版本的实现。

复用 DOM 重刷列表

为了达到节点的复用,我们需要将列表的 key 设置为数组索引,而非一个唯一的 id,如下:


this.state.data.map((data, i) => <div key={i}>{data}</div>)

复制代码


只需要这一点改动,再看看效果:



可以看到数据变了,但是 DOM 并没有被移除,而是被复用了,这是我想要的效果。


观察一下这个版本的实现与上一版本有何区别:



是的,这个版本,每一次 render 都会使得整个列表样式发生变化,而且还有一个问题,就是列表滚动到最后的时候,会发生 DOM 减少的情况,虽然并不影响显示,但是还是有 DOM 的创建和移除的问题存在。

复用 DOM +按需更新列表

为了能让列表只按照需要进行更新,而不是全部重刷,我们就需要明确知道有哪些 DOM 节点被移出了视野范围,操作这些视野范围外的节点来补充列表,从而完成列表的按需更新,如下图:



按需更新示意图


假设用户在向下滚动列表的时候,item 1 的 DOM 节点被移出了视野,这时我们就可以把它移动到 item 5 的位置,从而完成一次滚动的连续,这里我们只改变了元素的位置,并没有创建和删除 DOM。


dataInViewSlice、props.options三个流来计算出当前时刻的 data 切片,而视图的数据完全是根据 dataInViewSlice$ 来渲染的,所以如果想要按需更新列表,我们就需要在这个流里下手。


在容器滚动的过程中存在如下几种场景:


1.用户慢慢地向上或者向下滚动:移出视野的元素是一个接一个的;


2.用户直接跳转到列表的一个指定位置:这时整个列表都可能完全移出视野。


但是这两种场景其实都可以归纳为一种情况,都是求前一种状态与当前状态之间的索引差集。

实现

在 dataInViewSlice$ 流中需要做两步操作。第一,在初始加载,还没有数组的时候,填充一个数组出来;第二,根据滚动到当前时刻时的起止索引,计算出二者的索引差集,更新数组,这一步便是按需更新的核心所在。


先来实现第一步,只需要稍微改动一下原先的 dataInViewSlice$ 流的 map 实现即可完成初始数据的填充。


const dataSlice = this.stateDataSnapshot;if (!dataSlice.length) {  return this.stateDataSnapshow = data.slice(firstIndex, lastIndex + 1).map(item => ({    origin: item,    $pos: firstIndex * height,    $index: firstIndex++  }))}
复制代码


接下来完成按需更新数组的部分,首先需要知道滚动前后两种状态之间的索引差异,比如滚动前的索引为 [0,1,2],滚动后的索引为 [1,2,3],那么他们的差集就是 [0],说明老数组中的第一个元素被移出了视野,那么就需要用这第一个元素来补充到列表最后,成为最后一个元素。


首先将数组差集求出来:


// 获取滚动前后索引差集const diffSliceIndexes = this.getDifferenceIndexes(dataSlice, firstIndex, lastIndex);
复制代码


有了差集就可以计算新的数组组成了。还以此图为例,用户向下滚动,当元素被移除视野的时候,第一个元素(索引为 0)就变成最后一个元素(索引为 4),也就是,oldSlice [0,1,2,3] -> newSlice [1,2,3,4]。


在变换的过程中,[1,2,3] 三个元素始终是不需要动的,因此我们只需要截取不变的 [1,2,3]再加上新的索引 4 就能变成 [1,2,3,4]了。


// 计算视窗的起始索引let newIndex = lastIndex - diffSliceIndexes.length + 1;diffSliceIndexes.forEach(index => {  const item = dataSlice[index];  item.origin = data[newIndex];  item.$pos = newIndex * height;  item.$index = newIndex++;});return this.stateDataSnapshot = dataSlice;

复制代码


这样就完成了一个向下滚动的数组拼接,如下图所示,DOM 确实是只更新超出视野的元素,而没有重刷整个列表。



但是这只是针对向下滚动的,如果往上滚动,这段代码就会出问题。原因也很明显,数组在向下滚动的时候,是往下补充元素,而向上滚动的时候,应该是向上补充元素。如 [1,2,3,4] -> [0,1,2,3],对它的操作是 [1,2,3] 保持不变,而 4 号元素变成了 0 号元素,所以我们需要根据不同的滚动方向来补充数组。


先创建一个获取滚动方向的流 scrollDirection$:


// scroll direction Down/Upconst scrollDirection$ = scrollWin$.pipe(  map(() => virtualListElm.scrollTop),  pairwise(),  map(([p, n]) => n - p > 0 ? 1 : -1),  startWith(1));

复制代码


将 scrollDirection 的依赖中:


const dataInViewSlice$ = combineLatest(this.props.data$, this.options$, shouldUpdate$).pipe(  withLatestFrom(scrollDirection$))
复制代码


有了滚动方向,我们只需要修改 newIndex 就好了:


// 向下滚动时 [0,1,2,3] -> [1,2,3,4] = 3// 向上滚动时 [1,2,3,4] -> [0,1,2,3] = 0let newIndex = dir > 0 ? lastIndex - diffSliceIndexes.length + 1 : firstIndex;

复制代码


至此,一个功能完善的按需更新的虚拟列表就基本完成了,效果如下:



是不是还差了什么?


没错,我们还没有解决列表滚动到最后时会创建、删除 DOM 的问题了。


分析一下问题原因,应该能想到是 shouldUpdate 中计算出来的数,所以导致了列表数量的变化,知道了原因就好解决问题了。


我们只需要计算出数组在维持真实 DOM 数量不变的情况下,最后一屏的起始索引应为多少,再和计算出来的视窗中第一个元素的索引进行对比,取二者最小为下一时刻的起始索引。


计算最后一屏的索引时需要得知 data 的长度,所以先将 data 依赖拉进来:


const shouldUpdate$ = combineLatest(  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),  this.props.data$,  this.props.options$,  actualRows$)
复制代码


然后来计算索引:


// 计算当前列表中最顶部的索引map(([st, data, { height }, actualRows]) => {  const firstIndex = Math.floor(st / height)  // 在维持 DOM 数量不变的情况下计算出的索引  const maxIndex = data.length - actualRows < 0 ? 0 : data.length - actualRows;  // 取二者最小作为起始索引  return [Math.min(maxIndex, firstIndex), actualRows];})
复制代码


这样就真正完成了完全复用 DOM + 按需更新 DOM 的虚拟列表组件。


GitHub:https://github.com/musicq/vist


上述代码具体请看在线 DEMO:


https://stackblitz.com/edit/react-ts-virtuallist


原文链接:https://zhuanlan.zhihu.com/p/54327805


更多内容,请关注前端之巅。



2019-01-23 10:127033

评论 3 条评论

发布
用户头像
如果item的高度不一致,如何计算外层滚动容器的高度?
2019-01-24 14:14
回复
用户头像
google material 已经有虚拟化列表
2019-01-23 23:18
回复
用户头像
Adobe Flex 的List和DataGrid的渲染器模式嘛~
2019-01-23 14:33
回复
没有更多了
发现更多内容

手写webpack tapable源码,官方tapable的性能真的就一定是好的吗?

爱切图的木子老师

前端 webpack 前端面试 webpack-tapable

Go-Excelize API源码阅读(八)——UngroupSheets()

Regan Yue

Go 开源 源码刨析 8月日更 8月月更

元宇宙-原来挺简单

刘旭东

WebGL 数字孪生 元宇宙 blender Shapr3D

TiSpark 原理之下推丨TiDB 工具分享

TiDB 社区干货传送门

TiDB 查询优化及调优系列(五)调优案例实践

TiDB 社区干货传送门

使用 TiDB Cloud (DevTier) 构建 TiDB 集群

TiDB 社区干货传送门

Spark 性能调优

TiDB 社区干货传送门

性能调优

TiFlash DeltaTree Index 的设计和实现分析

TiDB 社区干货传送门

前端监控系列1| 字节的前端监控SDK是怎样设计的

字节跳动终端技术

前端 监控 sdk

vue-i18n国际化语言在项目中的使用

CRMEB

Redis让我凉在美团一面,摸爬滚打3个月,二战逆袭成功斩获Offer

冉然学Java

Java redis 数据结构 面试 美团

Linux面试最高频的5个基本问题

千锋IT教育

TiDB 论文引发的 HTAP 数据库再思考

TiDB 社区干货传送门

数据库架构选型

SpringMVC(六、声明式事务控制)

开源 springmvc 8月月更

【React源码系列】React Hydrate原理及源码剖析

爱切图的木子老师

React react源码 React服务端渲染原理 React Hydrate

Oracle SQL Language Reference手册中的错误

我不吃六安茶

ANTLR

带你了解TensorFlow pb模型常用处理方法

华为云开发者联盟

人工智能 模型

网易DBA团队出品“Mysql实用手册”正式开源,Github日下载破万

冉然学Java

Java MySQL 数据库 开源 架构

使用 Spring Boot 构建 TiDB 应用程序

TiDB 社区干货传送门

达摩克利斯之剑:开源软件的合规风险及防控策略

SEAL安全

开源安全 软件供应链安全 开源合规 软件供应链攻击

上海前端培训学习好就业难吗

小谷哥

学习大数据课程后好找工作吗?

小谷哥

TiSpark 如何扩展 Spark

TiDB 社区干货传送门

2022 CCF中国开源大会会议通知(第四轮)

CCF开源发展委员会

海泰方圆政务信息系统密码应用解决方案 保障政务业务和数据安全

电子信息发烧客

Android技术分享|【Android踩坑】怀疑人生,主线程修改UI也会崩溃?

anyRTC开发者

android 音视频 UI 移动开发 主线程

「周大福」WeOps助力周大福开启IT运维数字化转型之路

嘉为蓝鲸

karmada调度策略想要实现,这三个组件必须了解 | K8S Internals系列第4期

BoCloud博云

容器 云原生 k8s

【直播回顾】OpenHarmony知识赋能六期第五课—WiFi子系统

OpenHarmony开发者

OpenHarmony

Netty实战 -- 使用Netty实现分布式框架Dubbo RPC

Bug终结者

dubbo Netty RPC 网络通信 8月月更

什么是编译器中的常量传播?

华为云开发者联盟

开发 编译器 常量传播 基本块

如何用React + Rxjs实现一个虚拟滚动组件?_语言 & 开发_阿健大叔_InfoQ精选文章