分包异步化在货拉拉微信小程序中的实践
背景
货拉拉微信小程序经过多年迭代,代码包越来越大,主包大小接近 2M,增加一些大的业务功能,就有可能超过大小限制,无法上传代码。
本文适用于使用第三方框架开发的微信小程序,例如:Uniapp、Taro 等。如果使用微信原生语法开发,可以参考本文思路,以及微信官方文档教程。
本文着重关注优化主包大小,分包还不太可能超过 2M。
理论
微信官方建议性能优化中关于代码体积优化有以下 4 个方向:
1.合理使用分包加载
非主包资源都可以放到分包中,保证主包资源最快时间让用户可以访问,分包的资源可以按照重要性开启分包预下载。
2.避免非必要的全局自定义组件和插件
如题,非必要(组件功能单一、只有 1 个页面引入等)就不要把组件写在主包,拆进分包。多个分包都同时引用的资源,放在分包里则不合适,分包没法直接访问另一个分包的资源,除非两个分包都已经加载过了。这个问题可以使用「分包异步化」解决,这是本文重点,下文再表。
3.控制代码包内的资源文件
图片、字体文件建议尽量都走 CDN,小程序的 WXSS 中图片资源没法访问本地路径,也是建议把图片资源放在 CDN 上,这些就是替换路径的工作,很简单。
4.及时清理无用代码和资源
删代码,还有比这更开心的事情吗。
分析
货拉拉小程序在迭代中有持续优化的动作,页面和组件能分包的基本都拆出去了,剩下都是一些拆不动的。
信息点:
- 图片资源可以迁 CDN,预计可以 -178KB。
vendor.js
大,主要包含全局函数库 JS 以及主包用到的 JS 资源。
由上图简单分析可以得出,需要移除 tim-wx.js
,这是腾讯 IMSDK,主包依赖,需要在主包进行 SDK 初始化登录,无法拆进分包。
解决方案
基于以上分析,2 个关键信息:
- 为使用 IMSDK 提供的登录 API,腾讯 IMSDK 必须在小程序主包初始化。
- IMSDK 大小是 420KB,代码已经是压缩过的,无法 Tree Shaking。
方案 1:分包异步化
在小程序中,不同的分包对应不同的下载单元;因此,除了非独立分包可以依赖主包外,分包之间不能互相使用自定义组件或进行
require
。「分包异步化」特性将允许通过一些配置和新的接口,使部分跨分包的内容可以等待下载后异步使用,从而一定程度上解决这个限制。
如果小程序使用的是原生语法,可以使用「方案 1 」。如使用三方框架构建,例如:Uniapp、Taro 等,暂无法直接使用「方案 1」 ,或者说使用方式太过繁琐,不建议在生产使用。
跟着微信官方文档编写Demo
:
- 首先把
tim-wx-sdk
放进分包里面
// subPackageA/index.js
import TIM from 'tim-wx-sdk';
export default TIM;
2.封装一下 utils,在主包使用
// utils/im.js
let TIM = {};
require
.async('subPackageA/index')
.then((mod) => {
TIM = mod;
})
.catch(({ mod, errMsg }) => {
console.error(`subPackageA path: ${mod}, ${errMsg}`);
});
// 导出该 SDK
export { TIM };
3.在主包使用,因为 require.async
是异步函数,要注意使用 TIM
的时机,必须是异步回调之后才能执行,运行 Demo 的时候可以先延时用来验证。
// app.js
import { TIM } from '@/utils/im';
// 先延时等待 require.async 运行结束
setTimeout(() => {
TIM.login({
userID: 'xxxxx',
userSig: 'userSig',
});
}, 3000);
4.运行,毫无意外,它报错了。
根本原因: module.exports、require
属于 CommonJS
语法,CommonJS
是 Node.js
采用的模块化规范,而浏览器环境下不支持该语法,所以没有这些变量。所以,Uniapp 编译打包的时候会把 require
编译成 __webpack_require__
,而微信小程序运行宿主环境并没有 __webpack_require__
这个方法,所以报错。
而 require
微信小程序环境是支持直接运行的,不需要经过编译,我们只需要让 Uniapp 框架不编译 require.async
这个函数即可,经过编写各种 demo 和 Issues 查询,得出以下结论:
- Uniapp 官方编译脚本还未计划支持该小程序特性。
non_webpack_require
方案也涉及到打包脚本的一些改动,理解起来也很复杂,不适宜在生产环境中大改特改。
三方框架无法直接使用分包异步化,微信原生语法无影响。如一定要在第三方框架中使用「异步化」这个特性,可以使用「方案 2」。
方案 2:分包插件异步化
微信小程序提供插件能力,具体功能可以参考官方文档。
requirePlugin
是微信官方提供给插件之间互相调用的一个方法。
requirePlugin
官方用法示例:
// 使用回调函数风格的调用
requirePlugin(
'live-player-plugin',
(livePlayer) => {
console.log(livePlayer.getPluginVersion());
},
({ mod, errMsg }) => {
console.error(`path: ${mod}, ${errMsg}`);
},
);
// 或者使用 Promise 风格的调用
requirePlugin
.async('live-player-plugin')
.then((livePlayer) => {
console.log(livePlayer.getPluginVersion());
})
.catch(({ mod, errMsg }) => {
console.error(`path: ${mod}, ${errMsg}`);
});
如上,加载插件用的是 requirePlugin
,Webpack
打包不会编译这个方法。
回溯一下「方案一」中遇到的问题是:require
会被 Webpack
打包,编译成 __webpack_require__
,所以造成小程序宿主环境没有 require
分包的能力。
「方案二」的解决思路:微信官方提供的 requirePlugin
,Webpack
不会进行编译,则可以正常访问小程序宿主环境的的 requirePlugin
API,从而达成异步加载异步的目的。
- 既然要插件,先去微信官方注册一个插件,这部分可以搜官方文档,主要代码如下:
// xxx-plugin/index.js
// 插件代码只是加载 SDK,并且导出
import TIM from 'tim-wx-sdk';
module.exports = {
TIM,
};
2.在分包页面中引入插件。
{
"plugins": {
"xxx-plugin": {
"version": "dev-01055b63731de071ffb850464bd5c7b1",
"provider": "xxx-plugin appid"
}
}
}
3.上面的 utils 封装改一下。
// utils/im.js
let TIM = null;
requirePlugin
.async('xxx-plugin')
.then(({ TIM: modTIM }) => {
TIM = modTIM;
})
.catch(({ mod, errMsg }) => {
console.error(`direct-service-plugin path: ${mod}, ${errMsg}`);
});
// 暴露出去
export { TIM };
4.完事,好起来了。
顶层 await
这篇文章写得很清楚了,就不搬了。
「方案 2」确实是能够正常解决加载问题,但是引入了新的问题,上面的写法 requirePlugin.async
是个异步函数,调用 TIM
时机不同获取到的值不一样,使用 utils/im
的时候,还需要判断一下是否存在,还需要等待它加载。
import { TIM } from '@/uitls/im';
// 用的时候
TIM && TIM.login();
那比较好一点的方法是加 Promise
,每个使用的地方等待一下
1.加载插件的封装
// @/utils/async-load.ts
/**
* 加载插件的方法
* @param pluginName
* @returns Promise<any>
*/
export async function loadPluginPackage(pluginName: string): Promise<any> {
try {
// @ts-ignore
const mod = await requirePlugin.async(pluginName);
return mod;
} catch ({ mod, errMsg }) {
console.error(
`loadPluginPackage '${pluginName}' errpr path: ${mod}, ${errMsg}`,
);
return {};
}
}
2.使用的时候
// app.js
;(async init() {
const { TIM } = await loadPluginPackage('xxx-plugin')
TIM.login({
userID: 'xxxxx',
userSig: 'userSig',
})
})()
这样还是有问题,如果有多个地方都是用这个 JS,每个地方都要写一下加载插件的方法,可以在小程序启动的时候做一次加载就可以,后面所有用到的地方都用同一个 Promise 就行。
3.更建议的方式
// utils/im.js
let TIM = {};
import { loadPluginPackage } from '@/utils/async-load';
const TIMSdk = loadPluginPackage('xxx-plugin');
TIMSdk.then((mod) => {
TIM = mod.TIM;
});
// 导出出去
export { TIM, TIMSdk };
TIMSdk,是一个默认执行一次的 Promise,加载过一次之后,后续调用 TIMSdk 拿到的都是同一个结果。
使用方式
// app.js
import { TIM, TIMSdk } from '@/utils/im';
/**
*
* IM初始化
*/
const init = async () => {
// 要用的时候 await 一下即可
await TIMSdk;
const tim = TIM.create({
SDKAppID: config.SDKAppID,
});
};
init();
这样用起来很不方便,而且如果使用的地方如果二次封装,也很麻烦,目前没有很好的方法,顶层 await 是为了解决这个问题的,这个提案目前我们还用不上。
4.完美方式,顶部直接await
,暂时用不上
// app.js
const { TIM } = await loadPluginPackage('xxx-plugin');
基础库兼容性
「分包异步化」这个功能需要小程序基础库 2.11.2 及以上才支持,可以按照官方推荐,设置最低基础库。
如果目前线上基础库设置比这个低,需要拉一下现在线上的基础库分布,看一下提高基础库会影响多少人,如果比例很小,可以强行设置,提升用户体验。
总结
小程序此次优化结果:
主包大小从 1.83M 降到 1.36M,大小减少 25%。
一定要注意基础库的依赖,要用这个方案,必须要把最低基础库限制拉到 2.11.2。
控制小程序代码包大小主要几个手段:
-
静态资源,能走 CDN 的,全部走 CDN。
-
能分包的页面或者组件,全部放到分包里面去,主包只留不能拆分的,提升分包加载速度可以使用开启分包预下载。
-
如果资源一定要在主包引用且大小不可控,那就使用「分包异步化」或者「分包插件异步化」来处理。「分包异步化」和「分包插件异步化」两者的选择建议:
- 如果用的是第三方编译的小程序框架,例如: Uniapp,用不上「分包异步化」,等三方官方支持
- 分包插件异步化和分包异步化写法差不多,坏处是需要发一个微信小程序插件,好处是小程序是跨端编译到其它端也可以走插件这一套逻辑。