分包异步化 - 小程序代码体积最佳实践

678 阅读7分钟

背景

随着青藤之恋业务日趋复杂,小程序包体积上限变成了限制业务发展的一道红线。为了能够顺利将代码上传到微信开放平台,开发时常需要和业务 battle,“这个实验能不能全量了?”、“要不让客户端先做实验,等得出结论我们再做?”、“首页的弹窗要不改成页面?”

有些时候可以因为暂时的困难让业务做一定的取舍,但是作为一名好的开发应该尝试更多的方法让困难被解决而不是解决需求。

解决方案

常规操作

当我们一开始遇到体积问题的时候,最先想到的解决办法无非几种:

  • 非必要静态资源上云。 因为一些历史原因,小程序内有上百张本地图片,这部分优化确实在早期帮我们快速解决了燃眉之急(当然过程也有些痛苦,因为影响范围需要谨慎评估)
  • 能进子包的代码都进子包。 子包是小程序很早就给出的解决方案,从最早的至多 4 个子包的限制,再到现在的无限制。要想将主包代码移入子包痛苦程度比方法 1 要更加痛苦,因为「业务存在变化」,即我们要随时拥抱变化,今天降低的体积,你可能随时都要还回去,同时这些代码变动都需要测试再次回归
  • 代码优化与抽象。 这个方法的核心还是代码抽象与提纯,我们通过更多的高级抽象,让代码的复用性更高,简单的例子,比如业务中存在大量设计标准化的模态框或者 alert/confirm,我们通过 API 封装的形式,在各个场景进行命令式调用,可以一定程度避免冗余的模板写法
  • 三方 sdk 治理。 这部分的源头问题还是来自于老代码的不讲究,比如光 base64 的实现,我们项目中就存在多达 4 种,因此,我们进行了三方 sdk 的梳理与重新评估,最后通过自研、公共模块提取等方式释放了将近 100 kb 的空间

上述常规方案在开发能够消化的前提下,我们几乎做到了极致,但是有些代码就是无法从主包消解掉,在这个过程中,我也掉了不少头发。最后我们的代码体积来到了 1820 kb,终于给业务留出了将近 200 kb 的增长空间。但是居安思危以及业务发展让这 200 kb 的空间显得捉襟见肘,从来没有一次迭代让我们有种地主开仓放粮的痛快。

这件事直到我们发现了分包异步化的银弹发生了转变。俺们也有了意大利炮,我也敢打平安县城了!

分包异步化

分包异步化这个功能其实微信小程序两年多前就有了,但是我们确实也是在最近才关注到了它。

在小程序中,不同的分包对应不同的下载单元;因此,除了非独立分包可以依赖主包外,分包之间不能互相使用自定义组件或进行 require。「分包异步化」特性将允许通过一些配置和新的接口,使部分跨分包的内容可以等待下载后异步使用,从而一定程度上解决这个限制。

组件分包异步化

使用场景

  • 多子包共有组件写到了主包
  • 主包次要组件
  • 第三方库引用

代码示例

{
  "usingComponents": {
    "list": "../../subPackageB/components/full-list",
    "simple-list": "../components/simple-list"
  },
  
  // 子包下载完成之前会用 simple-list 渲染 list
  "componentPlaceholder": {
    "list": "simple-list"
  }
}

渲染流程

基础库尝试渲染一个组件时,会首先递归检查 usingComponents,收集其将使用到的所有组件的信息;在这个过程中,如果某个被使用到的组件不可用,基础库会先检查其是否有对应的占位组件。如果没有,基础库会中断渲染并抛出错误;如果有,则会标记并在后续渲染流程中使用占位组件替换该不可用的组件进行渲染。不可用的组件会在当前渲染流程结束后尝试准备(下载分包或注入代码等);等到准备过程完成后,再尝试渲染该组件(实际上也是在执行这个流程),并替换掉之前渲染的占位组件。

遇到的问题

占位组件的渲染在父组件渲染完成后才会进行渲染替换,因此完成时机是未知的,如果存在需要再父组件 onLoad 等生命周期中调用该组件的场景,此时会获取不到组件实例,需要等待子组件 ready

采用 async component 编写子组件,该方法继承 Component,并在组件 ready 时抛出事件。编写 suspense 组件,监听该事件,使用时需要用 suspense 包裹子组件。

// async component
export function IVYAsyncComponent(options): string {
  if (!options.lifetimes) {
    options.lifetimes = {};
  }
  const originalReady = options.lifetimes.ready;
  options.lifetimes.ready = function (this) {
    this.triggerEvent('ready', null, {
      bubbles: true,
      composed: true,
    });
    originalReady?.();
  };
  return IVYComponent(options);
}

// suspense 
...
methods: {
  getPromiseIns() {
    if (!this.data.__readyPromiseIns) {
      this.data.__readyPromiseIns = new GeneratePromiseCallback();
    }
    return this.data.__readyPromiseIns;
  },
  onAsyncComponentReady() {
    this.getPromiseIns().resolve(null);
  },
  ready() {
    return this.getPromiseIns().getPromise();
  },
},
...

// 使用
<suspense id="suspense">
  // 内容
</suspense>

// 调用
await this.getComponentById('suspense', true).ready();
this.getComponentById('content', true).focus();

上述方法仅能监听组件 ready,对于子包加载失败无法处理,但可以采用以下方法规避:

  • 微信提供了 wx.onLazyLoadError方法监听失败,但存在兼容性问题,仅在 2.24.3 基础库版本及以上支持。
  • 采用 Pormise.race([this.getComponentById('suspense', true).ready(), sleep(5000)])包裹代码,超时则执行兜底逻辑,

可以按需使用上述方法来解决问题,另外占位组件编写需要根据业务的实际场景,对于不同地方的异步组件采用不同的占位组件才能带来几乎无损的体验。

跨分包 JS 代码引用

使用场景

  • 分包之间代码相互依赖,主包依赖子包代码,需要前置逻辑
  • 进入子包某个逻辑需要前置逻辑,而这个逻辑的代码又是子包的逻辑,比如直接进入语音房

代码示例

// 语音房为例
const pkg = await require.async('../../../xxx/xxxx')
await pkg.enterRoom('xxxxxx');
pkg.room.enterRoom(xxxx)

遇到的问题

require.async 方法命名没有过一点脑子

require.async 又是微信创造出来的非标准化 API,在当下模块化发展的今天,没有 IDE 能够做到好的开发体验,并且写法还会让编码习惯变得糟糕,这种使用方式会导致每改一个方法都需要全局搜索是否有引用(编译器无法有效解析出require.async引用的代码),如果方法又是比较普遍的名字,那将会是一场灾难。

为了解决这个问题,我们自己写了个 SWC Plugin 可以自动将代码中的 dyamic import 写法转换成 require.async,这样一来,我们就可以使用动态import导入子包文件(此时编译器可以解析到引用,维护起来更加容易),同时还可以通过 SWC 解析拥有解析、提示、自动补全 alias 等能力。

引用子包文件过早导致主包逻辑还未初始化

对于在主包异步加载子包文件, 并且是较为前置的操作的情况下,被引入的子包中的文件尽量不要导入过多文件,这会导致不可确定的错误。目前实践为在进入小程序中即加载子包 A 中的文件 a,此时微信会去下载该子包,子包下载完成后会立即解析子包中的文件(会执行立即执行的代码)类似浏览器中的 async script 模式,此时并不是所有的主包文件都下载完毕,如果引用的主包文件未加载则会直接报错。

因此,较为安全的实践为,对于这种比较前置的子包尽量保持干净,并且单独成子包。

成果

目前小程序主包代码从 1880k 降低至 1774k ,并且后续无需过多关注主包体积,仅需合理的将可以异步的组件代码异步加载。至此,我们可以说彻底放下了业务开发时候的心理负担(以后和业务砍需求又少了一个理由,哈哈哈哈)。当然,由于我们也是刚刚落地这个方案,最佳实践还在不断探索中,如果有更多的进展,我们也会不断优化跟进。