数据预拉取的原理其实很简单,就是在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,而且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData
从本地缓存获取数据便可。这种作法能够充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。css
京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提高效果很是明显,但这个能力依然存在一些不成熟的地方:html
因为预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会从新发起请求。通过真机实测,在微信购物入口冷启动京喜小程序的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来讲是很是致命的。前端
因为请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,因此本地代理没法拦截到这一次真实请求,这会致使开发者没法经过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。git
小程序内部接口的响应体类型都是 application/octet-stream
,即数据格式未知,使本地代理没法正确解析。github
若是这几个问题点都不会影响到你的场景,那么能够尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提高。web
为了尽快获取到服务端数据,比较常见的作法是在页面 onLoad
钩子被触发时发起网络请求,但其实这并非最快的方式。从发起页面跳转,到下一个页面 onLoad
的过程当中,小程序须要完成一些环境初始化及页面实例化的工做,耗时大概为 300 ~ 400 毫秒。算法
实际上,咱们能够在发起跳转前(如 wx.navigateTo
调用前),提早请求下一个页面的主接口并存储在全局 Promise
对象中,待下个页面加载完成后从 Promise
对象中读取数据便可。npm
这也是双线程模型所带来的优点之一,不一样于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。小程序
若是开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。微信小程序
幸亏,小程序提供了 分包预下载 能力,开发者能够配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。
这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。
关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程当中必须发生的事件。
以京喜小程序如此庞大的小程序项目为例,每一个模块背后均可能有着海量的后台服务做支撑,而这些后台服务间的通讯和数据交互都会存在必定的时延。咱们根据京喜首页的页面结构,把全部模块划分红两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。
在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另外一个接口获取,经过拆分的手段来下降主接口的调用时延,同时减小响应体的数据量,缩减网络传输时间。
这也是关键渲染路径优化思路之一,经过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。
相似上一条措施,继续以京喜小程序首页为例,咱们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及如下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在全部首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。
为了更好地呈现效果,上面 gif 作了降速处理
在小程序中,发起网络请求是经过 wx.request 这个 API。咱们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有相似的限制,但区别在于不是针对域名限制,而是针对 API 调用:
wx.request
(HTTP 链接)的最大并发限制是 10 个;wx.connectSocket
(WebSocket 链接)的最大并发限制是 5 个;超出并发限制数目的 HTTP 请求将会被阻塞,须要在队列中等待前面的请求完成,从而必定程度上增长了请求时延。所以,对于职责相似的网络请求,最好采用节流的方式,先在必定时间间隔内收集数据,再合并到一个请求体中发送给服务端。
图片资源一直是移动端系统中抢占大流量的部分,尤为是对于电商系统。优化图片资源的加载能够有效地加快页面响应时间,提高首屏渲染速度。
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
;
这二者都是比较老生常谈的图片优化技术,这里就不打算细讲了。
小程序的 image 组件 自带 lazy-load
懒加载支持。雪碧图技术(CSS Sprite)能够参考 w3schools 的教程。
在不得不使用大图资源的场景下,咱们能够适当使用 “体验换速度” 的措施来提高渲染性能。
小程序会把已加载的静态资源缓存在本地,当短期内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。所以,对于大图资源,咱们能够先呈现高度压缩的模糊图片,同时利用一个隐藏的 <image>
节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提高效果相比,这点体验落差是能够接受的。
下面为你们提供部分例程:
<!-- banner.wxml --> <image src="{{url}}" /> <!-- 图片加载器 --> <image 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 的网络资源请求。
为了更好地呈现效果,上面 gif 作了降速处理
一方面,咱们能够从下降网络请求时延、减小关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另外一方面,咱们也须要从用户感知的角度优化加载体验。
“白屏” 的加载体验对于首次访问的用户来讲是难以接受的,咱们可使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。
骨架屏目前在业界被普遍应用,京喜首页选择使用灰色豆腐块做为骨架屏的主元素,大体勾勒出各模块主体内容的样式布局。因为微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,所以京喜首页的骨架屏是经过 WXSS 样式静态渲染的。
有趣的是,京喜首页的骨架屏方案经历了 “统一管理” 和 “(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:
<!-- index.wxml --> <skeleton wx:if="{{isLoading}}"></skeleton> <block wx:else> 页面主体 </block> 复制代码
但这种作法的维护成本比较高,每次页面主体模块更新迭代,都须要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此以外,感官上从骨架屏到真实模块的切换是跳跃式的,这是由于骨架屏组件和页面主体节点之间的关系是总体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展现)主体内容。
为了使用户感知体验更加丝滑,咱们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就能够轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此以外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass
模块集中管理,业务组件只须要在适当的节点挂上 skeleton
和 skeleton__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 线程环境,因此小程序的渲染损耗主要在后二者 数据通讯 和 节点树建立/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:
setData
调用尽量地把屡次 setData
调用合并成一次。
咱们除了要从编码规范上践行这个原则,还能够经过一些技术手段下降 setData
的调用频次。譬如,把同一个时间片(事件循环)内的 setData
调用合并在一块儿,Taro 框架就使用了这个优化手段。
在 Taro 框架下,调用 setState
时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一块儿,经过 setData
传递给原生小程序。
// 小程序里的时间片 API const nextTick = wx.nextTick ? wx.nextTick : setTimeout; 复制代码
data
中不可贵出,setData
传输的数据量越多,线程间通讯的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大致上呈正相关关系:
上图来自小程序官方开发指南
因此,与视图层渲染无关的数据尽可能不要放在 data
中,能够放在页面(组件)类的其余字段下。
每当调用 setData
更新数据时,会引发视图层的从新渲染,小程序会结合新的 data
数据和 WXML 片断构建出新的节点树,并与当前节点树进行比较得出最终须要更新的节点(属性)。
即便小程序在底层框架层面已经对节点树更新进行了 diff,但咱们依旧能够优化此次 diff 的性能。譬如,在调用 setData
时,提早确保传递的全部新数据都是有变化的,也就是针对 data 提早作一次 diff。
Taro 框架内部作了这一层优化。在每次调用原生小程序的 setData
以前,Taro 会把最新的 state 和当前页面实例的 data 作一次 diff,筛选出有必要更新的数据再执行 setData
。
附 Taro 框架的 数据 diff 规则
当用户事件(如 Click
、Touch
事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通讯的过程。但,若是没有在逻辑层中绑定事件的回调函数,通讯将不会被触发。
因此,尽可能减小没必要要的事件绑定,尤为是像 onPageScroll
这种会被频繁触发的用户事件,会使通讯过程频繁发生。
组件节点支持附加自定义数据 dataset
(见下面例子),当用户事件被触发时,视图层会把事件 target
和 dataset
数据传输给逻辑层。那么,当自定义数据量越大,事件通讯的耗时就会越长,因此应该避免在自定义数据中设置太多数据。
<!-- 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
组件中的变量 a
、b
经过组件属性传递给 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 的时间。因此,若是组件量级比较大,整个流程下来时间损耗仍是不小的,咱们依旧能够尝试事件总线的方案。
咱们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在相似的诉求。
京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。咱们不可能针对多种顺序可能性提供多套实现,这就须要用到小程序的自定义模板 <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 线程。以致于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setInterval
、setTimeout
定时器:
// Page A Page({ onLoad() { let i = 0 setInterval(() => { i++ }, 100) } }) 复制代码
即便如小程序的
<swiper>
组件,在页面进入后台态时依然是会持续轮播的。
正确的作法是,在页面 onHide
的时候手动把定时器清理掉,有必要时再在 onShow
阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData
大量数据,这就很是难受了...
咱们常常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都须要咱们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型以后不难发现,页面滚动时 onPageScroll
被频发触发,会使逻辑层和视图层发生持续通讯,若这时候再 “火上浇油” 调用 setData
传输大量数据,会致使内存使用率快速上升,使页面卡顿甚至 “假死”。因此,针对频发事件的监听,咱们最好遵循如下原则:
onPageScroll
事件回调使用节流;setData
,或减少 setData
的数据量;据 小程序官方文档 描述,大图片和长列表图片在 iOS 中会引发 WKWebView 的回收,致使小程序 Crash。
对于大图片资源(譬如满屏的 gif 图)来讲,咱们只能尽量对图片进行降质或裁剪,固然不使用是最好的。
对于长列表,譬如瀑布流,这里提供一种思路:咱们能够利用 IntersectionObserver 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据从新加载该组件。
然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。咱们能够适当地调整销毁阈值,或者优化骨架图的样式来尽量提高体验感。
小程序官方提供了一个 长列表组件,能够经过 npm
包的方式引入,有兴趣的能够尝试。