AI实践哪家强?来 AICon, 解锁技术前沿,探寻产业新机! 了解详情
写点什么

如何用 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:127162

评论 3 条评论

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

浪潮云说丨数据工场助力行业数据发挥生产要素新价值

云计算

对话吴军:人工智能如何推动金融行业的数字化转型

索信达控股

人工智能 大数据 金融科技 数字化转型 金融

一文回顾 Java 入门知识(下)

逆锋起笔

Java 面向对象 JAVA开发 java基础 javase

工厂管理没有头绪?那是你还没有可视化操控设备

一只数据鲸鱼

数据可视化 工业互联网 工业4.0 智慧工厂

WebRTC 用例和性能

anyRTC开发者

音视频 WebRTC RTC sdk

我的编辑器能玩贪吃蛇,一起玩不?

华为云开发者联盟

大前端 编辑器 贪吃蛇 Blot Quill

百度大规模Service Mesh落地实践

百度Geek说

Service Mesh 软件架构

淘宝“618”双11系统架构是如何设计的呢?这份Java千亿级并发系统架构设计笔记告诉你答案

Java 程序员 架构 计算机

2021年马士兵老师1000道Java大厂面试真题视频解析+笔记+源码

Java架构追梦

Java 架构 面试 马士兵

汽车之家:基于 Flink + Iceberg 的湖仓一体架构实践

Apache Flink

flink

一文你带快速认识Vue-Router路由

华为云开发者联盟

html Vue vue-router 路由 路由管理器

5分钟带你玩转国内首款研发自动化工具PingCode Flow

PingCode研发中心

研发管理 研发效能 自动化管理 研发工具

Scrum为何倡导固定迭代周期?

万事ONES

项目管理 Scrum 敏捷开发 Agile ONES

CloudQuery 的数据安全技术运用

BinTools图尔兹

Java 数据库 sql 数据安全

Hi,HarmonyOS!融云全系产品已成功适配鸿蒙 OS 2.0

融云 RongCloud

并发王者课-青铜10:千锤百炼-如何解决生产者与消费者经典问题

MetaThoughts

Java 多线程 并发

液体测量技术:从水到血液

不脱发的程序猿

物联网 液体测量技术 测量技术 ADI

6月18日华为云携手中科院上海药物所,深度解读AI药物研发技术

华为云开发者联盟

AI 华为云 药物 TechWave EIHealth

竞赛|数据竞赛Top解决方案开源整理

不脱发的程序猿

开源 数据竞赛

基于 Flink 打造的伴鱼实时计算平台 Palink 的设计与实现

Apache Flink

flink

PHP ppa 不再支持过时的 Ubuntu 16.04,请立即升级 20.04

大龄程序员老羊

php ubuntu 架构 DevOps

都啥年代了,求你别再说Redis是单线程了!

Java redis 编程 程序员

如何用Python快速的搜索邮件

IT蜗壳-Tango

6月日更

【LeetCode】零钱兑换 IIJava题解

Albert

算法 LeetCode 6月日更

并发王者课-青铜9:防患未然-如何处理线程中的异常

MetaThoughts

Java 多线程 并发

【布道API】API端点/资源命名最佳实践

devpoint

RESTful Rest API 6月日更

「免费开源」基于Vue和Quasar的前端SPA项目crudapi后台管理系统实战之EXCEL数据导入(九)

crudapi

Vue crud crudapi qusar 数据导入

奇亚矿机系统,Bzz分币系统,云算力APP开发

网络攻防学习笔记 Day40

穿过生命散发芬芳

网络攻防 6月日更

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