写点什么

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

评论 3 条评论

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

图像增强及运算:局部直方图均衡化和自动色彩均衡化处理

华为云开发者联盟

Python 人工智能 华为云 企业号 1 月 PK 榜

如何训练开发者记忆能力

SEAL安全

开发者 实用技能 记忆

1 📖 《JavaScript高级程序设计》__ 什么是JavaScript?

HoMeTown

JavaScript #读书 前端‘’

玩转机密计算从 secGear 开始

openEuler

开源 操作系统 openEuler 机密计算

马蜂窝如何利用 APISIX 网关实现微服务架构升级

API7.ai 技术团队

api 网关 APISIX envoy ingress Kubernetes, 云原生, eBPF

研发团队绩效考核:Leader 如何做到赏罚分明?

石云升

极客时间 复盘 1月月更 技术领导力实战笔记

使用 YonBuilder 进行报表分析 - 扩展篇

YonBuilder低代码开发平台

为什么数字化转型需要“低代码”?

元年技术洞察

DevOps 低代码 数字化转型 低代码平台

“低代码+PaaS”的技术创新实践

元年技术洞察

方舟 低代码 数字化转型 低代码平台

企业的数据存储、处理与分析之道

云布道师

阿里云 云存储

云原生安全系列 5:ETCD 安全加固

HummerCloud

etcd Kubernetes, 云原生, eBPF

2 📖 《JavaScript高级程序设计》__ HTML中的JavaScript

HoMeTown

JavaScript 前端 读书 js

声网许振明:RTC 场景 UHD 视频应用和探索

声网

前端 音视频 RTC

NFTScan 与 SeeDAO 孵化器达成战略合作,为开发者提供专业的 NFT 数据服务!

NFT Research

NFT

Pipy 实现 SOCKS 代理

Flomesh

HTTP Service Mesh 服务网格 Pipy 流量管理

如何使用极狐GitLab 机器人大幅提升研发效率

极狐GitLab

项目管理 DevOps 机器人流程自动化 极狐GitLab 研发效率

虚拟化技术浅析第二弹之初识Kubernetes

京东科技开发者

云计算 容器 微服务 #Kubernetes# 虚拟化技术

LED显示屏都需要4个配套设施

Dylan

LED显示屏 户外LED显示屏 led显示屏厂家

【Dubbo3 终极特性】「云原生三中心架构」带你探索 Dubbo3 体系下的配置中心和元数据中心、注册中心的原理及开发实战(中)

洛神灬殇

dubbo 注册中心 配置中心 Dubbo3 元数据中心

3 📖 《JavaScript高级程序设计》__ 语言基础(下)

HoMeTown

JavaScript 前端 读书 js 前端面试

mysql 中字段的 collate 和 charset 有什么区别

ModStart

eBPF SIG年度动态: eBPF和Wasm深度融合、参与7场活动及2023展望 | 龙蜥 SIG

OpenAnolis小助手

Linux 开源 ebpf 龙蜥社区 sig

岁末年初捷报频传 HashData斩获多项行业殊荣

酷克数据HashData

数据库·

使用MTK迁移Mysql源库后主键自增列导致数据无法插入问题

华为云开发者联盟

数据库 后端 华为云 企业号 1 月 PK 榜

证券服务应用评测系列:海通e海通财发布9.0版本,探索证券APP持续提升用户体验

易观分析

App 证券

3 📖 《JavaScript高级程序设计》__ 语言基础(上)

HoMeTown

JavaScript 前端 读书 js

MatrixOne入选艾瑞数据库研究报告啦~

MatrixOrigin

分布式数据库 国产数据库 MatrixOrigin MatrixOne 艾瑞咨询

2023年1月中国数据库排行榜:OceanBase 持续两月登顶,前四甲青云直上开新局

墨天轮

数据库 opengauss tdsql 国产数据库 polarDB

荣誉+1,龙蜥荣获“2022年度杰出开源运营团队”奖项

OpenAnolis小助手

开源 InfoQ 运营 获奖 龙蜥团队

运联智库发布2022供应链及合同物流百强排行榜

联营汇聚

如何打造一个“无需激励”自运转的技术团队?

石云升

极客时间 复盘 激励 1月月更 技术领导力实战笔记

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