小程序页面加载性能优化

6,581 阅读12分钟

6月份有幸被前端早读课邀请到厦门大前端技术沙龙,作了小程序页面加载性能优化的分享。从现场反响来看,大家对性能优化的关注度都很高,还是很开心的。

本文是分享的文字版,总结了我们上半年对榛果小程序性能优化的研究和一些实践,希望各位读者能够对小程序的性能优化有个系统的了解。

小程序出现的问题及优化方案

大家在开发和使用小程序的时候,是否有遇到这两个问题?

问题一,页面的加载时间过长:当页面在跳转时,有较长的白屏时间或“加载中”的状态;

问题二,页面失去响应:页面在滑动过程中出现卡顿,或按钮点击后失去响应;

本文将围绕这两个问题的解决思路进行展开。

问题一 加载时间过长

新页面在加载过程中除了要创建页面实例初始化等工作,更多时间还花在页面数据的请求上。所以针对数据请求,我们提出了三种优化思路:

  1. 请求前置:提前请求数据,缩短页面加载时间;

  2. 首屏直出:请求前,利用已有数据,跳过“页面加载”的过程;

  3. 数据缓存:请求后,缓存接口请求的数据;


优化思路一 请求前置

我们一般会在新页面加载完成的onLoad事件中请求数据,那我们可不可以考虑提前请求的时机?在回答这个问题前,我们需要先了解一下小程序的运行机制和生命周期。

小程序MINA框架


小程序整体框架分为三个层,视图层、逻辑层和系统层。视图层包含了小程序各个页面的WXML、WXS和WXSS。逻辑层则运行在JavaScript执行引擎中,包含处理数据逻辑和封装客户端提供的API。 系统层则提供微信客户端原生的能力。除了微信原生功能支持、网络请求等,还提供视图层和逻辑层之间数据传输与事件通知的功能。

逻辑层将数据变化通知给视图层,触发页面更新;视图层把触发的事件通知给逻辑层,进行业务处理。可以看出,视图层的变化实际上是由数据驱动的,小程序将逻辑层的数据反应成视图,同时将视图层的事件发送给逻辑层,这中间需要系统层做中转传输。

根据小程序框架,我们了解视图渲染与逻辑执行是并行独立的线程,因此我们可以一边做页面过渡,一边向后端请求数据,这样并行做请求与渲染。那么,问题又来了,我们应该把数据请求触发点应该提前到那里呢?可以小程序页面加载的生命周期中找到一些思路。

生命周期


页面加载的生命周期经过:

  • 视图层初始化,同时逻辑层页面实例创建后,触发onLoad和onShow事件,等待视图层初始化完成后的通知,再发送页面的初始数据;

  • 视图层接受初始数据后,完成首次渲染,通知逻辑层,逻辑层触发onReady事件,至此页面首次渲染完成,已准备妥当可与视图层进行交互;

  • 之后,逻辑层再调用setData,触发视图层的再次渲染,视图层再回调通知逻辑层,完成一次数据渲染操作;

一般我们都会将请求放在onLoad事件,等请求返回数据时,再调用setData渲染页面。如果前置请求,可以考虑提前到页面打开之前,这样能够更早接受到请求返回的数据。

将原本onLoad事件开始的请求前置到页面跳转,缩短的时间是跳转到onLoad事件之间的时间,这段时间大约100~300ms(简单实验了一下,iphoneX需花费150+ms,小米MIX2需花费200+ms)。


请求前置的实现

可能有同学会有疑问:在我们跳转的时候,下一个页面实例都还没有被创建,又该如何请求数据?难道要把下个页面的请求代码耦合进这个页面里吗?这样代码业务逻辑耦合度太高,不利于维护。

其实,微信小程序有独特的页面机制可以在页面没有创建实例前拿到页面对象的各个方法。小程序启动时,会调用Page函数注册所有的页面,运行时可以取到所有页面原型,页面被访问时创建的是基于这个原型所创建的实例。所以,我们可以包装注册页面的Page函数,收集页面原型的引用,从而得到页面中的方法。

为了实现请求前置,我们设计了一个FastOpen单例对象,其主要结构如下图:


FastOpen单例对象可以用来处理所有请求前置跳转的逻辑。其中包括了:

  • pageList:其元素记录了每一个页面的名称、原型引用和获取前置请求数据的方法;

  • register:用于注册页面。Page函数可以被包装成一个新的CommonPage函数,统一做每个页面都需要做的额外工作,例如埋点、性能数据收集以及将页面注册进FastOpen。我们将在CommonPage执行时,调用该方法,将页面名称、引用等push到pageList中,业务页面中实现initQuery,包含可以前置的请求;

  • initPage:调用目标页的initQuery,发送那些被前置的请求。假设一个场景,页面A跳转到页面B,当页面A跳转的时候可以通过FastOpen的initPage调用页面B的initQuery,并将其包装成promise,赋值给页面B的initDataPromise;

  • getDataPromise:获取页面前置请求的数据。页面A跳转到页面B,当页面B加载完成后,就可以调用该方法,获取页面B的前置请求数据;


优化思路二 首屏直出

在发送请求之前,我们可以考虑的优化方式是首屏直出优化:利用前置页已有的数据,先将部分首屏数据展示给用户,跳过“加载中”的过程,做到首屏页面直出。

从业务中可以发现,前置页中有可以用来首屏渲染的数据,比如我们榛果民宿业务,前置页(各种房源列表)就已经有房源详情页中的部分数据,如首图、标题,房型,价格,可住人数等等。因此,我们就可以利用前置页的数据来渲染一个简单的详情页,至少包含首屏的大部分数据。


如果没有可用的数据也不要紧,我们可以考虑骨架屏方案,及时响应用户操作。

我们再回到生命周期。优化前,页面出现首屏内容应该是在请求回来再次渲染完成之后。优化后,利用已有数据做首屏直接渲染,是可以在onLoad的时候就进行setData。如下图所示:


首屏直出的实现


为了实现目标页也可以取到前置页的部分数据,我们需要维护一个全局变量Map,key是每个页面实例唯一的id,value是可以给目标页首屏渲染的初始化数据。

整个流程是:

  • 前置页准备目标页首屏渲染的数据,存储到全局变量Map中,同时生成目标页的pageId;

  • pageId传回前置页,做跳转带上pageId;

  • 目标页面利用pageId获取数据,可以直接渲染首屏内容,无需明显的加载过程;


优化思路三 数据缓存

发送的请求返回时,我们可以做的优化是缓存它,让下一次相同的请求可以更快速响应。缓存用到的是小程序的本地缓存,有以下几个特点:

  • 缓存总大小只有10MB;

  • 缓存超过10MB会写失败;

  • 缓存对于线上版、体验版、开发版都是共用的;

  • 但对于不同小程序和用户,出于数据隐私的原因,它们是隔离的;

由于缓存大小有限,我们需要缓存置换算法,使用的LRU(最近最少使用算法)。主要思路是维护一个队列,当有新元素进队且队列满了的情况下,淘汰队尾元素;而当队列中的元素被使用时,则将其移动到对首。

数据缓存设计


我们设计一个缓存索引来帮助我们执行LRU算法。索引的key是接口path和字符串化的参数,value包含过期时间,用这个时间来排序淘汰最早过期的数据元素;以及数据大小,为了计算需要淘汰多少个元素。

需要缓存的数据则用与索引相同方法计算出key,将数据直接存在storage里。每次取数时更新索引的过期时间后,再取缓存数据。


问题二 页面失去响应

接下来,我们讨论问题二“页面失去响应”。要想解决这个问题,可以从渲染优化入手。

小程序渲染机制

渲染优化前,先再回顾一下小程序的渲染机制:


视图与逻辑属于不同线程,无法直接通信,数据传输主要通过系统层进行。逻辑层调用setData将数据变化通知给视图层,经过系统层触发页面更新。

每次setData都有跨进程通信的开销,并且视图层反馈计算与更新也是需要一定时间的,在这段时间用户的操作也会受影响。

优化思路四 渲染优化

渲染优化可以考虑五点:

1. 合并setData数据

将多次setData操作合并成一次,尽量减少逻辑层和视图层的通信开销;

2. 移除非视图层数据

控制页面data对象的大小,将非视图层数据移出page的data对象,非视图层的数据即未在wxml中使用的数据。

例如:


上面例子中,myText是视图层的数据,需要写在data中,而_myText则是方法间共享的变量,可以像右图一样移出data对象,在onLoad中初始化,更新时直接“=”赋值。

3. 精简setData数据

尽量精简setData参数,利用数据路径形式的key只提交改变部分。

这其实是setData提供的语法糖,例如下图代码中的场景:


data里有个userInfo对象,有一部分如姓名是不会变动的,而另一个字段isStudent可能需要校验后才能知道是否为学生。在校验后如果需要更改isStudent值,就一般做法需要setData方法赋值整个userInfo对象。而有了这个语法糖,我们可以参考右边的代码,直接用数据路径的形式,只提交更改的那部分。

4.停止后台页面setData

对于切换到后台的页面,应该停止对setData进行操作。

因为所有webview共享同一个JS运行环境,后台页面的setData会影响当前页面的渲染。最常见的场景就是倒计时秒杀组件,我们可以在页面onHide事件把计时器停掉,等onShow的时候再开启继续计时。



小程序性能衡量指标

对于性能优化效果要如何衡量?我们约定了两个指标:

  1. 首次渲染时间:页面首次渲染完成,表示页面已经加载可以和视图层交互;

  2. 首屏加载时间:首屏部分的内容已经渲染完成,用户可以看到首屏;

从页面加载生命周期来看,他们分别对应于:


首次渲染时间,可以用onReady事件,它就表示页面首次渲染完成可以与视图层交互,故而为 onReady - onLoad。


首屏加载时间,其开始时间同样是onLoad,至于结束时间,setData方法有回调,是在页面渲染完毕后执行的回调函数。我们用首屏数据setData的这个回调作为首屏加载时间的结束点。故而,首屏加载时间是 首屏setData - onLoad。

效果分析


从体验上来看,相比优化前,优化后没有加载中的过程,内容直接展示,体验流畅。

从数据上看,我们取AB测试一天的时间进行分析,TP90的数据显示:优化前是3.1s,优化后约1.4s,时间缩短了1.7s,缩短了55%,优化效果还是很明显的。


总结

寻找性能优化的思路,我们可以从这三方面入手:

  1. 体验:给用户提供良好流畅的体验,例如首屏直出,提前展示页面内容及时响应用户操作;

  2. 原理:从小程序的运行机制和生命周期出发,利用独特的页面机制,提前请求数据缩短页面时间;理解渲染机制,避免性能较差的编码方法;

  3. 数据:从接口、数据模块、页面三个层面考虑数据缓存,尽量提高命中率;


参考文档