京喜小程序的高性能打造之路

本文阅读时长约15分钟。京喜小程序开发团队核心成员倾力之做,都是干货,读完必定会收获满满,请你们耐心阅读~javascript

背景

京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了咱们开发团队的最基本执行原则。css

首页做为小程序的门户,其性能表现和用户留存率息息相关。所以,咱们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。html

除此以外,京喜首页在微信小程序、H五、APP 三端都有落地场景,为了提升研发效率,咱们使用了 Taro 框架实现多端统一,所以下文中有部份内容是和 Taro 框架息息相关的。前端

怎么定义高性能?

提起互联网应用性能这个词,不少人在脑海中的词法解析就是,“是否足够快?”,彷佛加载速度成为衡量系统性能的惟一指标。但这实际上是不够准确的,试想一下,若是一个小程序加载速度很是快,用户花费很短期就能看到页面的主体内容,但此时搜索框却没法输入内容,功能没法被流畅使用,用户可能就不会关心页面渲染有多快了。因此,咱们不该该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程当中能感知到的与应用加载相关的每一个节点。java

谷歌为 Web 应用定义了以用户为中心的性能指标体系,每一个指标都与用户体验节点息息相关:git

体验 指标
页面可否正常访问? 首次内容绘制 (First Contentful Paint, FCP)
页面内容是否有用? 首次有效绘制 (First Meaningful Paint, FMP)
页面功能是否可用? 可交互时间 (Time to Interactive, TTI)

其中,“是否有用?” 这个问题是很是主观的,对于不一样场景的系统可能会有彻底不同的回答,因此 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。github

小程序做为一个新的内容载体,衡量指标跟 Web 应用是很是相似的。对于大多数小程序而言,上述指标对应的含义为:web

  • FCP:白屏加载结束;
  • FMP:首屏渲染完成;
  • TTI:全部内容加载完成;

综上,咱们已基本肯定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。算法

小程序官方性能指标

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

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

详见 小程序性能评分规则

咱们应该把这一系列的官方指标做为小程序的性能及格线,不断地打磨和提高小程序的总体体验,下降用户流失率。另外,这些指标会直接做为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。

咱们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:

  • 首屏时间不超过 2.5 秒;
  • setData 的数据量不超过 100kb;
  • 全部网络请求都在 1 秒内返回结果;
  • 组件滑动、长列表滚动无卡顿感;

体验评分工具

小程序提供了 体验评分工具(Audits 面板) 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。

体验评分面板

以上截图均来自小程序官方文档

体验评分工具是目前检测小程序性能问题最直接有效的途径,咱们团队已经把体验评分做为页面/组件是否能达到精品门槛的重要考量手段之一。

小程序后台性能分析

咱们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据每每须要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能运行性能网络性能 这三个维度分析数据,开发者能够根据客户端系统、机型、网络环境和访问来源等条件作精细化分析,很是具备考量价值。

小程序助手性能分析

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

第三方测速系统

不少时候,宏观的耗时统计对于性能瓶颈点分析每每是杯水车薪,做用甚少,咱们须要更细致地针对某个页面某些关键节点做测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoadonReady数据加载完成首屏渲染完成各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。

内部测速系统

另外,微信为开发者提供了 测速系统,也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的能够尝试。

了解小程序底层架构

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

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

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

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

双线程模型图

上图来自小程序官方开发指南

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

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

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

接下来,咱们会结合小程序的底层架构分析出这些问题的根本缘由,并针对性地给出解决方案。

小程序启动太慢?

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

小程序加载界面

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

1. 准备运行环境:

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

小程序基础库包括 WebView 基础库和 AppService 基础库,前者注入到视图层中,后者注入到逻辑层中,分别为所在层级提供其运行所需的基础框架能力。

2. 下载小程序代码包:

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

3. 加载小程序代码包:

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

在此阶段,主包内的全部页面 JS 文件及其依赖文件都会被自动执行。

在页面注册过程当中,基础库会调用页面 JS 文件的 Page 构造器方法,来记录页面的基础信息(包括初始数据、方法等)。

4. 初始化小程序首页:

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

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

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

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

所以,咱们须要及时地剔除再也不使用的模块,以保证代码包空间利用率保持在较高水平。经过一些工具化手段能够有效地辅助完成这一工做。

  • 文件依赖分析

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

WXML 中的 importinclude

<!-- A.wxml -->
<template name='A'> <text>{{text}}</text> </template>

<!-- B.wxml -->
<import src="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
复制代码
<!-- A.wxml -->
<text> A </text>

<!-- B.wxml -->
<include src="A.wxml"/> <text> B </text> 复制代码

WXSS 中的 @import

@import './A.wxss'
复制代码

JS 中的 require/import

const A = require('./A')
复制代码

因此,能够说小程序里的全部依赖模块都是有迹可循的,咱们只须要利用这些关键字信息递归查找,遍历出文件依赖树,而后把没用的模块剔除掉。

  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是借助 Babel 把代码编译成抽象语法树(AST),经过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这须要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。

而 CSS 的 Tree-Shaking 能够利用 PurifyCSS 插件来完成。关于这两项技术,有兴趣的能够“谷歌一下”,这里就不铺开细讲了。

题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的能够看看这篇分享:小程序工程化探索

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

小程序代码包最终会通过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来讲效果很是低。如 JPGPNG 等格式文件,自己已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。因此,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)以外,建议开发者把图片、视频等静态资源都放在 CDN 上。

须要注意,Base64 格式本质上是长字符串,和 CDN 地址比起来也会更占空间。

逻辑后移,精简业务逻辑

这是一个 “痛并快乐着” 的优化措施。“痛” 是由于须要给后台同窗提改造需求,分分钟被打;“快乐” 则是由于享受删代码的过程,并且万一出 Bug 也不用背锅了...(开个玩笑)

经过让后台承担更多的业务逻辑,能够节省小程序前端代码量,同时线上问题还支持紧急修复,不须要经历小程序的提审、发布上线等繁琐过程。

总结得出,通常不涉及前端计算的展现类逻辑,均可以适当作后移。譬如京喜首页中的幕帘弹窗(以下图)逻辑,这里共有 10+ 种弹窗类型,之前的作法是前端从接口拉取 10+ 个不一样字段,根据优先级和 “是否已展现”(该状态存储在本地缓存) 来决定展现哪种,最后代码大概是这样的:

// 检查每种弹窗类型是否已展现
Promise.all([
  check(popup_1),
  check(popup_2),
  // ...
  check(popup_n)
]).then(result => {
  // 优先级排序
  const queue = [{
    show: result.popup_1
    data: data.popup_1
  }, {
    show: result.popup_2
    data: data.popup_2
  }, 
  // ...
  {
    show: result.popup_n
    data: data.popup_n
  }]
})
复制代码

逻辑后移以后,前端只需负责拿幕帘字段作展现就能够了,代码变成这样:

this.setData({
  popup: data.popup
})
复制代码

首页幕帘弹窗

复用模板插件

京喜首页做为电商系统的门户,须要应对各种频繁的营销活动、升级改版等,同时也要知足不一样用户属性的界面个性化需求(俗称 “千人千面”)。如何既能减小为应对多样化场景而产生的代码量,又能够提高研发效率,成为燃眉之急。

相似于组件复用的理念,咱们须要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的 “乐高” 积木玩具,咱们把首页模块的模板元素做颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就比如堆积木。当后续产品/运营须要新增模板时,只要在插件库中挑选插件排列组合便可,不须要额外新增/修改组件内容,也更不会产生难以维护的 if / else 逻辑,so easy~

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

  • 用户体验设计的统一。若是设计风格老是天差地别的,强行插件化只会成为累赘。
  • 服务端接口的统一。同上,若是得浪费大量的精力来兼容不一样模块间的接口字段差别,将会很是蛋疼。

下面为你们提供部分例程来辅助理解。其中,use 方法会接受各种处理钩子最终拼接出一个 Function,在对应模块处理数据时会被调用。

// bi.helper.js

/** * 插件引擎 * @param {function} options.formatName 标题处理钩子 * @param {function} options.validList 数据校验器钩子 */ 
const use = options => data => format(data)

/** * 预置插件库 */ 
nameHelpers = {
  text: data => data.text,
  icon: data => data.icon
}
listHelpers = {
  single: list => list.slice(0, 1),
  double: list => list.slice(0, 2)
}

/** * “堆积木” */
export default {
  1000: use({
    formatName: nameHelpers.text,
    validList: listHelpers.single
  }),

  1001: use({
    formatName: nameHelpers.icon,
    validList: listHelpers.double
  })
}
复制代码
<!-- bi.wxml -->
<!-- 各模板节点实现 -->
<template name="renderName">
  <view wx:if="{{type === 'text'}}"> text </view>
  <view wx:elif="{{type === 'icon'}}"> icon </view>
</template>

<view class="bi__name">
  <template is="renderName" data="{{...data.name}"/>
</view>
复制代码
// bi.js
Component({
  ready() {
    // 根据 tpl 值选择解析函数
    const formatData = helper[data.tpl]
    this.setData({
      data: formatData(data)
    })
  }
})
复制代码

分包加载

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

部分页面 h5 化

小程序提供了 web-view 组件,支持在小程序环境内访问网页。当实在没法在小程序代码包中腾出多余空间时,能够考虑降级方案 —— 把部分页面 h5 化。

小程序和 h5 的通讯能够经过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档

白屏时间过长?

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

FMP 无法用标准化的指标定义,但对于大部分小程序来讲,页面首屏展现的内容都须要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:

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

启用本地缓存

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

但并不是全部场景都适合缓存策略,譬如对数据即时性要求很是高的场景(如抢购入口)来讲,展现老数据可能会引起一些问题。

小程序默认会按照 不一样小程序不一样微信用户 这两个维度对缓存空间进行隔离。诸如京喜小程序首页也采用了缓存策略,会进一步按照 数据版本号用户属性 来对缓存进行再隔离,避免信息误展现。

数据预拉取

小程序官方为开发者提供了一个在小程序冷启动时提早拉取第三方接口的能力:数据预拉取

关于冷启动和热启动的定义能够看 这里

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

京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提高效果很是明显,但这个能力依然存在一些不成熟的地方:

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

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

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

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

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

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

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

跳转时预拉取

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

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

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

分包预下载

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

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

非关键渲染数据延迟请求

这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。

关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程当中必须发生的事件。

以京喜小程序如此庞大的小程序项目为例,每一个模块背后均可能有着海量的后台服务做支撑,而这些后台服务间的通讯和数据交互都会存在必定的时延。咱们根据京喜首页的页面结构,把全部模块划分红两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。

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

京喜首页浮层模块

分屏渲染

这也是关键渲染路径优化思路之一,经过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。

相似上一条措施,继续以京喜小程序首页为例,咱们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及如下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在全部首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。

京喜首页分屏渲染

为了更好地呈现效果,上面 gif 作了降速处理

接口聚合,请求合并

在小程序中,发起网络请求是经过 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 的教程。

  • 降级加载大图资源

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

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

下面为你们提供部分例程:

<!-- banner.wxml -->
<image src="{{url}}" />

<!-- 图片加载器 -->
<image
  style="width:0;height:0;display:none"
  src="{{preloadUrl}}"
  bindload="onImgLoad"
  binderror="onErrorLoad"
/>
复制代码
// 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                       // 加载原图
      })
    }
  }
})
复制代码

注意,具备 display: none 样式的 <image> 标签只会加载图片资源,但不渲染。

京喜首页的商品轮播模块也采用了这种降级加载方案,在首屏渲染时只会加载第一帧降质图片。以每帧原图 20~50kb 的大小计算,这一措施能够在初始化阶段节省掉几百 kb 的网络资源请求。

Banner 大图降级加载

为了更好地呈现效果,上面 gif 作了降速处理

骨架屏

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

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

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

有趣的是,京喜首页的骨架屏方案经历了 “统一管理”“(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:

<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
  页面主体
</block>
复制代码

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

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

<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}"> <view class="banner_wrapper"></view> </view>
复制代码
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}
复制代码

京喜首页骨架屏

上面的 gif 在压缩过程有些小问题,你们能够直接访问【京喜】小程序体验骨架屏效果。

如何提高渲染性能?

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

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

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

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

因为微信会提早开始准备 webview 线程环境,因此小程序的渲染损耗主要在后二者 数据通讯节点树建立/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:

  • 下降线程间通讯频次;
  • 减小线程间通讯的数据量;
  • 减小 WXML 节点数量;

合并 setData 调用

尽量地把屡次 setData 调用合并成一次。

咱们除了要从编码规范上践行这个原则,还能够经过一些技术手段下降 setData 的调用频次。譬如,把同一个时间片(事件循环)内的 setData 调用合并在一块儿,Taro 框架就使用了这个优化手段。

在 Taro 框架下,调用 setState 时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一块儿,经过 setData 传递给原生小程序。

// 小程序里的时间片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;
复制代码

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

不可贵出,setData 传输的数据量越多,线程间通讯的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大致上呈正相关关系:

数据传输时间与数据量关系图

上图来自小程序官方开发指南

因此,与视图层渲染无关的数据尽可能不要放在 data 中,能够放在页面(组件)类的其余字段下。

应用层的数据 diff

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

即便小程序在底层框架层面已经对节点树更新进行了 diff,但咱们依旧能够优化此次 diff 的性能。譬如,在调用 setData 时,提早确保传递的全部新数据都是有变化的,也就是针对 data 提早作一次 diff。

Taro 框架内部作了这一层优化。在每次调用原生小程序的 setData 以前,Taro 会把最新的 state 和当前页面实例的 data 作一次 diff,筛选出有必要更新的数据再执行 setData

附 Taro 框架的 数据 diff 规则

去掉没必要要的事件绑定

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

因此,尽可能减小没必要要的事件绑定,尤为是像 onPageScroll 这种会被频繁触发的用户事件,会使通讯过程频繁发生。

去掉没必要要的节点属性

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

<!-- wxml -->
<view data-a='A' data-b='B' bindtap='bindViewTap' > Click Me! </view>
复制代码
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})
复制代码

适当的组件颗粒度

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

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

咱们来看一个场景,京喜首页的 “京东秒杀” 模块涉及到一个倒计时特性,是经过 setInterval 每秒调用 setData 来更新表盘时间。咱们经过把倒计时抽离出一个基础组件,能够有效下降频繁 setData 时的性能影响。

京东秒杀

适当的组件化,既能够减少数据更新时的影响范围,又能支持复用,何乐而不为?诚然,并不是组件颗粒度越细越好,组件数量和小程序代码包大小是正相关的。尤为是对于使用编译型框架(如 Taro)的项目,每一个组件编译后都会产生额外的运行时代码和环境 polyfill,so,为了代码包空间,请保持理智...

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

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

<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" /> 复制代码
// 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)
      }
    })
    复制代码

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

但并不是全部场景都适合这种作法。像京喜首页这种具备 “数据单向传递”“展现型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会很是高;但如果频繁 “双向数据流“ 的场景,用这种方式会致使事件交错难以维护。

题外话,Taro 框架在处理父子组件间数据传递时使用的是观察者模式,经过 Object.defineProperty 绑定父子组件关系,当父组件数据发生变化时,会递归通知全部后代组件检查并更新数据。这个通知的过程会同步触发数据 diff 和一些校验逻辑,每一个组件跑一遍大概须要 5 ~ 10 ms 的时间。因此,若是组件量级比较大,整个流程下来时间损耗仍是不小的,咱们依旧能够尝试事件总线的方案。

组件层面的 diff

咱们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在相似的诉求。

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

实现一个支持调度全部业务组件的模板,根据后台下发的模块数组按序循环渲染模板,以下面例程所示。

<!-- index.wxml -->
<template name="render-component">
  <search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
  <nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
  <banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
  <icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>

<view
  class="component-wrapper"
  wx:for="{{comps}}"
  wx:for-item="comp"
>
  <template is="render-component" data="{{...comp}}"/>
</view>
复制代码
// search-bar.js
Component({
  properties: {
    floorId: Number,
  },
  created() {
    event.on('data-ready', (comps) => {
      const data = comps[this.data.floorId] // 根据楼层位置取数据
    })
  }
})
复制代码

貌似很是轻松地完成需求,但值得思考的是:若是组件顺序调整了,全部组件的生命周期会发生什么变化?

假设,上一次渲染的组件顺序是 ['search-bar','nav-bar','banner', 'icon-nav'],如今须要把 nav-bar 组件去掉,调整为 ['search-bar','banner', 'icon-nav']。经实验得出,当某个组件节点发生变化时,其前面的组件不受影响,其后面的组件都会被销毁从新挂载。

原理很简单,每一个组件都有各自隔离的节点树(ShadowTree),页面 body 也是一个节点树。在调整组件顺序时,小程序框架会遍历比较新/旧节点树的差别,因而发现新节点树的 nav-bar 组件节点不见了,就认为该(树)分支下从 nav-bar 节点起发生了变化,日后节点都须要重渲染。

但实际上,这里的组件顺序是没有变化的,丢失的组件按道理不该该影响到其余组件的正常渲染。因此,咱们在 setData 前先进行了新旧组件列表 diff:若是 newList 里面的组件是 oldList 的子集,且相对顺序没有发生变化,则全部组件不从新挂载。除此以外,咱们还要在接口数据的相应位置填充上空数据,把该组件隐藏掉,done。

经过组件 diff 的手段,能够有效下降视图层的渲染压力,若是有相似场景的朋友,也能够参考这种方案。

内存占用太高?

想必没有什么会比小程序 Crash 更影响用户体验了。

当小程序占用系统资源太高,就有可能会被系统销毁或被微信客户端主动回收。应对这种尴尬场景,除了提示用户提高硬件性能以外(譬如来京东商城买新手机),还能够经过一系列的优化手段下降小程序的内存损耗。

内存不足弹窗提示

内存预警

小程序提供了监听内存不足告警事件的 API:wx.onMemoryWarning,旨在让开发者收到告警时及时释放内存资源避免小程序 Crash。然而对于小程序开发者来讲,内存资源目前是没法直接触碰的,最多就是调用 wx.reLaunch 清理全部页面栈,重载当前页面,来下降内存负荷(此方案过于粗暴,别冲动,想一想就好...)。

不过内存告警的信息收集却是有意义的,咱们能够把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面 Crash 率比较高,从而针对性地作优化,下降页面复杂度等等。

回收后台页面计时器

根据双线程模型,小程序每个页面都会独立一个 webview 线程,但逻辑层是单线程的,也就是全部的 webview 线程共享一个 JS 线程。以致于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setIntervalsetTimeout 定时器:

// Page A
Page({
  onLoad() {
    let i = 0
    setInterval(() => { i++ }, 100)
  }
})
复制代码

即便如小程序的 <swiper> 组件,在页面进入后台态时依然是会持续轮播的。

正确的作法是,在页面 onHide 的时候手动把定时器清理掉,有必要时再在 onShow 阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData 大量数据,这就很是难受了...

避免频发事件中的重度内存操做

咱们常常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都须要咱们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型以后不难发现,页面滚动时 onPageScroll 被频发触发,会使逻辑层和视图层发生持续通讯,若这时候再 “火上浇油” 调用 setData 传输大量数据,会致使内存使用率快速上升,使页面卡顿甚至 “假死”。因此,针对频发事件的监听,咱们最好遵循如下原则:

  • onPageScroll 事件回调使用节流;
  • 避免 CPU 密集型操做,譬如复杂的计算;
  • 避免调用 setData,或减少 setData 的数据量;
  • 尽可能使用 IntersectionObserver 来替代 SelectorQuery,前者对性能影响更小;

大图、长列表优化

小程序官方文档 描述,大图片和长列表图片在 iOS 中会引发 WKWebView 的回收,致使小程序 Crash。

对于大图片资源(譬如满屏的 gif 图)来讲,咱们只能尽量对图片进行降质或裁剪,固然不使用是最好的。

对于长列表,譬如瀑布流,这里提供一种思路:咱们能够利用 IntersectionObserver 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据从新加载该组件。

然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。咱们能够适当地调整销毁阈值,或者优化骨架图的样式来尽量提高体验感。

小程序官方提供了一个 长列表组件,能够经过 npm 包的方式引入,有兴趣的能够尝试。

总结

结合上述的种种方法论,京喜小程序首页进行全方位升级改造以后给出了答卷:

1. Audits 审计工具的性能得分 86

2. 优化后的首屏渲染完成时间(FMP):

优化后的首屏渲染时间

3. 优化先后的测速数据对比:

优化先后的测速数据对比

然而,业务迭代在持续推动,多样化的用户场景徒增不减,性能优化将成为咱们平常开发中挥之不去的原则和主题。本文以微信小程序开发中与性能相关的问题为出发点,基于小程序的底层框架原理,探究小程序性能体验提高的各类可能性,但愿能为各位小程序开发者带来参考价值。

参考


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

image
相关文章
相关标签/搜索