写点什么

干货 | Taro 虚拟列表最佳实践

  • 2021-07-31
  • 本文字数:5222 字

    阅读完需:约 17 分钟

干货 | Taro虚拟列表最佳实践

一、背景

最近组内小程序项目从 Taro1 迁移到了 Taro3,紧跟凹凸实验室的步伐,开发体验确实比版本 1 好了很多,完全支持 React 语法,没有了那么多鸡肋的限制,项目的可配置程度也大大放开,充分给予了开发者自由发挥的空间。


但是由于 Taro3 是运行时架构,是以牺牲页面部分性能为代价的,这也间接导致了我们的列表页异常卡顿,由于我们的列表页是一次性请求所有数据,然后进行渲染,所以页面节点初始化渲染的时候会渲染很多节点,再加上一些筛选项,不用说用户,卡顿已经让我们自己都忍受不了。此为背景。


本文我们会先分析页面卡顿的原因,然后寻找对应的一些解决方案,分析其可行性,最后结合前期的问题解析,给出一套最优的解决方案。

二、原因分析

1)页面节点过多,渲染时间变长,阻碍了用户快速操作的需求;

2)列表 setState 数据量太大,造成逻辑层与渲染层的通讯时间变长;

3)修改 state,例如点击列表筛选项,列表数据需要重新大量渲染,造成页面卡顿;

三、解决方案

方案一:后端分页

我们第一时间想到让接口分页,这样初始化渲染的时候就不会渲染大量节点,然后监听下拉到底时机,再依次渲染数据;


但是该方案第一时间被毙掉,原因:

  • 列表页接口不只有小程序在用,app 客户端也在共用同一套接口,如果想让接口变更,那么 app 客户端也跟着去修改逻辑(列表页的逻辑也挺复杂的),因为我们去尝试给客户端增加需求量,不太厚道; 

  • 就算说服了客户端和服务端一起去修改,我们页面的初始化速度提升了,但是随着页面上拉,数据加载越来越多,当加载到一定数量之后,再操作页面的筛选项,依然会导致操作卡顿;


总结:想让页面初始化以及数据全部加载完成之后不卡顿,除非减少 setState 的数据量以及减少页面总的渲染节点数量,因此只能采用虚拟列表。

方案二:官方虚拟列表(3.2.1 版本)


官方文档:https://docs.taro.zone/docs/virtual-list


原理:只渲染当前可视区域内的数据节点,监听页面可视区域,不在可视区域的节点不再渲染,这样一来就大大减少了页面节点渲染数量。


使用效果:团队第一时间尝试了虚拟列表,但是效果并不是非常理想,主要问题有以下几点:


  • 由于我们的列表内容不是所有的 Item 都是等高的,所以虚拟列表每次渲染的时候都会去动态计算每个 Item 的高度,造成列表高度变换抖动;

  • 上拉加载过程中偶尔会出现无限上滑加载的问题,造成页面紊乱;

  • 滑动速度太快会导致页面很长一段时间的白屏,体验不佳;


总结:已知问题需要官方团队去解决,但是要等,而且 Item 不等高,需要频繁动态计算 Item 高度的问题并不好解决,目前市面上也没有什么特别好的方案,因此该方案也被搁浅了。

四、方案分析

1)减少页面节点数量:只能采用虚拟列表,只渲染当前可视区域内的节点;

2)减少 setState 的数据量:能不能不每次都去全量 setState;

3)动态计算 Item 高度:每次都重新计算每个 Item 高度,计算量太大,也会阻碍页面渲染;


基于以上问题,我们团队最终出品了更佳(没有最佳,只有更佳)虚拟列表方案。

五、终极(更佳)方案

5.1 效果概览

动图预览:



主要看一下虚拟列表节点组成:

5.2 前期思考

1)继续采用监听可视区域,只渲染可视区域内的节点。


2)由于 Item 不等高问题,需要动态计算每个 Item 的高度,效果不佳,我们放弃。因为只渲染当前可视区域内的数据,那么能不能以每一屏的数据为一个维度(界限),当一屏数据渲染完成之后,记录一下该屏幕节点所占的整体高度,当该屏幕的节点再次进入可视区域,我们将记录下的高度重新赋予这一屏幕,这样是不是就减少了大量计算的工作?


3)为了减少 setState 的数据量,不在可视区域内的那些屏幕的数据,可否用该屏幕的高度(一个简单的对象数据结构)去占位?好像思路都能说的过去,那到底可不可行呢,下面我们来一探究竟吧。

5.3 Coding

格式化数据

首先我们需要外部传入列表数据 list,然后在组件内部加工一下,按照一屏一屏渲染的思路,暂且把 list 改为二维数组,一个维度就是一屏的数据;



export default class VirtialList extends Component { constructor(props) { super(props) this.state = { twoList: [], // 二维数组 } } componentDidMount() { // 接收外部传入的列表数据 const { list } = this.props // 将list格式化为二维数组 this.formatList(list) } initList = [] // 承载初始化的二维数组,该数组初始化完成之后就不会再变了,除非外部list变化 /** * 将列表格式化为二维 * @param list 列表 */ formatList(list) { // 用户可自定义二维数组每一个维度的数据量 const { segmentNum } = this.props let arr = [] const _list = [] // 二维数组副本 list.forEach((item, index) => { arr.push(item) if ((index + 1) % segmentNum === 0) { // 够一个维度的量就装进_list _list.push(arr) arr = [] } }) // 将分段不足segmentNum的剩余数据装入_list const restList = list.slice(_list.length * segmentNum) if (restList?.length) { _list.push(restList) } this.initList = _list this.setState({ twoList: _list.slice(0, 1), // 第一次渲染,只取第一个维度的数据 }) } render() { const { twoList, } = this.state // 渲染回调 const { onRender } = this.props return ( <ScrollView> <View className="zt-main-list"> { twoList?.map((item, pageIndex) => { return ( // 每一个屏幕都用一个节点包裹着 <View key={pageIndex} className={`wrap_${pageIndex}`}> { item.map((el, index) => { return onRender?.(el, (pageIndex * segmentNum + index), pageIndex) }) } </View> ) }) } </View> </ScrollView> ) }}
复制代码


设置屏幕高度

我们已将数据格式化为二维数组了,初始化渲染的时候只会渲染数组的第一维度,那么在该维度节点渲染完成之后,需要记录下该维度节点所占屏幕的一个高度。



state = { wholePageIndex: 0, // 每一屏为一个单位,屏幕索引}formatList(list) { // ... this.setState({ twoList: _list.slice(0, 1), }, () => { // 注意:放在下一个事件循环去获取节点,更有保障 Taro.nextTick(() => { this.setHeight() }) })}pageHeightArr = [] // 用来装每一屏的高度setHeight():void { const { wholePageIndex } = this.state const query = Taro.createSelectorQuery() query.select(`.wrap_${wholePageIndex}`).boundingClientRect() query.exec((res) => { this.pageHeightArr.push(res?.[0]?.height) })}
复制代码


上拉加载


利用 ScrollView 的 onScrollToLower 属性,监听列表上拉至底部,加载下一个维度的数据,塞入二维数组列表。



<ScrollView scrollY onScrollToLower={this.renderNext} lowerThreshold={250}>//...</ScrollView>
renderNext = () => { // 每次加载下一屏幕的数据,修改屏幕索引 const page_index = this.state.wholePageIndex + 1
this.setState({ wholePageIndex: page_index, }, () => { const { wholePageIndex, twoList } = this.state // 找到当前屏幕的对应的数据,塞入二维数组 twoList[wholePageIndex] = this.initList[wholePageIndex] this.setState({ twoList: [...twoList], }, () => { Taro.nextTick(() => { this.setHeight() }) }) })}
复制代码


监听可视区域


利用 observer 对象的监听方法 observe,监听当前可视区域,渲染对应维度的数据,那么不在可视区域内的数据要怎么处理呢?


这也是该组件最重要的一环,当不在可视区域内的数据,因为我们之前已经记录了该维度节点渲染之后的一个高度,那么我们就利用一个节点赋予对应的高度,进行占位!



setHeight() { //... this.observe()}observe = () => { const { wholePageIndex } = this.state // 外界用户传入的组件高度 const { scrollViewProps } = this.props // 以传入的scrollView的高度为相交区域的参考边界,若没传,则默认使用屏幕高度 const scrollHeight = scrollViewProps?.style?.height || this.windowHeight // 设定监听的范围,我们这里默认监听上下两个屏幕的高度 const observer = Taro.createIntersectionObserver(this.currentPage.page).relativeToViewport({ top: 2 * scrollHeight, bottom: 2 * scrollHeight, }) observer.observe(`.wrap_${wholePageIndex}`, (res) => { const { twoList } = this.state if (res?.intersectionRatio <= 0) { // 当没有与当前视口有相交区域,则将该屏的数据置为该屏的高度占位 twoList[wholePageIndex] = { height: this.pageHeightArr[wholePageIndex] } this.setState({ twoList: [...twoList], }) } else if (!twoList[wholePageIndex]?.length) { // 如果有相交区域,则将对应的维度的数据塞入二维数组 twoList[wholePageIndex] = this.initList[wholePageIndex] this.setState({ twoList: [...twoList], }) } })}render() { return ( <ScrollView> <View className="zt-main-list"> { twoList?.map((item, pageIndex) => { return ( <View key={pageIndex} className={`wrap_${pageIndex}`}> { item?.length > 0 ? ( <Block> { item.map((el, index) => { return onRender?.(el, (pageIndex * segmentNum + index), pageIndex) }) } </Block> ) : ( <View style={{'height': `${item?.height}px`}}></View> ) } </View> ) }) } </View> </ScrollView> )}
复制代码

六、性能提升

接下来是智行小程序机票列表页优化前跟优化后的几组数据对比:

列表页渲染时长

主要指的是页面航线列表的渲染总时间。


筛选项响应时间

主要指的是从点击页面下方筛选按钮时间开始算起,到底部浮层弹出的时间间隔,单位毫秒。

性能提升总结

可以看出在使用虚拟列表对页面进行优化之后,页面总的渲染性能会有一个质的提升,页面列表渲染速度提升了将近 45%,按钮点击响应速度提升了将近 50%。


目前我们只是针对航班列表使用了虚拟列表进行优化,页面中还有一个比较损耗性能的点是上方的日历列表,后期我们将把日历列表也改成虚拟列表,相信性能会更进一步提升。

七、总结

组件的实现比较简单,关键点就在于:

1)将列表数据格式化为二维数组; 

2)不在可视区域内的数据用{height: xx px}填充,减少了列表数据 setState 的量;

3)动态计算每一个屏幕的高度并记录,减少计算量;

八、最后

该组件支持列表内部节点不等高的棘手问题,目前已经用于生产环境,运行稳定 。如果这篇文章对你现在的开发有一些帮助,或者说给你带来了一些更好的思考,欢迎一起来讨论。


githubhttps://github.com/tingyuxuan2302/taro3-virtual-list

npm 包https://www.npmjs.com/package/taro-virtual-list

Taro 物料市场https://taro-ext.jd.com/plugin/view/60bf31e23ac107d9df4685cb


作者简介

不浪,携程高级前端开发工程师,关注前端热门技术,目前从事小程序的相关开发与优化。


本文转载自:携程技术(ID:ctriptech)

原文链接:干货 | Taro虚拟列表最佳实践

2021-07-31 07:003224

评论

发布
暂无评论
发现更多内容

常用消息中间件对比

浅羽技术

RocketMQ MQ 消息队列 Rabbit MQ 8月月更

Hash 算法详细介绍与实现 (二)

迷彩

php 数据结构 hash算法 8月月更 Hash表

聊聊实际使用的电源转化电路,分享一些不同场合下的转3.3V电路

矜辰所致

电路设计 8月月更 电源转化

Kubernetes源码学习之一:下载和编译源码

程序员欣宸

Kubernetes 8月月更

CR,我们在Review什么

agnostic

CR

【算法实践】手把手带你快速实现插入排序

迷彩

Python 算法 插入排序 8月月更 折半插入排序

云原生(二十八) | Kubernetes篇之自建高可用k8s集群搭建

Lansonli

云原生 k8s 8月月更

Python 教程之数据分析(3)—— Python中不同图表的数据可视化

海拥(haiyong.site)

Python 8月月更

使用 Containerlab + Kind 快速部署 Cilium BGP 环境

Se7en

最常见的ES面试核心问答知识点

浅羽技术

搜索引擎 倒排索引 ES Elastic Search 8月月更

[JS入门到进阶] 前端开发不能写undefined?这是误区!

HullQin

CSS JavaScript html 前端 8月月更

SpringCloud 服务的拆分及远程调用

微服务 SpringCould 8月月更

Grafana Labs CEO 关于 Grafana Mimir 的问题答复

Grafana 爱好者

Mimir

元器件选型实例(如何选一款合适的 DC-DC )

矜辰所致

电路设计 电源设计 8月月更 元器件选型

聊聊面试中的过滤器与拦截器

TimeFriends

8月月更

常见性能测试场景概述

穿过生命散发芬芳

性能测试 8月月更

架构师的十八般武艺:架构决策

agnostic

架构决策

日拱算法:双指针解“救生艇”问题

掘金安东尼

算法 前端 8月月更

想不到吧,Mysql在项目中的优化场景这么多

知识浅谈

sql 8月月更

小米最新充电器最高支持210W闪充,成为行业内第一

科技怪咖

HTML入门

楠羽

#开源

「uTools」生成P站Logo插件

小鑫同学

8月月更

分布式事务常见面试题解答

浅羽技术

分布式事务 事务 TCC 两阶段提交改进 8月月更

吹爆!阿里最新开源的这份Redis全栈小册,涵盖了Redis的所有操作

Java永远的神

Java 数据库 redis 程序员 面试

【超文本标记语言】:走进HTML的世界....

翼同学

html html5 HTML5, CSS3 8月月更

自阿里P8爆出1031道java面试题后,我在boss直聘狂拿千份Offer

退休的汤姆

Java 程序员 面试 阿里 秋招

理一理 OC/OD 门、开漏输出、推挽输出、图腾柱等概念

矜辰所致

电路设计 8月月更 电路基础

共36万字!为上岸Alibaba,我把Github上Java面试题都整理了一遍

退休的汤姆

Java 程序员 面试题 Java工程师 秋招

Jenkins 构建成功后发送邮件配置

宁在春

Docker jenkins 8月月更

自定义 Hero 动画的路径

岛上码农

flutter 前端 移动端开发 安卓开发 8月月更

Jenkins + Github + Nginx 自动化部署 Vue 项目

宁在春

Docker jenkins 8月月更

干货 | Taro虚拟列表最佳实践_架构_携程技术_InfoQ精选文章