提升小程序性能和体验

背景:首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们可以从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。

小程序官方性能指标

小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现setData 数据量元素节点数 和 网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):

  • 首屏时间不超过 5 秒;
  • 渲染时间不超过 500ms;
  • 每秒调用 setData 的次数不超过 20 次;
  • setData 的数据在 JSON.stringify 后不超过 256kb;
  • 页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个;
  • 所有网络请求都在 1 秒内返回结果;

详见 小程序性能评分规则

1、我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。

2、这些指标会直接作为小程序体验评分工具的性能评分规则,其集成在开发者工具中,是目前检测小程序性能问题最直接有效的途径。在小程序运行时实时检查相关问题点,并为开发者给出优化建议。

小程序后台性能分析

小程序管理平台 和 小程序助手 为开发者提供了大量的真实数据统计。性能分析面板从 启动性能运行性能 和 网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。

启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时

提升小程序性能和体验_第1张图片

了解小程序底层架构

为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 web 浏览器的差异性。

微信小程序是大前端跨平台技术的其中一种产物,与当下其他热门的技术 React Native、Weex、Flutter 等不同,小程序的最终渲染载体依然是浏览器内核,而不是原生客户端。

而对于传统的网页来说,UI 渲染和 JS 脚本是在同一个线程中执行,所以经常会出现 “阻塞” 行为。微信小程序基于性能的考虑,启用了双线程模型

  • 视图层:也就是 webview 线程,负责启用不同的 webview 来渲染不同的小程序页面;
  • 逻辑层:一个单独的线程执行 JS 代码,可以控制视图层的逻辑;

提升小程序性能和体验_第2张图片

任何线程间的数据传输都是有延时的,这意味着逻辑层和视图层间通信是异步行为。除此之外,微信为小程序提供了很多客户端原生能力,在调用客户端原生能力的过程中,微信主线程和小程序双线程之间也会发生通信,这也是一种异步行为。这种异步延时的特性会使运行环境复杂化,稍不注意,就会产出效率低下的编码。

作为小程序开发者,我们常常会被下面几个问题所困扰:

  • 小程序启动慢;
  • 白屏时间长;
  • 页面渲染慢;
  • 运行内存不足;

所以,我们需要结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。

一、小程序启动太慢?

小程序启动阶段,也就是如下图所示的展示加载界面的阶段。

提升小程序性能和体验_第3张图片

在这个阶段中(包括启动前后的时机),微信会默默完成下面几项工作:

1. 准备运行环境:

在小程序启动前,微信会先启动双线程环境,并在线程中完成小程序基础库的初始化和预执行。

2. 下载小程序代码包:

在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。

3. 加载小程序代码包:

小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。

4. 初始化小程序首页:

在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合 WXML 结构、WXSS 样式和初始数据来渲染界面。

综合考虑,为了节省小程序的“点点点”时间(小程序的启动动画是三个圆点循环跑马灯),除了给每位用户发一台高配 5G 手机并顺带提供千兆宽带网络之外,还可以尽量 控制代码包大小,缩小代码包的下载时间。

①无用文件、函数、样式剔除

经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。

因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。

  • 文件依赖分析

在小程序中,所有页面的路径都需要在小程序代码根目录 app.json 中被声明,类似地,自定义组件也需要在页面配置文件 page.json 中被声明。另外,WXML、WXSS 和 JS 的模块化都需要特定的关键字来声明依赖引用关系。

import api from '../../api/index'; // 如果没有被使用,应当删除
import rule from './config';
  • 无用函数剔除              提升小程序性能和体验_第4张图片
  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是借助 Babel 把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。CSS 的 Tree-Shaking 可以利用 PurifyCSS 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”。

②减少代码包中的静态资源文件

小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPGPNG 等格式文件,本身已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在 CDN 上。需要注意,Base64 格式本质上是长字符串,和 CDN 地址比起来也会更占空间

// 应该尽量避免css文件中这种写法,打包后转成base64更占空间
.icon-cart {
  background-image: url('../../assets/images/icon/icon-cart.png');
}

.icon-close {
  background-image: url('../../assets/images/icon/icon-close.png');
} 

③逻辑后移,精简业务逻辑

通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。一般不涉及前端计算的展示类逻辑,都可以适当做后移。

④复用模板插件

如果需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。我们需要提供更丰富的可配置能力,实现更高的代码复用度。

当然,要完成这样的插件化改造免不了几个先决条件:

  • 用户体验设计的统一。如果设计风格总是天差地别的,强行插件化只会成为累赘。
  • 服务端接口的统一。同上,如果得浪费大量的精力来兼容不同模块间的接口字段差异,那也会更加麻烦。

⑤分包加载

小程序启动时只会下载主包/独立分包,启用分包可以有效减少下载时间。(独立)分包需要遵循一些原则,详细的可以看官方文档:

  • 使用分包
  • 独立分包

⑥部分页面 h5 化

小程序提供了 web-view 组件,支持在小程序环境内访问网页。当实在无法在小程序代码包中腾出多余空间时,可以考虑降级方案 —— 把部分页面 h5 化。小程序和 h5 的通信可以通过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档。

二、白屏时间过长?

白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是 FMP (首次有效绘制)。

对于大部分小程序来说,页面首屏展示的内容都需要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:

  • 网络资源加载时间
  • 渲染时间

①启用本地缓存

小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地 I/O 读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。

但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。

小程序默认会按照 不同小程序不同微信用户 这两个维度对缓存空间进行隔离。

②数据预拉取

小程序官方为开发者提供了一个在小程序冷启动时提前拉取第三方接口的能力:数据预拉取。关于冷启动和热启动的定义可以看 这里

数据预拉取的原理:在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,并且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData 从本地缓存获取数据即可。这种做法可以充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。

但这个能力存在一些不成熟的地方:

  • 预拉取的数据会被强缓存

    由于预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会重新发起请求。经过真机实测,在微信购物入口冷启动的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来说是非常致命的。

  • 请求体和响应体都无法被拦截

    由于请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,所以本地代理无法拦截到这一次真实请求,这会导致开发者无法通过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。

    小程序内部接口的响应体类型都是 application/octet-stream,即数据格式未知,使本地代理无法正确解析。

  • 微信服务器发起的请求没有提供区分线上版和开发版的参数,且没有提供用户 IP 等信息

如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。

③跳转时预拉取

为了尽快获取到服务端数据,比较常见的做法是在页面 onLoad 钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面 onLoad 的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为 300 ~ 400 毫秒。

实际上,我们可以在发起跳转前(如 wx.navigateTo 调用前),提前请求下一个页面的主接口并存储在全局 Promise 对象中,待下个页面加载完成后从 Promise 对象中读取数据即可。

这也是双线程模型所带来的优势之一,不同于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。

④分包预下载

如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。

幸好,小程序提供了 分包预下载 能力,开发者可以配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。

⑤非关键渲染数据延迟请求

这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程中必须发生的事件。

我们根据小程序首页的页面结构,可以把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。

在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另一个接口获取,通过拆分的手段来降低主接口的调用时延,同时减少响应体的数据量,缩减网络传输时间。

提升小程序性能和体验_第5张图片

⑥分屏渲染

这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。我们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。

⑦接口聚合,请求合并

在小程序中,发起网络请求是通过 wx.request 这个 API。我们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有类似的限制,但区别在于不是针对域名限制,而是针对 API 调用:

  • wx.request (HTTP 连接)的最大并发限制是 10 个;
  • wx.connectSocket (WebSocket 连接)的最大并发限制是 5 个;

超出并发限制数目的 HTTP 请求将会被阻塞,需要在队列中等待前面的请求完成,从而一定程度上增加了请求时延。因此,对于职责类似的网络请求,最好采用节流的方式,先在一定时间间隔内收集数据,再合并到一个请求体中发送给服务端。

⑧图片资源优化

图片资源一直是移动端系统中抢占大流量的部分,尤其是对于电商系统。优化图片资源的加载可以有效地加快页面响应时间,提升首屏渲染速度。

  • 使用 WebP 格式

WebP 是 Google 推出的一种支持有损/无损压缩的图片文件格式,得益于更优的图像数据压缩算法,其与 JPG、PNG 等格式相比,在肉眼无差别的图片质量前提下具有更小的图片体积(据官方说明,WebP 无损压缩体积比 PNG 小 26%,有损压缩体积比 JPEG 小 25-34%)。小程序的 image 组件 支持 JPG、PNG、SVG、WEBP、GIF 等格式。

  • 图片裁剪&降质

鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸 2 倍于页面元素真实尺寸比较合适)。我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:

裁剪成 100x100 的图片:https://{host}/s100x100_jfs/{file_path}

降质 70%:https://{href}!q70

  • 图片懒加载、雪碧图(CSS Sprite)优化

这两者都是比较老生常谈的图片优化技术,小程序的 image 组件 自带 lazy-load 懒加载支持。雪碧图技术(CSS Sprite)可以参考 w3schools 的教程。

  • 降级加载大图资源

在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染性能。

小程序会把已加载的静态资源缓存在本地,当短时间内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。因此,对于大图资源,我们可以先呈现高度压缩的模糊图片,同时利用一个隐藏的  节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提升效果相比,这点体验落差是可以接受的。





// banner.js
Component({
  ready() {
    this.originUrl = 'https://path/to/picture'  // 图片源地址
    this.setData({
      url: compress(this.originUrl)             // 加载压缩降质的图片
      preloadUrl: this.originUrl                // 预加载原图
    })
  },
  methods: {
    onImgLoad() {
      this.setData({
        url: this.originUrl                       // 加载原图
      })
    }
  }
})

⑨骨架屏

一方面,我们可以从降低网络请求时延、减少关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另一方面,我们也需要从用户感知的角度优化加载体验。

“白屏” 的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。

骨架屏目前在业界被广泛应用,首页可选择使用灰色豆腐块作为骨架屏的主元素,大致勾勒出各模块主体内容的样式布局。由于微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,因此首页的骨架屏可以通过 WXSS 样式静态渲染的。




  页面主体

但这种做法的维护成本比较高,每次页面主体模块更新迭代,都需要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此之外,感官上从骨架屏到真实模块的切换是跳跃式的,这是因为骨架屏组件和页面主体节点之间的关系是整体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展示)主体内容。

为了使用户感知体验更加丝滑,我们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就可以轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此之外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass 模块集中管理,业务组件只需要在适当的节点挂上 skeleton 和 skeleton__block 样式块即可,极大地降低了维护成本。



  
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}

三、如何提升渲染性能?

当调用 wx.navigateTo 打开一个新的小程序页面时,小程序框架会完成这几步工作:

1. 准备新的 webview 线程环境,包括基础库的初始化;

2. 从逻辑层到视图层的初始数据通信;

3. 视图层根据逻辑层的数据,结合 WXML 片段构建出节点树(包括节点属性、事件绑定等信息),最终与 WXSS 结合完成页面渲染;

由于微信会提前开始准备 webview 线程环境,所以小程序的渲染损耗主要在后两者 数据通信 和 节点树创建/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:

  • 降低线程间通信频次;
  • 减少线程间通信的数据量;
  • 减少 WXML 节点数量;

①合并 setData 调用

尽可能地把多次 setData 调用合并成一次。我们除了要从编码规范上践行这个原则,还可以通过一些技术手段降低 setData 的调用频次。

②只把与界面渲染相关的数据放在 data 中

setData 传输的数据量越多,线程间通信的耗时越长,渲染速度就越慢。与视图层渲染无关的数据尽量不要放在 data 中,可以放在页面(组件)类的其他字段下。

③应用层的数据 diff

每当调用 setData 更新数据时,会引起视图层的重新渲染,小程序会结合新的 data 数据和 WXML 片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。

即使小程序在底层框架层面已经对节点树更新进行了 diff,但我们依旧可以优化这次 diff 的性能。譬如,在调用 setData 时,提前确保传递的所有新数据都是有变化的,也就是针对 data 提前做一次 diff。

④去掉不必要的事件绑定

当用户事件(如 ClickTouch 事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。

所以,尽量减少不必要的事件绑定,尤其是像 onPageScroll 这种会被频繁触发的用户事件,会使通信过程频繁发生。

⑤去掉不必要的节点属性

组件节点支持附加自定义数据 dataset(见下面例子),当用户事件被触发时,视图层会把事件 target 和 dataset 数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。



  Click Me!
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})

⑥适当的组件颗粒度

小程序的组件模型与 Web Components 标准中的 ShadowDOM 非常类似,每个组件都有独立的节点树,拥有各自独立的逻辑空间(包括独立的数据、setData 调用、createSelectorQuery 执行域等)。

不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树 diff 的效率,从而影响到组件内 setData 的性能。另外,如果组件内使用了 createSelectorQuery 来查找节点,过于庞大的节点树结构也会影响查找效率。

⑦事件总线,替代组件间数据绑定的通信方式

WXML 数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式。如下面例程所示:Component A 组件中的变量 ab 通过组件属性传递给 Component B 组件。在此过程中,不可避免地需要经历一次 Component A 组件的 setData 调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?


// Component B
Component({
  properties: {
    propA: String,
    propB: String,
  },
  methods: {
    onLoad: function() {
      this.data.propA
      this.data.propB
    }
  }
})

推荐一种特定场景下非常便捷的做法:通过事件总线(EventBus),也就是发布/订阅模式,来完成由父向子的数据传递。其构成非常简单(例程只提供关键代码...):

  • 一个全局的事件调度中心

    class EventBus {
      constructor() {
        this.events = {}
      }
    
      on(key, cb) { this.events[key].push(cb) }
    
      trigger(key, args) { 
        this.events[key].forEach(function (cb) {
          cb.call(this, ...args)
        })
      }
      
      remove() {}
    }
    
    const event = new EventBus()
  • 事件订阅者

    // 子组件
    Component({
      created() {
        event.on('data-ready', (data) => { this.setData({ data }) })
      }
    })
  • 事件发布者

    // Parent
    Component({
      ready() {
        event.trigger('data-ready', data)
      }
    })

子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。

但并非所有场景都适合这种做法。具有 “数据单向传递”“展示型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会非常高;但若是频繁 “双向数据流“ 的场景,用这种方式会导致事件交错难以维护。

⑧组件层面的 diff

我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置。

首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板