最新发布《数智时代的AI人才粮仓模型解读白皮书(2024版)》,立即领取! 了解详情
写点什么

干货 | 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:002759

评论

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

声网 Agora 初体验

若尘

声网 Agora

Prometheus官方文档Querying[三]function

卓丁

【签约计划】调查采访能力考核成绩公布

InfoQ写作社区官方

签约计划 热门活动

ARTS - week 5

steve_lee

PI的一种简写。

山@支

Markdown使用语法

Geek_6370d5

markdown语法

HBase的rowKey设计技巧

五分钟学大数据

HBase 4月日更

安卓开发基础面试题,分享一点面试小经验,含BATJM大厂

欢喜学安卓

android 程序员 面试 移动开发

ffmpeg完美实现解封装操作!

txp

音视频

阿里P8架构师分享私用Java学习资料(含视频和项目源码以及面试题)

北游学Java

Java 数据库 分布式 微服务

Vuex整洁架构之道

devpoint

vuex vue架构 mapGetters mapState mapActions

ARTS - week 6

steve_lee

一文带你了解如何排查内存泄漏导致的页面卡顿现象

零一

chrome 大前端 浏览器 内存泄露 问题处理

Nginx新增模块more_clear_headers问题记录

风翱

nginx 4月日更

自定义Hadoop的输入格式

小舰

4月日更

安卓开发实战讲解!从新手到Flutter架构师,一篇就够!快来收藏!

欢喜学安卓

android 程序员 面试 移动开发

继续探究:一文理清JVM和GC(下)

比伯

Java 架构 程序人生 计算机 技术宅

Dubbo 学习笔记(二) Spring Boot 整合 Dubbo

U2647

Spring Boot dubbo 4月日更

Python OpenCV 图片高斯模糊

梦想橡皮擦

Python OpenCV 4月日更

Java检查异常、非检查异常、运行时异常、非运行时异常的区别

Sakura

4月日更

Linux rm 命令

一个大红包

4月日更

面向小白使用Git 的手册

程序员海军

git

推荐18个 Vue常用组件库

程序员海军

Vue 组件库

架构实战营 - 模块 2- 作业

泄矢的呼啦圈

架构实战营

一篇文章带你彻底了解MySQL各种约束

若尘

MySQL 数据库 约束 4月日更

JavaScript 中的执行上下文和执行栈 的理解

程序员海军

JavaScript 大前端 上下文 执行栈

园区网的网关部署在接入层还是汇聚层 面试官与求职者之间谈话

艺博东

网关 交换机 网络配置

如何高效率的工作

程序员海军

工作效率 提升效率

再谈日更公众号

彭宏豪95

写作 感悟 微信公众号 4月日更

M2-task

Focused

手撕83K STAR的Axios设计思想,并进行能力增强

梁龙先森

源码分析 大前端 axios

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