小程序的架构
双线程模型,分为逻辑层和视图层
- 逻辑层:逻辑层将数据进行处理后发送给视图层,同时接受视图层的事件反馈。
- 视图层:将逻辑层的数据反映成视图,同时将视图层的事件发送给逻辑层。
为什么需要按需注入
小程序是由逻辑层和视图层来组成的,每个页面和自定义组件都会有对应的逻辑层代码,小程序已经发展了6-7年了,当小程序越来越大,页面和组件也越来越复杂,会导致代码包整体会越来越大,那么我们就面临一个问题,如何优化启动性能,提升用户体验。
通常情况下小程序启动时,启动页面依赖的所有代码包(主包、分包、插件包、扩展库等)的所有 JS 代码会全部合并注入,包括其他未访问的页面以及未用到自定义组件,同时所有页面和自定义组件的 JS 代码会被立刻执行。这造成很多没有使用的代码在小程序运行环境中注入执行,影响注入耗时和内存占用。
想要优化小程序启动耗时,提升FCP(First Contentful Paint)目前主要有两种优化方式:
- 分包:可以将一些使用频率低的页面放到分包,减少代码包的大小,减少下载解压代码包的耗时。
- 按需注入:开启按需注入特性避免不必要的代码注入和执行,以降低小程序的启动时间和运行时内存。
小程序 js 文件的加载
比如现在有个自定义组件 comp-a:
// comp-a/index.js
const mixin = require('./mixin')
// 1. 加载文件就会执行
const list = []
for(let i = 0;i<1000;i++){
list.push(i)
}
Component({
data:{
list:[]
},
behaviors:[mixin], // 2. 注册组件会进行合并操作
attached(){
// 3. 创建组件才会执行
this.setData({
list:[0]
})
}
})
如果用户没有使用 comp-a 这个组件,那么 1、2 的执行耗时就是被浪费的。
如何实现
方案一:
- 编译:在普通小程序的基础上移除 require 页面 js。
- 加载:加载时调用 require 去加载对应 js 文件。
- 优点:改动量较小,无需 sdk 的配合。
- 缺点:只减少了执行的耗时,对于大文件的加载没有优化。
方案二:
- 编译:拆分成多个文件。
- 加载:加载文件时发事件通知 sdk 加载对应文件。
- 优点:对于整理性能优化明显。
- 缺点:工作量大。
编译
举个例子:
可以看到编译时是把所有 js 相关的文件都打到了 APP.js 的文件里,所有和模版相关的文件都打到了 template.js 里。在启动小程序时就会直接注入运行这两个文件,那如果启动页是 page-a,但没有用到 comp-a 的自定义组件,这些文件也是会一起注入运行的。
这里也比较明显可以看到 逻辑层和视图层会分别执行那些模块的代码。
拆分编译产物:
由下图可以看到文件被拆分成多个,简单介绍下每个文件的作用:
- common.app.js:模版渲染的公共数据和方法。
- webview.app.js:fts 和 template 的定义。
- appservice.app.js:App 的注册、babel、其他 js 文件。
- foo.webview.js:组件模版的内容。
- foo.appservice.js:组件的 js 逻辑。
加载流程
当接收到 sdk 启动小程序的事件时,会根据启动参数的 path 递归收集所有用到的组件,然后调用 loadJsFile 的方法将收集到当前页面所有的组件的 path 发给 sdk,让 sdk 同步加载所需要的文件。
数据统计
这里是小程序启动时文件加载的数据统计。
小程序说明:94 页面,包含主包在内有 5 个分包。
这里可以看到,在开启了按需注入后,启动的时间是有明显减少的。
用时注入
用时注入就是只有当组件需要被渲染才去加载注入。
如果小程序开启了按需注入,且对某个组件设置了占位组件,那么就视为这个组件开启了用时注入。
比如:
<comp-a id="comp" ft:if="{{flag}}">this is placeholder</comp-a>
<view>view in comp-a</view>
{
"usingComponents": {
"comp-a":"/comp-a/index"
},
"componentPlaceholder": {
"comp-a": "view"
}
}
默认 flag 是 false,那么 comp-a 就不需要渲染,在首次进入这个页面的时候就不会去加载 comp-a 相关的文件。
当修改 flag 为 true 的时候,会创建 comp-a,这时候 comp-a 相关文件还没有加载,先用定义的占位节点 view 创建,同时异步调用 loadJsFile 加载 comp-a 相关组件的文件。
当 comp-a 的相关文件加载注册完时,再将占位节点的 view 替换成 comp-a 的实现,这样在首屏加载时还会减少这部分的耗时。
operationFlow
operationFlow 的方式是将与页面渲染相关的事件和需要 逻辑层 和 视图层 同步的事件通过二维数组的形式(actions)发送,数组里面每个都是通过数组表示的 action。
由于完整的按需注入流程用到的 actions 比较复杂,这里简单举个例子介绍下 operationFlow。
比如:
[
[5,1,1685674862069], // 开始标识位
[10, 'index/index', 'a8d5bd9d', {}], // 创建页面
[12, 1], // 组件 ready 回调
[13, '0a9ff93f'], // 创建组件
[5,0] // 结束标识位
]
上面的数据是在页面初始化的时候 逻辑层 发送给 视图层 截取的部分数据,这样做有几个好处:
- 通过 action 的第一个 Number 枚举值可以表示当前 action 是做什么事情,简化了之前用各种 String 标记的事件。
- 比如之前的
bridge.publish('createPage','index/index','a8d5bd9d',1)。 - 现在只需要发送
bridge.publish('operationFlow', '[[5,1,1685674862069],[10,"index/index","a8d5bd9d",{}],[12,1],[13,"0a9ff93f"],[5,0]]')。
- 比如之前的
- 通过二维数组表示队列,当 视图层 接收到上面数据的时候,就可以按顺序执行对应的方法,同时也会校验顺序是否是期望的。
- 可以减少通信的次数和数据量,在上面的例子中,如果是独立发送事件至少需要发送 3 次,现在只需要发送 1 次。
使用场景
按需注入的使用场景也有一些建议使用的地方:
- 页面首屏非核心元素,比如:活动的 banner、弹窗等。
- 页面首屏不会显示的元素,比如:页面类似于商城的功能页,有 banner、二级页面入口、各分类入口,最下面是商品瀑布流,如果上方元素过多,用户第一次进来其实看不到下面的商品瀑布流,那么瀑布流的组件也可以使用按需注入。
- 因为按需注入并不是金手指,使用按需注入会提升首屏渲染的性能,但也会导致页面元素不是统一渲染,会有分段加载的情况,可能会影响用户体验,所以具体在什么场景使用还是建议根据自己的业务场景使用。
渲染引擎
抽离操作元素的接口,达到可以适配实现了相同接口底层不同的渲染引擎,目前我们正在做用 flutter 来做底层渲染。