背景
货拉拉搬家小程序是一款搬家服务应用,为用户提供方便、安全和可靠的搬家服务。
小程序官方有约束:主包大小不允许超过 2M。而我们搬家业务较为复杂,一些功能往往需要借助第三方库来实现,下方表格列举了项目中用到的部分第三方库,可以看到体积都不小:
NPM 包名 | 用途 | 体积 |
---|---|---|
tim-wx-sdk | 腾讯云 IM SDK,基于 WebSocket 实现,提供在线聊天能力 | 接近 500KB |
MQTT.js | 基于 MQTT 协议实现,在我们业务中主要用于服务端推送实时消息给客户端 | 186KB |
lottie-miniprogram | 能够使用 json 动效文件方便的实现动画效果,相较于 gif 拓展性更强且体积相对更小 | 200KB |
如果是在首页(主包)中如果引入了大体积的第三方库,就会让本就不富裕的包体积雪上加霜。当主包的体积超出了 2M 限制,此时的小程序将无法上传:
综上,这次要做的事情就是:在不占用主包体积的前提下,主包能够使用这些第三方库。
微信小程序提供了分包异步化能力,就是为了这个场景准备的。
本文接下来以 MQTT.js 这个第三方库为例,来实现分包异步化引入,其他的第三方库也是同理。
开始
按照官方文档的姿势,使用require
关键字就能让主包异步引入分包中的代码。
先注册一个名为external-library-mqtt
的分包,并在对应文件夹内放置一个空的index.vue
和MQTT.js
的源码文件:
subPackages: [
+ {
+ root: 'pages/external-library-mqtt',
+ pages: [
+ {
+ path: 'index',
+ },
+ ],
+ },
]
接下来对着官方文档抄,修改主包内 MQTT 的引入方式:
// src/plugins/mqtt/index.js
- import mqtt from './mqtt.min.js'
+ let mqtt
+ require('../pages/external-library-mqtt/mqtt.min.js', res => {
+ // 这里打印一下 mqtt.min.js 的源码
+ console.log('require mqtt.min.js:\n', res)
+ mqtt = res
+ }, ({mod, errMsg}) => {
+ console.error(`path: ${mod}, ${errMsg}`)
+ })
好了,先编译看看效果再说……
出师未捷身先死,编译报错了。因为我们使用的是第三方开发框架(uni-app),而不是微信小程序的原生语法,第三方框架大都通过 Webpack 编译,静态产物不支持CommonJS
模块,所以require
关键字无法编译通过。
继续看官方文档,稍微往下翻翻,还有一招:
通过微信小程序提供的requirePlugin
方法,主包也可以异步引入插件中的代码,那么把 MQTT.js 放入插件内,一样能够满足需求。
在放手去干之前,我们先来探讨一个话题,在本文的场景下,第三方库是应该放在分包中?还是放在插件中?两者之间有什么区别?
分包 VS 插件
先来对比一下两者的差异
分包 | 插件 | |
---|---|---|
单独提审+发版 | 不需要 | 需要 |
体积限制 | 单个分包体积不能超过 2M,小程序总体积不能超过 20M | 单个插件体积不能超过 2M |
发布限制 | 无 | 一个小程序账号(AppID)只能发布一个插件 |
可以看出插件有不少限制条件;而分包本身就寄托在当前小程序内, 相比起来更加自由。
-
随着业务发展,公司内各业务线的小程序也逐渐诞生出自己的业务插件,去提供给其他业务线接入。比如:为了获得更好的微信分享能力,出现了一些营销活动类型的插件。
-
这些业务插件本身就已具备一定的体积,若再把大体积的第三方库也一并放入插件中,已然破坏了插件的单一职责。
-
如果因此导致插件体积超限,难道那时要再另外申请一个小程序账号,用来发布新的插件?很明显,这不是个好选择。
基于上述的分析,本文所探讨的「第三方库异步引入」如果能闭环在自身小程序内,那是最好不过。所以还是继续探索分包异步化的实现吧。
卷土重来
回到之前编译失败的问题中来,提炼一下目标:编译时跳过require
关键字的限制,让它存在于编译产物中。
替换编译产物
uni-app 是通过 Webpack 打包的,而 webpack 也提供了编译流程中的各种 hooks,可以利用其中的emit
去替换编译产物中的内容。
还是之前的方法,将原先的require
关键字替换为customRequire
(名称随意即可)。
// src/plugins/mqtt/index.js
let mqtt
- require('../pages/external-library-mqtt/mqtt.min.js', res => {
+ customRequire && customRequire('../pages/external-library-mqtt/mqtt.min.js', res => {
console.log('require mqtt.min.js:\n', res)
mqtt = res
}, ({mod, errMsg}) => {
console.error(`path: ${mod}, ${errMsg}`)
})
上述文件是在主包中引入的,经过编译后会被打包入dist/common/vendor.js
中。
接着我们新建一个 Webpack plugin,用来把编译产物中的customRequire
还原成require
:
// 自定义 webpack plugin
export class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
let content = compilation.assets['common/vendor.js'].source();
content = content.replace(/customRequire/g, 'require');
compilation.assets['common/vendor.js'] = {
source() {
return content;
},
size() {
return content.length;
},
};
});
}
}
复制文件
我们回忆一下分包文件夹,此时的内容mqtt.min.js
并没有被分包中任何文件引用到,所以它不会打包进编译产物中,这里利用copy-webpack-plugin
将它复制进dist
文件夹对应的分包目录下:
// vue.config.js
+ const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
// ...
configureWebpack: {
plugins: [
new MyPlugin(),
+ new CopyWebpackPlugin([
+ {
+ from: path.join(__dirname, 'src/pages/external-library-mqtt/mqtt.min.js'),
+ to: path.join(__dirname, 'dist', process.env.NODE_ENV === 'production' ? 'build' : 'dev', process.env.UNI_PLATFORM, 'pages/external-library-mqtt'),
+ }
+ ]),
],
},
}
这个时候再次编译一下代码,控制台打印出来MQTT.js
的源码了,我们已成功引入了它:
再结合下小程序编译文件的前后对比,我们可以看到MQTT.js
即分包 external-library-mqtt
已经从主包中脱离出来,主包体积也相应的减少了 186 KB,说明分包异步化此时已经成功。
大功告……不要大意,好像少了点什么东西。
缓存队列
之前代码中 MQTT 是使用import
同步引入,调整之后变为了异步引入,如果在引入完成前,就先调用了 MQTT 提供的方法,那代码就要报错了。设计一个缓存队列来解决它:
// src/plugins/mqtt/index.js
+ const queue = []
+ const mqttReady = () => {
+ const run = () => {
+ const fn = queue.shift()
+ if (typeof fn !== 'function') {
+ return
+ }
+ fn()
+ run()
+ }
+ run()
+ }
customRequire && customRequire('../pages/external-library-mqtt/mqtt.min.js', res => {
// ...
+ mqttReady()
}, ...)
+ const beforehook = (fn) => {
+ return (...args) => {
+ if (!mqtt) {
+ queue.push(() => {
+ fn(...args)
+ })
+ } else {
+ fn(...args)
+ }
+ }
+ }
- const login = () => {...}
+ const login = beforehook(() => {...})
至此,分包异步引入完成。
更简单的实现方式
就像“高端的食材往往只需最朴素的烹饪方式”一样,代码功能往往也总会有更为简单粗暴的实现,Webpack 文档中有这么一个好东西:
可以依靠它,来实现我们的目标。
// src/plugins/mqtt/index.js
- customRequire && customRequire('../pages/external-library-mqtt/mqtt.min.js', res => {
+ __non_webpack_require__ && __non_webpack_require__('../pages/external-library-mqtt/mqtt.min.js', res => {
// ...
}, ({mod, errMsg}) => {
// ...
})
...
编译后,查看dist/common/vendor.js
文件,Webpack 将 __non_webpack_require__
编译成了require
,省去了我们自己写 Webpack plugin 的步骤。
异常考虑
失败率
项目中的代码逻辑:小程序仅在冷启动(onLaunch)时才会去异步引入分包内的 MQTT.js
在神策指标面板中,以小程序冷启动的次数作为分母,require
的失败回调次数作为分子,可以看到分包异步加载的失败率在 0.02 ~ 0.03% 之间,趋势比较稳定。
虽说失败率已经很低了,但作为技术优化来说,追求极致的路还没有走到头,我们可以引入重试机制,让失败率进一步降低。
重试机制
我们来做一套重试机制:当分包异步引入失败时,延时一定时间后再次引入。
顺便再将代码整合一下:
// src/plugins/mqtt/index.js
- __non_webpack_require__ && __non_webpack_require__('../pages/external-library-mqtt/mqtt.min.js', res => {
- console.log('require mqtt.min.js:\n', res)
- mqtt = res
- }, ({mod, errMsg}) => {
- console.error(`path: ${mod}, ${errMsg}`)
- })
/**
* MQTT.js 异步引入
* @param {number} retry 重试次数
* @return {Promise}
*/
+ const asyncRequire = (retry) => {
+ let _retry = retry
+
+ const loadError = async(error, resolve) => { ... }
+
+ const loadResource = (resolve) => {
+ __non_webpack_require__.async('../pages/external-library-mqtt/mqtt.min.js')
+ .then(...).catch(...)
+ }
+
+ return new Promise((resolve) => {
+ loadResource(resolve)
+ })
+ }
+
+ const { error, res } = await asyncRequire(3)
+ if (res) mqtt = res
加入了重试机制后,分包异步的失败率降至 0.003%。
项目收益
原先项目中因为腾讯云 IM SDK 体积过大(接近 500KB),所以不得不将其从主包移动至分包内,白白增加了分包体积。
有了分包异步化能力之后,我们针对此进行了改造,将腾讯云 IM SDK 从分包中移除,在主包异步引入:
至此,在不影响主包体积的情况下,将庞大的第三方库从分包中移除后,也能保证功能的正常使用。优化后的代码在发布后,通过前后对比,分包的下载耗时也得到了提升:
分包名称 | 优化前平均耗时 | 优化后平均耗时 | 耗时缩短(百分比) |
---|---|---|---|
/pages/message/ (消息中心页) | 750ms | 620ms | 17% |
/pages/orders/ (订单详情页) | 800ms | 670ms | 16% |
详见下图(出自:小程序开发者后台-统计-性能数据面板):
总结
本文结合实际业务场景,针对小程序开发中“主包体积不够用”这一大痛点,以“分包异步引入”为话题进行了探索。
在第三方开发框架(uni-app/Taro 等)官方仅能支持插件异步引入的情况下,实现了另一套可行、稳定且更佳的方案,增强了小程序拓展性的同时,也提升了分包页面的载入性能,带来额外项目收益。
通过项目中埋点数据反馈,加入了后续的重试机制,这套分包异步化方案的失败率可达到万分之 0.3,能放心使用。