写点什么

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

评论

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

直播回顾 | 为什么在开发流程中应用静态代码分析工具?

鉴释

软件开发生命周期 在线研讨会 静态代码分析

唯品会三年,我只做了5件事,如今跳槽天猫拿下offer(Java岗)

公众号_愿天堂没有BUG

Java 编程 程序员 架构 面试

太为难我了,阿里面试了7轮(5年经验,拿下P7岗offer)

公众号_愿天堂没有BUG

Java 编程 程序员 架构 面试

2021 营销数字化的下一个站点

人称T客

面向大规模商业系统的数据库设计和实践

百度Geek说

数据库 后端 数据库设计 数字化

全民K歌跨端体系建设

Edwiin

跨端 hippy 全民K歌

亚信科技AntDB数据库与中科可控、海光公司完成产品兼容认证

亚信AntDB数据库

服务器 国产化 国产芯片 国产数据库 产品兼容性互认

ELK原来这么简单!《零基础(1)

Java 程序员 后端

在 Dubbo3.0 上服务治理的实践

阿里巴巴中间件

云计算 Serverless 云原生 dubbo 中间件

Camtasia添加弹题交互教程

淋雨

视频剪辑 Camtasia 录屏软件

香港服务器弹性是企业数字化转型的一个关键方面

九河云安全

GitHub标星8k!Java虚拟机5大核心知识点

Java 程序员 后端

难以置信!一篇文章就梳理清楚了 Python OpenCV 的知识体系

梦想橡皮擦

8月日更

云原生 | 混沌工程工具 ChaosBlade Operator 入门篇

RadonDB

混沌工程 RadonDB KubeSphere

真正决定你成败的,是时间管理!

博文视点Broadview

GitHub标星过万!10592字,475行

Java 程序员 后端

时序数据库永远的难关 — 时间线膨胀(高基数 Cardinality)问题的解决方案

阿里巴巴中间件

云计算 阿里云 云原生 中间件 时序数据库

关于测试的三个关键问题

QualityFocus

测试 质量 测试文化 测试落地

GitHub标星过万!亦直问JVM(1)

Java 程序员 后端

单元测试:GTest之事件机制(一)

正向成长

测试 测试 单元测试 GTest

IM之Qunar实现

Qunar技术沙龙

架构 IM 办公 客服 消息

现成FIL分币系统介绍|FIL分币平台搭建

Geek_23f0c3

Filecoin fil挖矿 FIL挖矿分币系统

香港云服务器的火爆市场,下一个未来发展将会怎么改变?

九河云安全

香港云服务器的性能提升对行业服务带来显著动力

九河云安全

价值连城 神经网络- 吴恩达Andrew Ng Coursera Neural Networks and Deep Learning John 易筋 ARTS 打卡 Week 58

John(易筋)

ARTS 打卡计划

太可惜了,四面字节跳动,我的offer竟被一道“算法题”给拦截了

公众号_愿天堂没有BUG

Java 编程 程序员 架构 面试

入职字节跳动那一天,我哭了(蘑菇街被裁,奋战7个月拿下offer)

公众号_愿天堂没有BUG

【“互联网+”大赛华为云赛道】API命题攻略:厘清三步解题思路,用好开发工具

华为云开发者联盟

API 华为云 modelarts 互联网+ API Explorer

【“互联网+”大赛华为云赛道】CloudIDE命题攻略:明确业务场景,快速开发插件

华为云开发者联盟

ide 开发 插件 华为云 CloudIDE

亏损、退市、卖身...区块链如何挽救影视行业?

旺链科技

区块链 版权保护 影视行业

使用 PolarDB 和 ECS 搭建门户网站

若尘

阿里云 Polar 8月日更

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