小程序的运行流程和一些重要细节,还介绍了进行优化的基本方法。主要的优化策略可以归纳为三点:精简代码,降低WXML结构和JS代码的复杂性;合理使用setData调用,减少setData次数和数据量;必要时使用分包优化。
1 启动
在小程序启动时,微信会为小程序展示一个固定的启动界面,界面内包含小程序的图标、名称和加载提示图标。此时,微信会在背后完成几项工作:下载小程序代码包、加载小程序代码包、初始化小程序首页。
图1 小程序启动流程图
1 代码包下载
在某个小程序第一次启动时,微信需要下载小程序代码包。此后,如果小程序代码包未更新且还被保留在缓存中,则下载小程序代码包的步骤会被跳过。下载到的小程序代码包不是小程序的源代码,而是编译、压缩、打包之后的代码包。代码包大小可以在开发者工具的“详情”栏中找到。
从开发者的角度看,控制代码包大小有助于减少小程序的启动时间。对低于1MB的代码包,其下载时间可以控制在929ms(iOS)、1500ms(Android)内。
以下是一些常规的控制代码包大小的方法。
-
精简代码,去掉不必要的WXML结构和未使用的WXSS定义。
-
减少在代码包中直接嵌入的资源文件。
-
压缩图片,使用适当的图片格式。
如果小程序比较复杂,优化后的代码总量可能仍然比较大,此时可以采用分包加载的方式进行优化。
2 分包加载流程
一般情况下,小程序的代码将打包在一起,在小程序启动时一次性下载完成。采用分包时,小程序的代码包可以被划分为几个:一个是“主包”,包含小程序启动时会马上打开的页面代码和相关资源;其余是“分包”,包含其余的代码和资源。这样,小程序启动时,只需要先将主包下载完成,就可以立刻启动小程序。这样就可以显著降低小程序代码包的下载时间。
图2 分包目录结构示例
代码清单1 使用分包时app.json示例
{
"pages":[
"pages/index",
"pages/logs"
],
"subPackages": [
{
"root": "packageA",
"pages": [
"pages/cat",
"pages/dog"
]
}, {
"root": "packageB",
"pages": [
"pages/apple",
"pages/banana"
]
}
]
}
一个支持分包的小程序目录结构可以组织成上图的形式。代码根目录下有“packageA”和“packageB”两个子目录(它们的名字需要在app.json中声明),这两个子目录就构成了两个分包,每个分包下都可以有自己的页面代码和资源文件。而除掉这两个目录的部分就是小程序的主包。在小程序启动时,“packageA”和“packageB”两个子目录的内容不会马上被下载下来,只有主包的内容才会被下载。利用这个特性就可以显著降低初始启动时的下载时间。
使用分包时需要注意代码和资源文件目录的划分。启动时需要访问的页面及其依赖的资源文件应放在主包中。
3 代码包加载
微信会在小程序启动前为小程序准备好通用的运行环境。这个运行环境包括几个供小程序使用的线程,并在其中完成小程序基础库的初始化,预先执行通用逻辑,尽可能做好小程序的启动准备。这样可以显著减少小程序的启动时间。
小程序的代码包被下载(或从缓存中读取)完成后,小程序的代码会被加载到适当的线程中执行。此时,所有app.js、页面所在的JS文件和所有其他被require的JS文件会被自动执行一次,小程序基础库会完成所有页面的注册。在小程序代码调用Page构造器的时候,小程序基础库会记录页面的基础信息,如初始数据(data)、方法等。需要注意的是,如果一个页面被多次创建,并不会使得这个页面所在的JS文件被执行多次,而仅仅是根据初始数据多生成了一个页面实例(this),在页面JS文件中直接定义的变量,在所有这个页面的实例间是共享的。
例如,若从页面A使用wx.navigateTo跳转到页面B,再使用wx.navigateTo跳转到页面A,此时页面栈中有三个页面:A、B、A。这时两个A页面的实例将共享它的JS文件中Page构造器以外直接定义的变量。有经验的开发者可以利用这个特性,但一些开发者也会错误地共享出一些变量,因而使用时要小心。
代码清单2 小程序代码包加载期间执行的代码示例
console.log('加载 page.js')
var count = 0
Page({
onLoad: function() {
count += 1
console.log('第 ' + count + ' 次启动这个页面')
}
})
如果在page.js中加入这段代码,则在小程序代码包加载阶段就会在控制台中输出一段提示语,并在每次页面被创建后输出这是这个页面第几次被创建。
在小程序代码包加载完毕后,小程序基础库会根据启动路径选择一个页面来启动。这时会根据页面路径和初始数据创建一个新页面。页面创建和运行期间会涉及许多数据通信和页面渲染,接下来会详细介绍。
2 页面层级准备
在视图层内,小程序的每一个页面都独立运行在一个页面层级上。小程序启动时仅有一个页面层级,每次调用wx.navigateTo,都会创建一个新的页面层级;相对地,wx.navigateBack会销毁一个页面层级。
对于每一个新的页面层级,视图层都需要进行一些额外的准备工作。在小程序启动前,微信会提前准备好一个页面层级用于展示小程序的首页。除此以外,每当一个页面层级被用于渲染页面,微信都会提前开始准备一个新的页面层级,使得每次调用wx.navigateTo都能够尽快展示一个新的页面。
页面层级的准备工作分为三个阶段。
- 第一阶段是启动一个WebView,在iOS和Android系统上,操作系统启动WebView都需要一小段时间。
- 第二阶段是在WebView中初始化基础库,此时还会进行一些基础库内部优化,以提升页面渲染性能。
- 第三阶段是注入小程序WXML结构和WXSS样式,使小程序能在接收到页面初始数据之后马上开始渲染页面(这一阶段无法在小程序启动前执行)。
图3 页面层级准备过程图
对于wx.redirectTo,这个调用不会打开一个新的页面层级,而是将当前页面层级重新初始化:重新传入页面的初始数据、路径等,视图层清空当前页面层级的渲染结果然后重新渲染页面。
3 数据通信
在每个小程序页面的生命周期中,存在着若干次页面数据通信。逻辑层向视图层发送页面数据(data和setData的内容),视图层向逻辑层反馈用户事件。
1 页面初始数据通信
在小程序启动或一个新的页面被打开时,页面的初始数据(data)和路径等相关信息会从逻辑层发送给视图层,用于视图层的初始渲染。Native层会将这些数据直接传递给视图层,同时向用户展示一个新的页面层级,视图层在这个页面层级上进行界面绘制。视图层接收到相关数据后,根据页面路径来选择合适的WXML结构,WXML结构与初始数据相结合,得到页面的第一次渲染结果。
图4 初始数据通信时序图
分析这个流程不难得知:页面初始化的时间大致由页面初始数据通信时间和初始渲染时间两部分构成。其中,数据通信的时间指数据从逻辑层开始组织数据到视图层完全接收完毕的时间,数据量小于64KB时总时长可以控制在30ms内。传输时间与数据量大体上呈现正相关关系,传输过大的数据将使这一时间显著增加。因而减少传输数据量是降低数据传输时间的有效方式。
图5 数据传输时间与数据量关系图
2 更新数据通信
初始渲染完毕后,视图层可以在开发者调用setData后执行界面更新。在数据传输时,逻辑层会执行一次JSON.stringify来去除掉setData数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将setData所设置的数据字段与data合并,使开发者可以用this.data读取到变更后的数据。
因此,为了提升数据更新的性能,开发者在执行setData调用时,最好遵循以下原则:
- 不要过于频繁调用setData,应考虑将多次setData合并成一次setData调用;
- 数据通信的性能与数据量正相关,因而如果有一些数据字段不在界面中展示且数据结构比较复杂或包含长字符串,则不应使用setData来设置这些数据;
- 与界面渲染无关的数据最好不要设置在data中,可以考虑设置在page对象的其他字段下。
代码清单3 提升数据更新性能方式的代码示例
Page({
onShow: function() {
// 不要频繁调用setData
this.setData({ a: 1 })
this.setData({ b: 2 })
// 绝大多数时候可优化为
this.setData({ a: 1, b: 2 })
// 不要设置不在界面渲染时使用的数据,并将界面无关的数据放在data外
this.setData({
myData: {
a: '这个字符串在WXML中用到了',
b: '这个字符串未在WXML中用到,而且它很长…………………………'
}
})
// 可以优化为
this.setData({
'myData.a': '这个字符串在WXML中用到了'
})
this._myData = {
b: '这个字符串未在WXML中用到,而且它很长…………………………'
}
}
})
3 用户事件通信
视图层会接受用户事件,如点击事件、触摸事件等。用户事件的通信比较简单:当一个用户事件被触发且有相关的事件监听器需要被触发时,视图层会将信息反馈给逻辑层。如果一个事件没有绑定事件回调函数,则这个事件不会被反馈给逻辑层。视图层中有一套高效的事件处理体系,可以快速完成事件生成、冒泡、捕获等过程。
视图层将事件反馈给逻辑层时,同样需要一个通信过程,通信的方向是从视图层到逻辑层。因为这个通信过程是异步的,会产生一定的延迟,延迟时间同样与传输的数据量正相关,数据量小于64KB时在30ms内。降低延迟时间的方法主要有两个。
- 去掉不必要的事件绑定(WXML中的bind和catch),从而减少通信的数据量和次数;
- 事件绑定时需要传输target和currentTarget的dataset,因而不要在节点的data前缀属性中放置过大的数据。???
图6 事件通信时间与数据量关系图
4 视图层渲染
视图层在接收到初始数据(data)和更新数据(setData数据)时,需要进行视图层渲染。在一个页面的生命周期中,视图层会收到一份初始数据和多份更新数据。收到初始数据时需要执行初始渲染,每次收到更新数据时需要执行重渲染。
1 初始渲染
初始渲染发生在页面刚刚创建时。初始渲染时,将初始数据套用在对应的WXML片段上生成节点树。节点树也就是在开发者工具WXML面板中看到的页面树结构,它包含页面内所有组件节点的名称、属性值和事件回调函数等信息。最后根据节点树包含的各个节点,在界面上依次创建出各个组件。
图7 视图层初始渲染流程图
在这整个流程中,时间开销大体上与节点树中节点的总量成正比例关系。因而减少WXML中节点的数量可以有效降低初始渲染和重渲染的时间开销,提升渲染性能。
代码清单4 简化WXML代码的例子
<view data-my-data="{
{myData}}"> <!-- 这个 view 和下一行的 view 可以合并 -->
<view class="my-class" data-my-data="{
{myData}}" bindtap="onTap">
<text> <!-- 这个 text 通常是没必要的 -->
{
{myText}}
</text>
</view>
</view>
<!-- 可以简化为 -->
<view class="my-class" data-my-data="{
{myData}}" bindtap="onTap">
{
{myText}}
</view>
2 重渲染
初始渲染完毕后,视图层可以多次应用setData的数据。每次应用setData数据时,都会执行重渲染来更新界面。
初始渲染中得到的data和当前节点树会保留下来用于重渲染。每次重渲染时,将data和setData数据套用在WXML片段上,得到一个新节点树。然后将新节点树与当前节点树进行比较,这样可以得到哪些节点的哪些属性需要更新、哪些节点需要添加或移除。最后,将setData数据合并到data中,并用新节点树替换旧节点树,用于下一次重渲染。
图8 视图层重渲染流程图
在进行当前节点树与新节点树的比较时,会着重比较setData数据影响到的节点属性。因而,去掉不必要设置的数据、减少setData的数据量也有助于提升这一个步骤的性能。
5 原生组件通信
一些原生组件支持使用context来更新组件。不同于setData,使用context来更新组件并不会涉及到重渲染过程,数据通信过程也不同。在setData的数据通信流程中,数据从逻辑层经过native层转发,传入视图层的WebView,再经过一系列渲染步骤之后传入组件。而使用context时,数据从逻辑层传到native层后,直接传入组件中,这样可以显著降低传输延迟。
图9 setData与context对比时序图(上setData,下context)
在具体通信过程上,因为context的方法繁多,通信方式相对于setData更复杂。不过基础库会对context方法调用时的通信进行封装优化,通常开发者不需要关心这个问题。