小程序按需注入:优化启动性能,提升用户体验

566 阅读6分钟

小程序的架构

双线程模型,分为逻辑层和视图层

  • 逻辑层:逻辑层将数据进行处理后发送给视图层,同时接受视图层的事件反馈。
  • 视图层:将逻辑层的数据反映成视图,同时将视图层的事件发送给逻辑层。

framework01.fc0c8f0d.jpg

为什么需要按需注入

小程序是由逻辑层和视图层来组成的,每个页面和自定义组件都会有对应的逻辑层代码,小程序已经发展了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 的自定义组件,这些文件也是会一起注入运行的。

这里也比较明显可以看到 逻辑层和视图层会分别执行那些模块的代码。

文件处理1.png

拆分编译产物:

由下图可以看到文件被拆分成多个,简单介绍下每个文件的作用:

  • common.app.js:模版渲染的公共数据和方法。
  • webview.app.js:fts 和 template 的定义。
  • appservice.app.js:App 的注册、babel、其他 js 文件。
  • foo.webview.js:组件模版的内容。
  • foo.appservice.js:组件的 js 逻辑。

文件处理2.png

加载流程

当接收到 sdk 启动小程序的事件时,会根据启动参数的 path 递归收集所有用到的组件,然后调用 loadJsFile 的方法将收集到当前页面所有的组件的 path 发给 sdk,让 sdk 同步加载所需要的文件。

加载流程.png

数据统计

这里是小程序启动时文件加载的数据统计。

小程序说明:94 页面,包含主包在内有 5 个分包。

安卓性能统计.png

IOS性能统计.png

这里可以看到,在开启了按需注入后,启动的时间是有明显减少的。

用时注入

用时注入就是只有当组件需要被渲染才去加载注入。

如果小程序开启了按需注入,且对某个组件设置了占位组件,那么就视为这个组件开启了用时注入。

比如:

<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]          // 结束标识位
]

上面的数据是在页面初始化的时候 逻辑层 发送给 视图层 截取的部分数据,这样做有几个好处:

  1. 通过 action 的第一个 Number 枚举值可以表示当前 action 是做什么事情,简化了之前用各种 String 标记的事件。
    1. 比如之前的 bridge.publish('createPage','index/index','a8d5bd9d',1)
    2. 现在只需要发送 bridge.publish('operationFlow', '[[5,1,1685674862069],[10,"index/index","a8d5bd9d",{}],[12,1],[13,"0a9ff93f"],[5,0]]')
  2. 通过二维数组表示队列,当 视图层 接收到上面数据的时候,就可以按顺序执行对应的方法,同时也会校验顺序是否是期望的。
  3. 可以减少通信的次数和数据量,在上面的例子中,如果是独立发送事件至少需要发送 3 次,现在只需要发送 1 次。

使用场景

按需注入的使用场景也有一些建议使用的地方:

  1. 页面首屏非核心元素,比如:活动的 banner、弹窗等。
  2. 页面首屏不会显示的元素,比如:页面类似于商城的功能页,有 banner、二级页面入口、各分类入口,最下面是商品瀑布流,如果上方元素过多,用户第一次进来其实看不到下面的商品瀑布流,那么瀑布流的组件也可以使用按需注入。
  3. 因为按需注入并不是金手指,使用按需注入会提升首屏渲染的性能,但也会导致页面元素不是统一渲染,会有分段加载的情况,可能会影响用户体验,所以具体在什么场景使用还是建议根据自己的业务场景使用。

渲染引擎

抽离操作元素的接口,达到可以适配实现了相同接口底层不同的渲染引擎,目前我们正在做用 flutter 来做底层渲染。