小程序性能优化

3,638 阅读13分钟

1 优化方向

  • 启动加载性能

  • 渲染性能

2 启动加载性能

2.1 启动加载原理

小程序启动主要分为逻辑层的启动和视图层的启动。逻辑层执行js代码逻辑,视图层以 webview 为载体,完成页面内容的渲染和更新。

小程序启动前,客户端会对小程序的基础环境进行预加载,提升小程序加载的速度。在用户打开小程序的时,会首先进行代码包的下载,下载完成后分别在逻辑层和视图层注入执行开发者的业务代码,最终将执行结果聚合渲染出首屏内容。

小程序运行

即小程序启动过程三个阶段:资源准备(代码包下载);业务代码的注入以及落地页首次渲染;落地页请求时的loading态(当落地页不要请求任何数据时就没有这个过程)。

举例:

图1:下载小程序代码包。小程序主要在进行资源准备,主要是代码包的下载和校验工作。

图2:加载小程序代码包。小程序在进行业务代码的注入和执行,等待首次渲染完成。

图3:初始化小程序首页。当小程序完成第一次渲染后,对于很多小程序并不意味着页面内容完全出现,而需要与服务器进行一次的通信,获取数据来进行渲染。图3就是这类场景下等待请求返回的过程。

2.2 启动性能优化

2.2.1 代码包体积优化:控制代码包大小

小程序加载过程中,代码包的大小最直接影响整个小程序加载启动性能。

所以,提升小程序的启动加载性能,最直接有效的就是减少代码包的大小。

减小代码包的大小的方式:

  • 在开发者工具中开启“代码压缩”的选项。

  • 分包加载。

  • 即时清理废弃的代码,尤其是比较大的第三方库,以及一些不使用的图片等资源文件。

  • 控制代码包内图片等资源:减少本地图片等资源文件,必要时使用网络图片代码。

2.2.2 开发者代码注入优化

  • 减少启动过程的同步调用

  • 启动 懒注入(添加这项配置后,未使用到的代码文件将不被执行)

    { "lazyCodeLoading": "requiredComponents" }

2.2.3 页面渲染优化

  • 提前请求
  1. 数据请求并不依赖页面结构完整,可以在页面加载时或代码注入时即 在页面 onload 就发起,而不需要等待页面渲染完成。用户等待请求返回的时间就会进一步缩短。
  2. 数据预拉取
  3. 周期性更新
  • 利用缓存

利用storage API 对请求结果请进缓存,二次启动时,直接用缓存数据完成渲染,然后再在后台进行据更新,保证用户第一时间看到页面内容,同时,即使在无网环境下,用户也可以使用小程序的部分功能。

  • 避免白屏

请求过程中,在页面中先展示一个基础的骨架和结合已有的数据进行展示,可以让用户对页面内容有一个心理预期,减少在等待的时候离开的可能。

  • 精简首屏数据
  • 及时反馈

对于一些耗时的操作,在用户等待的过程中,即使给予交互操作的反馈,避免用户以为小程序无响应。

2.3 附录:分包加载

2.3.1 分包加载机制

根据业务场景,将用户访问率高的页面放在主包里,将访问率低的页面放入子包里,按需加载;

在小程序启动时,默认会下载主包并启动主包内页面,当用户进入分包内某个页面时,客户端会把对应分包下载下来,下载完成后再进行展示。

2.3.2 独立分包

独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。

可以按需将某些具有一定功能独立性的页面配置到独立分包中。当小程序从普通的分包页面启动时,需要首先下载主包;而独立分包不依赖主包即可运行,可以很大程度上提升分包页面的启动速度。

2.3.3 分包预下载

开发者可以通过配置,在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。对于独立分包,也可以预下载主包。

3 渲染性能优化

3.1 小程序渲染原理

小程序是基于 双线程 模型的。

在这种架构中,小程序的渲染层使用 WebView 作为渲染载体,而逻辑层则由独立的 JsCore 线程运行 JS 脚本,双方并不具备数据直接共享的通道,因此渲染层和逻辑层的通信要由 Native 的 JSBrigde 做中转。

小程序更新视图数据的通信流程:

每当小程序视图数据需要更新时,逻辑层会调用小程序宿主环境提供的 setData 方法将数据从逻辑层传递到视图层,经过一系列渲染步骤之后完成UI视图更新。完整的通信流程如下:

  • 小程序逻辑层调用宿主环境的 setData 方法。

  • 逻辑层执行 JSON.stringify 将待传输数据转换成字符串并拼接到特定的JS脚本,并通过evaluateJavascript 执行脚本将数据传输到渲染层。

  • 渲染层接收到后, WebView JS 线程会对脚本进行编译,得到待更新数据后进入渲染队列等待 WebView 线程空闲时进行页面渲染。

  • WebView 线程开始执行渲染时,待更新数据会合并到视图层保留的原始 data 数据,并将新数据套用在WXML片段中得到新的虚拟节点树。经过新虚拟节点树与当前节点树的 diff 对比,将差异部分更新到UI视图。同时,将新的节点树替换旧节点树,用于下一次重渲染。

综上,逻辑层使用 setData 向视图层传输数据,由视图层进行页面更新。从架构上,逻辑层和视图层无法直接共享数据的,数据传输是一次跨进程的通信,会有一定的通信开销,这一开销与传输的数据量正相关。

3.2 正确使用 setData

  • 避免在 data 中放置与渲染无关的数据,只在 data 中放置与页面渲染相关的数据。
  • 避免使用 setData 一次性传输大量数据,只对发生变化的数据进行 setData。当数据量达到 1MB 时,耗时会增加到数百毫秒,在一些低端机型上可能需要1s甚至更久。

比如,对于长列表,利用 setData 进行列表局部刷新。

// 后台获取列表数据
const list = requestSync(); 
// 更新整个列表
this.setData({ list });

实际上,只有个别字段需要更新时,我们可以这么写来避免整个 list 列表更新:

// 后台获取列表数据
const list = requestSync(); 
// 局部更新列表
this.setData({   
 'list[0].src': list[0].src
});

又或者是:

// 1.通过一个二维数组来存储数据
let feedList = [[array]];

// 2.维护一个页面变量值,加载完一次数据page++
let page = 1

// 3.页面每次滚动到底部,通过数据路径更新数据
onReachBottom:()=>{    
    fetchNewData().then((newVal)=>{        
        this.setData({            
            ['feedList[' + (page - 1) + ']']: newVal,        
        })    
    }
}

// 4.最终我们的数据是[[array1],[array2]]这样的格式,然后通过wx:for遍历渲染数据
  • 不要在短时间内连续的频繁调用 setData,对连续的 setData 尽可能的进行合并。频繁调用会导致 webview 在一段时间内一直在进行数据数据处理和页面更新,无法即使响应用户操作,造成操作卡顿,也无法及时将用户的操作事件反馈给逻辑层,造成交互延迟。

  • 切勿在后台页面进行setData

小程序的每个页面虽然是在独立的 webview 中运行的,但是 webview 的 js 引擎是共享的,如果一个页面出现 setData 的误用,会抢占资源,影响其他页面的运行。

举例:页面中有一个倒计时,比如电商的秒杀活动,当秒杀页面进入后台,如果没有定制定时器,那这时后台页面还是会不断进行更新,抢占了当前资源。

3.3 事件的正确使用

视图层将事件反馈给逻辑层时,同样需要一个通信过程,通信的方向是从视图层到逻辑层。因为这个通信过程是异步的,会产生一定的延迟,延迟时间同样与传输的数据量正相关,数据量小于64KB时在30ms内

降低延迟方法:

  • 去掉不必要的事件绑定(WXML中的bindcatch),从而减少通信的数据量和次数;

  • 事件绑定时需要传输targetcurrentTargetdataset,因而不要在节点的data前缀属性中放置过大的数据。

3.4 避免不当使用页面滑动(onPageSrcoll)的监听回调

每一次事件监听都是一次视图到逻辑的通信过程,所以只在必要的时候监听pageSrcoll。

  • 只在必要的时候舰艇 onPageSrcoll 事件。
  • 避免在 onPageSrcoll 中执行复杂逻辑。
  • 避免在 onPageSrcoll 中频繁调用 setData。
  • 在某些场景下,比如曝光量统计。通常的做法是,监听 onPageSrcoll 事件,并不断查询元素位置,节点信息查询(createSelectorQuery)也是非常耗时的,会影响小程序通讯渲染的性能。在这种情况下,使用节点布局相交状态监听(IntersectionObserver)替代,减少不要的通信。

而当需要在频繁触发的用户事件(如 PageScroll 、 Resize 事件)中调用 setData ,合理的利用 函数防抖(debounce) 和 函数节流(throttle) 可以减少 setData 执行次数。

函数防抖(debounce):函数在触发n秒后才执行一次,如果在n秒内重复触发函数,则重新计算时间。
函数节流(throttle):单位时间内,只会触发一次函数,如果同一个单位时间内触发多次函数,只会有一次生效。

我们还可以自己设计一个 diff 算法,重新对 setData 进行封装,使得在 setData 执行之前,让待更新的数据与原 data 数据做 diff 对比,计算出数据差异 patch 对象,判断 patch 对象是否为空,如果为空则跳过执行更新,否则再将 patch 对象执行 setData 操作,从而达到减少数据传输量和降低执行 setData 频率的目的。

// setData重新封装成新的方法,使得数据更新前先对新旧数据做diff对比,再执行setData方法
this.update = (data) => {    
    return new Promise((resolve, reject) => {        
        const result = diff(data, this.data);        
        if (!Object.keys(result).length) {            
            resolve(null);            
            return;        
        }         
        this.setData(result, () => {            
            resolve(result);        
        });    
    });
}

具体流程如下图:

3.5 使用自定义组件

自定义组件除了有利于代码复用,提升开发效率外,还可以有效的提升页面局部频繁更新时的性能。

自定义组件的更新只在组件内部进行,不受页面其他部分内容的影响,可以大大降低页面更新的开销。

小程序自定义组件的实现是由小程序官方设计的 Exparser 框架所支持。

在页面引用自定义组件后,当初始化页面时,Exparser 会在创建页面实例的同时,也会根据自定义组件的注册信息进行组件实例化,然后根据组件自带的 data 数据和组件WXML,构造出独立的 Shadow Tree ,并追加到页面 Composed Tree 。创建出来的 Shadow Tree 拥有着自己独立的逻辑空间、数据、样式环境及setData调用:

基于自定义组件的 Shadow DOM 模型设计,我们可以将页面中一些需要高频执行 setData 更新的功能模块(如倒计时、进度条等)封装成自定义组件嵌入到页面中。

当这些自定义组件视图需要更新时,执行的是组件自己的 setData ,新旧节点树的对比计算和渲染树的更新都只限于组件内有限的节点数量,有效降低渲染时间开销。

当然,并不是使用自定义组件越多会越好,页面每新增一个自定义组件, Exparser 需要多管理一个组件实例,内存消耗会更大,当内存占用上升到一定程度,有可能导致 iOS 将部分 WKWebView 回收,安卓机体验会变得更加卡顿。因此要合理的使用自定义组件,同时页面设计也要注意不滥用标签。

3.6 尽可能的减少数据结构的嵌套层数

时间开销大体上与节点树中节点的总量成正比例关系。因而减少WXML中节点的数量可以有效降低初始渲染和重渲染的时间开销,提升渲染性能。

初始渲染时,将初始数据套用在对应的WXML片段上生成节点树。最后根据节点树包含的各个节点,在界面上依次创建出各个组件。

重渲染时,对当前节点树与新节点树的比较时,会着重比较setData数据影响到的节点属性。因而,去掉不必要设置的数据、减少setData的数据量也有助于提升这一个步骤的性能。

3.7 key值在列表渲染中的作用

key值在列表渲染的时候,能够提升列表渲染性能。

小程序的页面是如何渲染的,主要分为以下几步:

  • 将wxml结构的文档构建成一个vdom虚拟数

  • 页面有新的交互,产生新的vdom数,然后与旧数进行比较,看哪里有变化了,做对应的修改(删除、移动、更新值)等操作

  • 最后再将vdom渲染成真实的页面结构

key值的作用就在第二步,当数据改变触发渲染层重新渲染的时候,会校正带有 key 的组件,框架会确保他们被重新排序,而不是重新创建,以确保使组件保持自身的状态,并且提高列表渲染时的效率。

key值如果不指明,默认会按数组的索引来处理,因而会导致一些类似input等输入框组件的值出现混乱的问题。

  • 不加key,在数组末尾追加元素,之前已渲染的元素不会重新渲染。但如果是在头部或者中间插入元素,整个list被删除重新渲染,且input组件的值还出现了混乱,值没有正常被更新 。

  • 添加key,在数组末尾、中间、或者头部插入元素,其它已存在的元素都不会被重新渲染,值也能正常被更新。因而,在做list渲染时,如果list的顺序发生变化时,最好增加key,且不要简单的使用数组索引当做key。

PS:亲身体验的数据,如果你们原来小程序的初次渲染到达260ms左右的话,处理好自定义组件,setData相关的优化等基础优化,长列表暂未优化的情况下,初次渲染就能提升到 140ms左右。