性能是前端开发老生常谈的热门话题,而现在租车前端也开始大力投入小程序,小程序性能将成为团队未来重点关注的指标之一。
1. 小程序启动
1.1. 启动流程
1.2. 快速渲染
现在大量前端团队使用小程序开发业务,就是因为小程序能够拥有媲美原生的用户体验。而究其原因,快速渲染机制起到了核心作用。
小程序的快速渲染主要经历以下四个阶段:解析和编译、预加载、页面渲染和绘制与显示。
1.2.1. 解析和编译
当用户打开小程序时,小程序框架首先对小程序的代码进行解析和编译。这一过程包括将小程序的代码转换成可执行的指令,并生成对应的数据结构,如页面树和组件树。解析和编译过程需要消耗一定的时间,但在后续的页面渲染中能够大大提高效率。
1.2.2. 预加载
在解析和编译完成后,小程序框架进行预加载。预加载是指在用户进入具体页面之前,提前加载可能需要使用的资源,如图片、样式文件等。通过预加载,小程序能够在用户切换页面时减少加载时间,提高渲染速度。
1.2.3. 页面渲染
当用户进入具体页面时,小程序框架将页面树和组件树渲染到屏幕上。渲染过程包括计算每个组件的位置和尺寸、应用样式和布局,并生成最终的绘制指令。
1.2.4. 绘制与显示
小程序框架将渲染得到的绘制指令交给底层的图形系统进行绘制。图形系统会将指令转换成图像,并显示在屏幕上。
1.3. 双线程架构
在小程序的快速渲染中,双线程技术起到了关键的作用。众所周知,在Web/H5项目中,Dpm渲染和 JavaScript 执行互相影响。而小程序引入了双线程技术,将渲染和逻辑分离到不同的线程中,从而提高了渲染的速度和效率。
小程序的渲染层和逻辑层分别由2个线程管理:
- 渲染层:界面渲染相关的任务全都在WebView里执行。一个小程序存在多个界面,所以渲染层会存在多个WebView线程。
渲染线程负责页面的渲染和绘制工作,通过解析和编译小程序的代码,构建页面树和组件树,并将其渲染到屏幕上。渲染线程与底层的图形系统紧密配合,利用硬件加速等技术快速绘制页面。通过将渲染任务分离到独立的线程中,渲染线程可以专注于页面的绘制,不受逻辑线程的影响,从而提高了渲染的效率。
- 逻辑层:采用JsCore线程运行JS脚本。
逻辑线程负责处理小程序的逻辑和交互。它执行小程序的 JavaScript 代码,处理用户的输入和事件,并更新页面的状态。逻辑线程与渲染线程通过消息机制进行通信,当逻辑线程有新的指令或数据更新时,会将消息发送给渲染线程,触发页面的更新和重新渲染。通过将逻辑和渲染分离到不同的线程,逻辑线程能够独立执行,不会阻塞页面的渲染,保证了小程序的快速响应和流畅的交互体验。
视图层和逻辑层通过系统层的JsBridge进行通信:逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。
1.3.1. 优点
- 将逻辑层和渲染层隔离开,用户无法直接操作DOM,提供了相对封闭和安全的运行环境。
- JS执行不会阻塞或干扰webView渲染,但是大部分情况下视觉都要依赖JS中处理的数据,JS如果被阻塞(阻塞原因有逻辑重或请求慢等)了就不会通知视图去更新(即执行setData),所以这条优点其实意义不是很大。
- 所有的页面和组件的逻辑(js)都在一个线程(AppService)里,使用同一个上下文环境,比较好做状态共享或跨页面通讯。可以使用global对象试一下,如 global.a = 1; 另一个页面输出一下gobal可以看到a为1;
1.3.2. 缺点
- 缺点在于每一次数据传递都要进行一次线程之间的通信,业务逻辑跟渲染层天然隔离,造成通信开销大、延迟高等问题,通信越频繁、数据量越大,则性能瓶颈越严重。页面大小,打开数量和内存回收都存在限制和一定的不可控性。
- 每个页面都创建一个WebView线程处理,有更多的内存、时间开销。
- 渲染层和逻辑层状态要维护两份,进一步加重内存、时间开销,并且没有办法完全保证两份数据状态实时保持一致,例如仅使用 this.data 更新数据而不是通过setData时,那么实际渲染的值与逻辑层的值就不一致,某些场景下会造成非预期的问题。
1.4. 首屏生命周期
2. 性能指标
2.1.1. 启动耗时
用户点击访问小程序到小程序首页完全加载完成的时间,而非 Page.onReady
事件触发。这样的计算方式会比较接近用户的体感耗时。
启动耗时 = 资源准备核心耗时 + 加载渲染耗时 + 首屏关键内容耗时
启动耗时:从用户点击进入小程序开始计算,到小程序界面首次渲染完毕的耗时。中间包含代码包下载(非首次启动不需要)、代码执行、渲染等耗时。
启动耗时可以理解为:开始时间:点击进入小程序,结束时间:小程序首页渲染完成。
资源准备核心耗时:在小程序冷启动的情况下,点击小程序到小程序信息、代码包、和部分运行环境准备完成的平均耗时。
加载渲染耗时:在小程序冷启动的情况下,资源准备完成到首个页面基于初始数据渲染的页面耗时,不是LCP以及FMP耗时,以onReady事件触发事件截止,未包含页面实际业务逻辑耗时。
首屏关键内容耗时: 小程序首屏上关键内容的加载和渲染时间,主要针对业务逻辑的执行渲染耗时。
2.1.2. 首屏耗时
即页面首次渲染到主体内容可见所需要的时间,对于小程序的用户体验是至关重要的。用户通常会关注页面内容完全显示出来的时间,过长的时间将极度考验用户的耐性,严重影响用户体验,并很大程度上决定了用户的去和留。
“首屏耗时”中的“首屏”,可定义为“页面首次渲染满屏内容”,其中:
- 首次:页面渲染满屏之后,可能会因用户操作或业务需要更新内容,只关注第一次的绘制。
- 渲染:对用户来说一般为图片、文字的展示。从小程序视角来看,主要包括 DOM 渲染、资源加载等。
- 满屏:通俗理解铺满整个屏幕。
- 内容:页面的表现形式,如文字、图片、视频等。
2.1.3. 性能指标(Timings)
- FP (First Paint) : 页面首个像素点开始绘制的时刻,无论是否绘制了文本、图片
- FCP (First Contentful Paint) : 首次绘制文本 / 图片 / 原生组件的时刻,小程序中可以近似理解为加载渲染耗时结束的时间点。
- LCP (Largest Contentful Paint) : 渲染面积最大的文本 / 图片的时刻
- FMP (First Meaningful Paint) :
-
- 支付宝:页面加载结束前最后一次渲染
- H5 : 首次有意义绘制,指页面主要内容已呈现,用户能够开始获取页面的核心信息。
3.1. 资源准备阶段性能
3.1.1. 代码包优化
当小程序启动时,客户端会从 CDN 进行小程序离线包下载,包大小直接影响了下载耗时,而下载耗时是启动耗时重要的影响因素。
3.1.1.1. 分包
使用 分包加载 是包大小优化效果最明显的手段。不过,使用分包加载时可以使用 分包预下载 进一步优化启动耗时。在使用 分包加载 后,虽然能够将小程序包体积缩小,但是当用户在首次进入分包页面时,需要等分包下载完成后才能进入页面,从而造成页面启动的延迟,影响用户的使用。因此,可以在使用 分包加载 时使用 分包预下载 进一步优化启动耗时。
3.1.1.2. 图片优化
- 包内图片文件在打包时,会全部拷贝到最终的打包产物中,因此,这些静态资源文件会占用大量的包体积,从而影响启动耗时。
-
- 图片上传CDN。
- 图片进行合理压缩、剪裁。
- 及时清理没有使用到的图片。
- 图片格式转换 WebP 或者 SVG,WebP 和 SVG 格式的图片能够在不降低图片质量的前提下减小图片的体积。
- 网络图片过大会影响请求耗时,从而影响页面启动耗时,且消耗过多的网络流量,影响资源流耗。
-
- 大图建议从 CDN 渠道上传,且需要控制并发加载数量,并开启 HTTP 缓存,下次加载同样图片,直接从缓存读取。
- 图片并发请求,短时间内发起太多图片请求会加重网络带宽压力,且并发请求太多也会导致图片加载慢。
-
- 使用雪碧图来进行优化。CSS 雪碧图的基本原理是把一些小图标整合到一张单独的图片中,请求的时候只请求这个图片,在需要显示图标的地方,使用 CSS
background
和background-position
属性来进行定位。 - 图片开启 HTTP 缓存控制,下次加载同样图片,直接从缓存读取。
- 未使用到的图片或者屏幕外的图片采用 ****懒加载 ****处理。
- 使用雪碧图来进行优化。CSS 雪碧图的基本原理是把一些小图标整合到一张单独的图片中,请求的时候只请求这个图片,在需要显示图标的地方,使用 CSS
3.1.1.3. 正确使用自定义组件
- 如果无法避免的需要频繁更新某一部分渲染层的ui,可以将该部分声明为一个独立的自定义组件,因为这些组件内部的数据更新是独立的,计算开销更小
- 去掉自定义组件不必要的dataset属性,因为每次时间触发时,这些数据都会被收集传递到逻辑层
- 一个页面内自定义组件的数量也不宜过多,否则将会因为自定义组件的注册开销影响到首屏的渲染速度
3.1.2. 按需注入和用时注入
通常情况下,在小程序启动时,启动页面依赖的所有代码包(主包、分包、插件包、扩展库等)的所有 JS 代码会全部合并注入,包括其他未访问的页面以及未用到自定义组件,同时所有页面和自定义组件的 JS 代码会被立刻执行。这造成很多没有使用的代码在小程序运行环境中注入执行,影响注入耗时和内存占用。
可以通过开启「按需注入」特性避免不必要的代码注入和执行,以降低小程序的启动时间和运行时内存。
{
"lazyCodeLoading": "requiredComponents"
}
注意:启用按需注入后,页面 JSON 配置中定义的所有组件和 app.json 中 usingComponents 配置的全局自定义组件,都会被视为页面的依赖并进行注入和加载。建议开发者及时移除 JSON 中未使用自定义组件的声明,并尽量避免在全局声明使用率低的自定义组件,否则可能会影响按需注入的效果。
在开启「按需注入」特性的前提下,「用时注入」可以指定一部分自定义组件不在小程序启动时注入,而是在真正渲染的时候才进行注入。
在已经指定 lazyCodeLoading 为 requiredComponents 的情况下,为自定义组件配置 占位组件,组件就会自动被视为用时注入组件:
- 每个页面内,第一次渲染该组件前,该组件都不会被注入;
- 每个页面内,第一次渲染该组件时,该组件会被渲染为其对应的占位组件,渲染流程结束后开始注入;
- 注入结束后,占位组件被替换回对应组件。
3.1.3. 数据预拉取
预拉取能够在小程序冷启动的时候通过客户端向第三方服务器拉取业务数据,当页面打开时来获取数据可以更快地渲染页面,减少用户等待时间。
3.1.4. 合理规划发布版本
小程序启动时如果检测到版本更新,会进行以下操作,影响启动耗时
- 重新获取小程序的基础信息
- 进行小程序代码包的增量更新
- 重新生成 JS 代码的 Code Cache
- 重新生成初始渲染缓存
能够快速迭代发布是小程序相对 APP 的一个优势,但是过于频繁的新版本发布可能会导致部分用户每次使用都需要进行小程序的更新,导致平均启动耗时变长。
3.2. 加载渲染阶段&首屏内容渲染阶段性能
3.2.1. 同步 JSAPI 治理
同步的 JSAPI 虽然开发比较方便,但是会有很大的性能损耗。具体表现在同步 JSAPI 的调用过多将造成进程的阻塞,影响后续业务逻辑的执行,造成响应变慢,因此原则即是 能不用就不用,非要用要慎重 。优化经验中发现,getSystemInfoSync
、getStorageSync
、setStorageSync
、getLocation
、getCities
是同步调用的高发区。
- 针对
getSystemInfoSync
、getLocation
这类结果固定的方法,可以把数据保存在store或缓存中,避免重复调用; getStorageSync
可以用getStorage
替换,同步改异步方法。同时可以合并多个字段到一个对象,减少需要的次数。
3.2.2. 请求治理
3.2.2.1. 精简首屏数据
首页渲染的耗时与页面的复杂程度正相关。对于复杂页面,可以选择进行渐进式的渲染,根据页面内容优先级,优先展示页面的关键部分,对于非关键部分或者不可见的部分可以延迟更新。
此外,与视图层渲染无关的数据应尽量不要放在 data 中,避免影响页面渲染时间。
3.2.2.2. 提前首屏请求
由于网络请求需要相对较长的时间,应该在 Page.onLoad 或更早的时机发起网络请求,而不应等待 Page.onReady 之后再进行。
3.2.2.3. 缓存请求结果
数据存储在本地,返回的会比网络请求快。
以某功能为例:两个接口线上平均耗时分别为300+ms、1000+ms,而通过storage获取缓存只需要66.9ms。
而这部分数据在一段时间内是不会更新的,所以这段时间同一功能只需要使用缓存即可。
3.2.2.4. 避免重复请求
3.2.2.5. 页面资源的域名尽量采用相同域名。底层网络可以复用相同的 TCP 连接做资源请求,节省了重复的 DNS > TCP 建连 > HTTPS 握手等繁杂的网络交互工作,这样可以大幅提升资源加载效率。
3.2.2.6. 非关键资源应该做好隔离。避免非关键资源加载过慢,导致整个页面渲染不出来。
3.2.3. 合理使用setData
setData 的过程,大致可以分成几个阶段:
- 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;
- 将 data 从逻辑层传输到视图层;
- 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新。
从刚刚的双线程架构可知,小程序的逻辑层和视图层是两个独立的运行环境、分属不同的线程或进程,数据传输过程是异步的、非实时的。数据传输的耗时与数据量的大小正相关,如果对端线程处于繁忙状态,数据会在消息队列中等待。
3.2.3.1. 合理的data内容或范围
- 减少数据量,避免一次性 setData 传递过长的列表。
- setData 应只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。
- 组件的 setData 只会引起当前组件和子组件的更新,可以降低虚拟 DOM 更新时的计算开销。对于需要频繁更新的页面元素(例如:秒杀倒计时),可以封装为独立的组件,在组件内进行 setData 操作。
3.2.3.2. setData调用频次
页面 setData 调用次数不宜过多,否则会导致 JS 线程一直在执行编译和渲染,视图层和逻辑层数据交互阻塞,可能导致用户的事件操作传递到逻辑层不及时,同时逻辑层的处理结果也不能很快的传递到视图层,从而造成用户滑动时的卡顿和操作反馈延时。
- 需要频繁触发重新渲染时,避免使用页面级别的 setData 和 spliceData 触发组件重新渲染。
- 对连续的 setData 调用尽可能的进行合并
- 避免在 onPageScroll 回调中每次都调用 setData
- 避免不必要的 setData
3.2.4. 渲染性能
3.2.4.1. 适当监听页面或组件的 scroll 事件
反面教材:首页原生化时,在首页做了onScroll和touch相关事件监听,导致首页特别是mPaas端卡顿。
- 部分业务场景会需要监控元素曝光情况,用于进行一些页面状态的变更或上报分析。建议使用节点布局相交状态监听 IntersectionObserver 推断某些节点是否可见、有多大比例可见;
3.2.4.2. 控制axml和wxml数量和层级
过大的节点树会增加内存的使用,样式重排时间也会更长,影响体验
3.2.5. 页面切换
页面切换的性能影响用户操作的连贯性和流畅度,是小程序运行时性能的一个重要组成部分。
3.2.5.1. 避免在 onHide/onUnload 执行耗时操作
页面切换时,会先调用前一个页面的 onHide 或 onUnload 生命周期,然后再进行新页面的创建和渲染。如果 onHide 和 onUnload 执行过久,可能导致页面切换的延迟。若必须要进行部分复杂逻辑,可以考虑用 setTimeout 延迟进行。
3.2.5.2. 接口预请求
页面之间可以通过 EventChannel 进行通信。在页面跳转时,可以同时发起下一个页面的数据请求,而不需要等到页面 onLoad 时再进行,从而可以让用户更早的看到页面内容。尤其是在跳转到分包页面时,从发起页面跳转到页面 onLoad 之间可能有较长的时间间隔,可以加以利用。
3.2.5.3. 使用createWorker进行异步预加载
小程序提供了 createWorker 接口,允许我们在后台创建 Worker 线程,用于执行一些异步任务。通过在 Worker 中进行页面的异步预加载,可以减少主线程的负担,提高整体性能。
/ 创建 Worker
const worker = wx.createWorker('/workers/preload-worker.js');
// 向 Worker 发送预加载指令
worker.postMessage({
targetPage: '/pages/targetPage/targetPage',
preloadResources: ['path/to/image.jpg', 'path/to/stylesheet.css']
});
// 在 Worker 中执行页面预加载逻辑
worker.onMessage((res) => {
console.log('Worker message:', res);
});
4. 性能分析查看
4.1. 微信
- wx.performance:类似我们的网络performance,可以通过api获取网页加载等性能指标的数据。
- 真机性能分析工具:真机性能分析工具可以实现利用开发者工具,通过局域网连接,录制真机上小程序/小游戏的Memory、CPU相关的性能数据,帮助开发者更好地定位性能问题。developers.weixin.qq.com/miniprogram…
4.2. 支付宝
性能分析:支付宝IDE自带的性能分析工具,类似于真机调试,打码扫码后打开小程序,根据用户操作会记录各个页面性能数据。
5. 总结
寻找性能优化的思路,我们可以从这三方面入手:
- 体验:给用户提供良好流畅的体验,例如首屏直出,提前展示页面内容及时响应用户操作;
- 原理:从小程序的运行机制和生命周期出发,利用独特的页面机制,提前请求数据缩短页面时间;理解渲染机制,避免性能较差的编码方法;
- 数据:从接口、数据模块、页面三个层面考虑数据缓存,尽量提高命中率;