作者|Kenny,携程资深前端开发工程师。2021年加入携程,从事小程序/H5相关研发工作。一、背景随着项目的不断迭代,规模日益增大,基于Taro3的runtime的劣势也越来越突出,尤其是在复杂的列表页面上,性能不佳,这极大地影响了用户体验。本文将围绕复杂列表的性能优化,尝试建立检测指标,了解性能瓶颈,并通过预加载、缓存、优化组件级别、优化数据结构等提供一些实验后的技术解决建议,希望大家能够给带来一些想法。2、问题现状及分析我们以某酒店的多功能列表为例(下图),设置检测标准(setData的个数和setData的响应时间为指标),检测情况如下:索引渲染时间setData次(ms)首次进入列表页面72404下拉长列表更新31903更新多屏列表下过滤项21758更新多屏列表下列表项2748到期由于历史原因,本页面代码由微信原来的taro1转换而来,后续迭代为taro3。项目中存在一些问题,小程序原生的写法可能会忽略。根据上面多次测量的指标值和视觉感受,存在以下问题:2.1首次进入列表页加载时间过长,白屏时间过长,界面列表页请求时间过长;初始化列表也是setData数据量太大,而且次数太多;页面节点过多,导致渲染时间过长;2.2页面过滤项更新卡住,下拉动画卡住。过滤项节点过多,更新时setData数据量大;filteritem组件的更新会导致页面一起更新;2.3无限列表更新卡住,滚动太快时请求下一页来不及;数据量大,设置数据时响应慢;从画面到渲染完成的过渡机制,体验较差;3、尝试优化方案3.1跳转到预加载API:通过观察小程序的请求,可以发现其中有两个列表页请求耗时较长。在Tar??o3的升级中,官方提到了预加载Preload。在小程序中,调用Taro.navigateTo等路由跳转API后,会有一定的延迟(300ms左右,如果是分包新下载跳转需要更长时间),所以可以提前请求一些网络请求时跳跃开始。所以我们使用Taro.preload在跳转前预加载复杂的列表请求://PageAconstquery=newQuery({//...})Taro.preload({RequestPromise:requestPromiseA({data:query}),})//页面BcomponentDidMount(){//在跳转的过程中,发起了一个请求,因为返回了一个promise,所以需要在页面B接受:Taro.getCurrentInstance().preloadData?.RequestPromise?.then(res=>{this.setState(this.processResData(res.data))})}用同样的检测方式反复测试,使用preload时,可以提前300~400ms提前获取酒店列表数据.左边是没有预加载的旧列表,右边是预加载的列表。可以明显看出预加载列表会更快。但是在实际使用中,我们发现preload存在一些缺陷。对于接收页面,如果接口比较复杂,会在一定程度上侵入业务流程的代码。本质是抢占了网络请求,所以我们可以在网络请求部分加入缓存策略来达到这个效果,访问成本会大大降低。3.2setData的合理使用setData是小程序开发中使用频率最高的API,也是最容易引起性能问题的。setData的过程大致可以分为几个阶段:逻辑层虚拟DOM树的遍历和更新,触发组件生命周期和观察者等;将数据从逻辑层传输到视图层;在视图层更新虚拟DOM树,真实DOM元素更新并触发页面渲染更新。数据传输的时间消耗与数据量的大小正相关。老列表页第一次加载时,一共请求了4个接口。短时间内请求了6次SetData,两次数据量过大。我们试过的优化方法是把数据量大的2次分开,发现另外5次都是零散的状态和数据,可以当做1次。IndexsetDatatimessetDatatime-consumed(ms)reductiontime-consumedpercentage第一次进入列表页面321829.23%完成这一步后,平均可以减少200ms左右,效果不大,因为上的节点数页面没有变化,setData时间的主要消耗也分布在渲染时间上。3.3优化页面节点数根据微信官方文档,过大的节点树会增加内存占用,重新排列样式的时间也会变长。建议页面节点数小于1000,节点树深度小于30层,子节点数不大于60。在微信开发者工具中,有一个页面的两个模块中的大量节点。一个是过滤项模块,一个是长列表模块。由于这部分功能较多,结构复杂,我们采用了选择性渲染。例如,在用户浏览列表中,过滤项不生成具体的节点。当点击展开过滤时,再渲染节点,一定程度上缓解了页面列表的体验。另一方面,对于整体布局的书写,有意识地避免嵌套太深的书写,比如使用RichText,部分选择图片代替。3.4优化过滤项3.4.1更改动画方式在重构过滤项的过程中,发现在部分机型上,小程序的动画效果不理想。比如在打开过滤项选项卡时,一个下拉在实现的初期,会出现两个问题:当过滤页面的节点过多时,动画会先闪一下再出现,点击响应太慢慢,用户体验差。旧过滤项的动画是通过关键帧实现的。fadeIn的动画是加在最外层的,但是会在动画出现的那一帧闪烁一次。经过分析,关键帧执行动画造成的卡顿:.filter-wrap{animation:.3sease-infadeIn;}@keyframesfadeIn{0%{transform:translateY(-100%)}100%{transform:translateY(0)}}于是,尝试改变实现方式,通过transition实现transfrom:.filter-wrap{transform:translateY(-100%);过渡:无;&.active{转换:translateY(0);过渡:变换.3s缓入;}}3.4.2在维护一个简单的状态操作filteritem时,每次操作都需要根据唯一id循环遍历filteritem的数据结构,找到对应的item并改变itemstate,然后将整个结构重置为设置状态。官方文档中提到,关于setState,尽量避免处理过大的数据,影响页面的更新性能。为了解决这个问题,解决方法是提前将复杂的对象压平,如下:{"a":{"subs":[{"a1":{"subs":[{"id":1}]}}]},"b":{"subs":[{"id":2}]},//...}扁平化过滤项数据结构:{"1":{"id":1,"name":"Hanting","includes":[],"excludes":[],//...},"2":{//...},//...}不改变原始数据,使用展平数据结构维护动态选择列表:constflattenFilters=data=>{//...return{[id]:{id:2,name:"allseasons",includes:[],excludes:[]//...},//...}}constfilters=[],filtersSelected={}constflatFilters=flattenFilters(filters)constonClickFilterItem=item=>{//所有操作都需要先获取平面itemconstflatItem=flatFilters[item.id]if(filtersSelected[flatItem.id]){//已经选中,需要取消选中deletefiltersSelected[flatItem.id]}else{//未选中,需要选中filtersSelected[flatItem.id]=flatItem//取消选择排除项项目constidsSelected=Object.keys(filtersSelected)constidsIntersection=intersection(idsSelected,flatItem.selfExcludes)//intersectionif(idsIntersection.length){idsIntersection.forEach(id=>{deletefiltersSelected[id]})}//其他逻辑(快速筛选,关键词等)}this.setState({filtersSelected})}上面是一个简单的实现,前后对比,我们只需要维护一个非常简单的对象,添加或删除它的属性,性能略有提升,并且代码为了简洁和整洁,在业务代码中,有很多地方可以通过数据结构转换来提高效率。关于过滤项,可以对比检测的平均数据,减少200ms~300ms,也会得到一些改进:指标setData耗时oldsetData耗时new耗时百分比减少longlistfilteritemsexpand10239675.47%长列表点击过滤项1758144317.92%3.5长列表优化早期酒店列表页面引入虚拟列表,为长列表渲染一定数量的酒店。核心思想是只渲染屏幕上显示的数据。基本实现是监听滚动事件,重新计算需要渲染的数据。不需要渲染的数据留下一个空的div占位符元素。加载下一页略有滞后:根据数据,下拉更新列表平均耗时1900ms左右:指标setData耗时下拉列表更新31903的setData次数解决这个问题,解决方法是提前加载下一页的数据,下一页存储在内存变量中。滚动加载时直接从内存变量中取出,然后setData更新为数据。滑动速度过快会出现白屏(速度越快,白屏持续时间越长,下左图):虚列表的原理是利用空的View来占位。快速回滚时,渲染时节点过于复杂,尤其是酒店有图片,渲染会变慢,导致白屏。我们尝试了三种解决方案:1)使用动态骨架图替换原来的View占据下图右侧:2)CustomWrapper提升性能,官方推荐使用CusomWrapper,可以将被包裹的组件与页面隔离,并且组件渲染时不会更新整个页面,从page.setData变为component.setData。自定义组件基于ShadowDOM实现,将DOM和CSS封装在组件中,使组件内部与主页面的DOM保持分离。图中的#shadow-root为根节点,成为shadowroot,与主文档分开渲染。#shadow-root可以嵌套形成节点树(ShadowTree)wrapped组件是隔离的,这样内部数据的更新不会影响整个页面。可以简单的看下低性能客户端下的性能。效果还是很明显的。同时点击,右边的弹窗会平均快200ms到300ms(同机型同环境下测得),越低机型越明显。(右边的在CustomWrapper下)3)使用小程序原生组件,用小程序原生组件实现列表Item。原生组件绕过Taro3的runtime,也就是说,当用户在页面上操作时,如果是taro3的组件,需要对前后数据进行diff计算,然后生成需要的节点数据新建虚拟dom,然后调用小程序的API对节点进行操作。Native组件绕过这一系列操作,直接更新底层小程序的数据。所以,缩短了一些时间。可以看看执行后的效果:索引setDatatimes(old)setDatatimes(new)下拉列表更新31setData耗时(old)setData耗时(new)耗时百分比减少190383656.07%可以看到原生的性能提升很大,平均更新列表缩短了1秒左右,但是使用原生组件也有不足之处,主要有以下两个方面:组件中包含的所有样式都需要按照小程序的规范编写,与taro风格隔离;taro风格不能在原生组件API中使用,比如createSelectorQuery;与三种方案相比,性能提升逐渐加强。考虑到使用Taro的本意是跨端,如果使用原生的话,是没有办法达到这个目的的,但是我们正在尝试通过插件在编译时生成原生小程序对应的组件代码来解决这个问题.最终达到最佳效果。3.6React.memo当复杂页面的子组件过多时,父组件的渲染会导致子组件进行相应的渲染。React.memo可以进行浅比较以防止不必要的渲染:constMyComponent=React.memo(functionMyComponent(props){/*renderwithprops*/})React.memo是一个高阶组件。它与React.PureComponent非常相似,但它适用于函数组件而不是类组件。如果你的函数组件在给定相同的props的情况下呈现相同的结果,你可以通过将其包装在React.memo调用中以记住组件的渲染结果来提高组件的性能。这意味着在这种情况下,React将跳过渲染组件并直接重用最近渲染的结果。默认情况下,它只对复杂对象进行浅层比较。如果要控制比较过程,请传入自定义比较函数作为第二个参数。functionMyComponent(props){/*使用props渲染*/}functionareEqual(prevProps,nextProps){/*如果将nextProps传入render方法的返回结果与将prevProps传入render方法的返回结果一致,返回true,否则返回false*/exportdefaultReact.memo(MyComponent,areEqual);4.总结这个复杂列表的性能优化我们早就经历过,尝试过各种可能的优化点。从列表页的预加载、过滤项数据结构和动画实现的改变,到长列表的体验优化和原生的结合,提升了页面的更新和渲染效率。目前,我们仍在密切关注,继续探索。下面是最终的效果对比(右侧优化后):
