本文阅读时长约 20 分钟。京喜小程序开发团队核心成员倾力之作,都是干货,读完一定会收获满满,请大家耐心阅读~
背景—
京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了我们开发团队的最基本执行原则。
首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。
除此之外,京喜首页在微信小程序、H5、APP 三端都有落地场景,为了提高研发效率,我们使用了 Taro[1] 框架实现多端统一,因此下文中有部分内容是和 Taro 框架息息相关的。
怎么定义高性能?—
提起互联网应用性能这个词,很多人在脑海中的词法解析就是,“是否足够快?”,似乎加载速度成为衡量系统性能的唯一指标。但这其实是不够准确的,试想一下,如果一个小程序加载速度非常快,用户花费很短时间就能看到页面的主体内容,但此时搜索框却无法输入内容,功能无法被流畅使用,用户可能就不会关心页面渲染有多快了。所以,我们不应该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程中能感知到的与应用加载相关的每个节点。
谷歌为 Web 应用定义了以用户为中心的性能指标体系,每个指标都与用户体验节点息息相关:
体验 | 指标 |
---|---|
页面能否正常访问? | 首次内容绘制 (First Contentful Paint, FCP) |
页面内容是否有用? | 首次有效绘制 (First Meaningful Paint, FMP) |
页面功能是否可用? | 可交互时间 (Time to Interactive, TTI) |
其中,“是否有用?” 这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。
小程序作为一个新的内容载体,衡量指标跟 Web 应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:
-
FCP:白屏加载结束; -
FMP:首屏渲染完成; -
TTI:所有内容加载完成;
综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。
小程序官方性能指标
小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现、setData
数据量、元素节点数 和 网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):
-
首屏时间不超过 5 秒; -
渲染时间不超过 500ms; -
每秒调用 setData
的次数不超过 20 次; -
setData
的数据在JSON.stringify
后不超过 256kb; -
页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个; -
所有网络请求都在 1 秒内返回结果;
详见 小程序性能评分规则[2]
我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。
我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:
-
首屏时间不超过 2.5 秒; -
setData
的数据量不超过 100kb; -
所有网络请求都在 1 秒内返回结果; -
组件滑动、长列表滚动无卡顿感;
体验评分工具
小程序提供了 体验评分工具(`Audits` 面板)[3] 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。
以上截图均来自小程序官方文档
体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。
小程序后台性能分析
我们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据往往需要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台 和 小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能、运行性能 和 网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。
其中,启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时
第三方测速系统
很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoad
、onReady
、数据加载完成、首屏渲染完成、各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。
另外,微信为开发者提供了 测速系统[4],也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的可以尝试。
了解小程序底层架构—
为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 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 中的 import
和 include
:
template name='A'>
text>{{text}}text>
template>
import src="A.wxml"/>
template is="A" data="{{text: 'B'}}"/>
text> A text>
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[5] 的原理就是借助 Babel
把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。
而 CSS 的 Tree-Shaking 可以利用 PurifyCSS[6] 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”,这里就不铺开细讲了。
题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的可以看看这篇分享:小程序工程化探索。
减少代码包中的静态资源文件
小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPG
、PNG
等格式文件,本身已经被压缩过了,再使用 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: