uniapp微信小程序主包超包完全解决方案

3,779 阅读16分钟

众所周知,微信小程序的代码包存在体积的限制,最多不能超过 2MB,随着项目越来越大,经常面临主包超包的问题。

超包的罪魁祸首有以下三个:

  • static 静态资源目录下的图片太多
  • 使用的库文件太多,导致打包后的 vender 太大
  • 全局注册的 components 目录下的组件太多

虽然微信提供了分包的策略,但是对于node_modules下安装的库以及项目内的工具库,全局注册的组件,一旦在主包内使用了,都是打包进主包的。

也即是说当你不加思考的一味安装第三方包到node_modules里或者是直接把包放到项目内,很快就会导致主包超过 2mb 的限制,导致小程序无法上传。

传统的解决方式就是将 static 下的图片资源尽可能的都压缩或者使用在线的图片,微信开发者工具也提示以不要超过 150kb 为佳。

以前我都是使用 TinyPNG 这个图片压缩网站,将 static 目录下的所有图片都挨个压缩下来,费时费力不说,也只能带来有限的压缩空间,最后才勉强能上传上去。

但是这很明显是一个笨方法,治标不治本,因为主包太大的根由是由引入的各种第三方库,也就是 js 资源导致最后打包出来的 vender 庞大。以及大量的公共组件全局引入导致的主包体积太大。而这两者都是无法通过压缩的方式来减小代码体积。

这一次我就碰到了无法通过压缩图片来减少打包后主包体积的情况,即便把核心的组件库内整体重构了下,删减了大量用不到的代码,最后打包仍旧超过了近 100kb,所以我就痛定思痛要找出一个解决方案,让以后开发微信小程序再也不用担心主包超包的问题。

本文我将提出一个一劳永逸的方式,分别解决由组件资源(js、图片)带来的主包超包问题,以此来优化打包后的小程序包体积。

在开始之前,我们先了解下微信小程序的打包策略,看什么情况下,组件和资源会被打到主包,什么情况下会被打到分包。

uniapp 微信小程序的打包策略

组件资源的打包情况分析

首先使用 cli 的方式创建了一个 uni-app 的微信小程序,项目内容完全为代码初始化后的内容,未添加一行代码,打包后看到初始项目大小 98kb,其中 common 的体积在 92kb,也就是说图片资源和页面(html)以及一些配置文件所占的体积只有 6kb,而剩下的 92kb,则是 uniapp 框架代码。

下面在components公共目录下引入一个组件,这里以 uni-app 插件市场的tki-qrcode组件为例,此组件包含了 vue 文件和依赖的 js 文件,两者的大小分别是 4kb,和 41kb。

在主包内使用这个组件,然后打包,进行依赖分析,看到代码包体积来到了 118kb:

也就是说这个组件增加了主包 20kb 的代码大小,分别是

  • commm 的体积从 92->108,也就是组件的 js 部分增加了代码 16kb 的体积,
  • 组件 html 的部分增加了 4kb 的体积(即 tki-qrcode.vue 页面的大小)

由此可以得出结论 :src/components 下全局注册组件的体积打包后会全部算到主包里

这也就是说,随着全局组件的引入增多,项目主包的体积会越来越大。所以对于全局组件的数量一定要进行限制,而不能把所有的组件都放到了components目录下,这样固然在使用的时候很方便,但是却会导致主包体积迅速变大,所以components目录下应该只保留全局用的基础通用组件,而不是包含大量代码的业务组件。

当然如果你没有把组件放在components目录下,而是放在了其他自定义的文件夹下,都是一样打包到主包内的,只不过放在components目录下,uniapp 有 easycom 模式,可以免去了在页面内注册组件,而放在其它文件夹的组件,则需要手动的注册,比较麻烦。

组件放在分包的打包情况

既然知道组件放在主包内最后会打包进主包,那么我们自然就想到了把主包要使用的组件放到分包里,下面我们再看这种情况的打包:

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "usingComponents": {
          "tki-qrcode": "/libs/tki-qrcode/tki-qrcode"
        },
        "componentPlaceholder": {
          "tki-qrcode": "view"
        }
      }
    }
  ],
  "subPackages": [
    {
      "root": "libs",
      "pages": [{ "path": "index" }]
    }
  ],
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#F8F8F8",
    "backgroundColor": "#F8F8F8"
  }
}

这里我创建了一个 libs 分包来存放组件,这里要注意的是,当 libs 目录被设置为分包 后,默认情况下主包(或其它分包)就不能直接引用该分包内的组件了,因为微信小程序分包机制存在这样的限制:

分包独立性:微信小程序的分包是独立的,默认情况下:

  • 主包不能直接引用分包中的组件/资源
  • 分包也不能直接引用其他分包中的组件/资源

所以如果想要在主包使用分包的组件,需在主包或使用页面的配置中使用 componentPlaceholder 占位符(跨分包引用)。即在 page.json 里的 style 里增加如下配置:

"usingComponents": {
    "tki-qrcode": "/libs/tki-qrcode/tki-qrcode"
  },
"componentPlaceholder": {
  "tki-qrcode": "view"  // 用 view 组件临时占位
}

这样的话就可以在主包内正常使用分包组件了:

<template>
  <view class="content">
    <tki-qrcode
        :size="250"
        val="3333333333333333333"
        :showLoading="false"
        :loadMake="true"
></tki-qrcode>
  </view>
</template>

<script>
import tkiQrcode from '@/libs/tki-qrcode/tki-qrcode.vue'
export default {
  components:{
    tkiQrcode
  },
  data() {
    return {
      title: 'Hello'
    }
  },
}
</script>

下面我们看打包后的代码分析:

可以看到代码包的体积还是 118,组件虽然放到了分包内,但是只有组件的 html 被打包进入了分包,组件的 js 部分还是打包进入了主包内,所以不能有效的减少主包的体积。

这样看好像组件放在主包和分包打包出来的效果是一样的,实则不然,对于放在分包的组件,uniapp 提供了分包优化,开启这个配置后就可以将组件的代码都打包进入对应的分包内,而不是打包进主包。

开启分包优化后的打包情况

manifest.json里的mp-weixin增加optimization配置:

"mp-weixin": {
    /* 微信小程序特有相关 */ "appid": "",
  "setting": {
    "urlCheck": false
  },
  "usingComponents": true,
  "optimization": {
    "subPackages": true
  }
},

再次打包后查看代码分析:

此时代码包的体积为 119kb,而分包的体积为 18KB,可见开启分包优化后,分包组件的 html 和 js 都被打到了分包里,减少了代码的体积。

由此可以得出在组件上优化主包体积的策略:即开启分包优化配置,将主包内要使用的组件单独放到一个组件分包内。

静态资源打包情况分析

对于放置在 static 目录下的静态资源,不论使用与否都会最终被打包进入主包,如图所示:

主包页面并没有使用 static 目录下的 logo 图片,而项目的大小还是 98kb,可见这个未使用的4kb 图片还是被打包进入了主包。

如果将图片放到分包里,则最后打包后的结果为:

主包内使用分包图片:

<image class="logo" src="/libs/static/logo.png"></image>

可以看到主包的体积由 98kb 变成了 94kb,减少的 4kb 就是 logo 图片的体积,而分包 libs 则增加了 4kb,可见在主包内使用分包的图片资源,最终只会打包进入分包。

而且即便是在其它分包使用分包内的图片,仍然不影响使用。

由此我们可以得出在静态资源方面对主包体积的优化:对于静态图片资源,单独放置在一个分包内,不放在主包的 static 目录下。

js 资源打包情况分析

下面再看对于 js 资源,微信小程序是如何打包,这里以一个md5库为例,未压缩的 md5 体积在 11kb, 在主包内引入 md5查看主包的体积大小:

主包体积在 101kb,相比于原始的空项目体积增加了 3kb 左右,也就是打包后的 md5 体积在 3kb 左右。也是增加到了vendor里。

这也即表明了:在主包内使用的 js 代码最终都会被打进vender里,即增加主包的体积

那么如果将 js 丢到分包里,在主包内引入使用,在开启分包优化的情况下,是否还会增加主包的体积?

很遗憾的是,分包优化对于 js 代码无能为力,打包后的总包体积为 102kb,主包的体积仍旧是 101kb,分包增加的不到 1kb 的代码仅仅只是页面的大小,而 js 并没有没打包进入分包。

也就是说:分包优化对于 js 资源是没用的,主包内使用了分包的 js,最终还是会被打进主包,分包内的 js 文件只有在分包内使用,才会被打进分包里。

uniapp 微信小程序打包策略总结

经过以上对于组件和 js 资源,静态资源的打包测试,我们得出了几个结论:

  • 未开启分包的情况下,主包页面引用的组件和 js 资源,静态资源都会被打包进入主包
  • 开启分包未开启分包优化的情况下,主包页面引用来自分包的组件和 js 资源仍然会被打包进入主包,而静态资源则被打入对应分包。
  • 开启主包并开启分包优化的情况下,主包页面引用来自分包的组件会被打进主包,而引用的分包 js 资源仍被打进主包。

至此我们已经明白了微信小程序的打包策略,由此我们可以依赖原生提供的功能来解决由组件资源和图片资源引起的超包问题——开启分包优化

但主包超包的最大罪魁祸手则是打包后的vender造成的,也就是主包内使用到 js 模块,无论他来自主包还是分包,最终经过打包后都会变成主包体积的一部分。

虽然原生没有提供解决方案,但给了我们一个思路,即如果在主包内使用分包的 js 资源,也能够像使用开启了分包优化后的组件那样被打进分包就好了。

好在经过一番探索,找到了一个相关的插件,可以跨包加载 js 的资源:zion-uniapp-mp-load-package,实现我们上述的功能,在主包内使用分包的 js 资源,同时 js 资源被打包进入分包。

下面就进入到本文的第二部分。

使用跨包加载 js 插件减少主包体积

首先是安装插件:

npm i zion-uniapp-mp-load-package -D

增加vue.config.js配置,以及上文第一部分的开启分包优化的配置

const {
  zionUniMpLoadPackagePlugin
} = require('zion-uniapp-mp-load-package/webpack')
module.exports = {
  configureWebpack: {
    plugins: [new zionUniMpLoadPackagePlugin()],
    optimization: {
      moduleIds: 'named'
    }
  }
}

下面在主包内引入 md5查看主包的体积大小:

可以看到代码包的体积来到 105kb,这个插件的体积为 3kb,而未使用此插件代码包的体积为 102kb,符合预期。

目前我们只是配置了此插件,还没有使用其提供的跨包加载 js 资源的方法,所以此时 js 代码还是打包进入主包的。

下面使用跨包加载 js 方法来加载 md5

  1. 在分包内注册一个页面,用于引入分包的 js

注意一定要有export default

  1. 在主包内通过跨包加载发方法使用分包 js:

在 script 里加载组件:

let md5
loadMpPackage(
  'libs',
  () => {
    console.log('加载成功')
    ({ md5 } = loadMpPackageModule('/libs/md5.js'))
    console.log(md5)
  },
  ({ mod, errMsg }) => {
    console.log('加载出错', mod, errMsg)
  }
)
export default {
  onLoad() {
    console.log('md5:', md5('sfsdfsadfasdf'))
  }
}

直接在onload里使用

onLoad() {
  loadMpPackage(
    'libs',
    () => {
      console.log('加载成功')
      const { md5 } = loadMpPackageModule('/libs/md5.js')
      console.log('md5:', md5('1111111111'))
    })
}

简单说下这个方法,loadMpPackage指定要加载的 js 所在分包,我这里设置的是 libs

loadMpPackageModule,加载具体的 js 模块,返回的结果通过解构就能拿到对应的模块。

然后我们再次看打包后的体积

可以看到整包的大小在 106kb,而主包的大小只有 101kb,而主包的初始体积是 98kb,加上插件的 3kb 体积,刚好是 101kb,而分包的体积为 5kb,由此可见在主包内使用的分包 js 模块,4kb 左右的 md5 完全被打包进入到了分包内,丝毫没有增加主包的体积。

至此我们就实现了通过跨包加载 js 来使得主包使用的分包 js 打包进入分包,完美解决由 js 带来的主包超包问题。

使用的几个 tips

此插件提供的两个方法都是异步的方法,所以要等待模块加载完成才能使用。

模块加载方法不仅可以放在页面内使用,也可在main.js内挂载到 vue 的原型上使用,这样在具体的页面内就可以通过this.$md5来使用模块方法,而免去了异步加载。


import Vue from 'vue'
import App from './App'
import './uni.promisify.adaptor'

Vue.config.productionTip = false

App.mpType = 'app'

loadMpPackage(
  'libs',
  () => {
    const { md5 } = loadMpPackageModule('/libs/md5.js')
    Vue.prototype.$md5 = md5
  },
  ({ mod, errMsg }) => {
    console.log('加载出错', mod, errMsg)
  }
)

const app = new Vue({
  ...App
})
app.$mount()

不过要说明的是,因为模块加载方法是异步的,所以在app.vue页面是无法使用的,app.vue 的挂载完成先于模块加载完成,此时找不到方法。

如果不想全局挂载也不想回调使用,也可将此插件封装为公共模块,具体实现如下:

export function loadSDK(fileName) {
  if (!fileName) {
    return Promise.reject(new Error('fileName is required'))
  }
  return new Promise((resolve, reject) => {
    loadMpPackage(
      'libs',
      () => {
        const sdk = loadMpPackageModule(`/libs/${fileName}.js`)
        resolve(sdk)
      },
      ({ mod, errMsg }) => {
        console.log('加载出错', mod, errMsg)
      }
    )
  })
}

具体页面使用:

import { loadSDK } from '@/utils/index.js'
export default {
onLoad() {
  loadSDK('md5').then(({md5}) => {
    console.log('md5:', md5('11111111'))
  })
},

这里要注意,在vue页面内不能使用async的方式来同步执行loadSDK方法, 会导致跨包引用失效,分包代码打包同时打包进入主包和分包。

跨包加载插件的第二个用途

在第一部分中,我们说了开启分包优化后,组件可以被打包进入分包,但是在使用上还有一个痛点:在主包内使用分包组件还需要引入再注册组件才能使用,使用起来比较繁琐。

而跨包加载插件的第二个用途就是帮助我们免去这一步操作,直接在分包内全局注册组件,然后就可以在主包内不需要引入注册就能愉快使用。

我们还是以tki-qrcode组件为例,我们将其放在libs下的components目录内,并在分包的index页面内引入:

然后在主包内直接就可以在模版内使用,而不需要再引入和注册,其效果相当于直接把组件放在src/components目标下,只不过组件只会增加分包的体积,而不会影响主包。

实际案例

前文中,我提出了由三类资源引起的主包超包的解决方案,但因为用的是测试用例,需要放入分包的资源都比较小,只有几 kb,看起来使用跨包加载插件有点大材小用的样子。下面我就演示一个实际生产环境的例子来看下跨包加载插件的威力。

我在项目中用到了云信的 im 插件,压缩后的代码也接近 1mb 大小,直接就干掉了主包一半的容量,再加上框架的 vender,主包的组件和页面,基本上留给主包的空间就相当的捉襟见拙了,在未开发 im 前,我的页面基本都写在主包内的,为了开发 im,引入了 im 插件后,直接迫使主包只能留下 tabbar 相关的页面,全局组件一删再删,原来使用的本地图片也全部改成在线的,这才勉强把 im 塞进去,但是这就使得主包基本不能写任何东西了,稍不留神,就导致主包超了。

之前不知道多少次被超包的问题搞得心态快崩了,这次痛定思痛,一劳永逸的解决超包的问题。

先看下未使用跨包加载插件前,引入 im 后的大小:

可以看到主包的体积直接飙到了 1mb,下面我们使用分包优化:

  1. 将 im 放到分包的 sdk 文件内,并引入

  1. 在主包内引入 im,我这里封装了一个文件im.js来专门加载 im,并暴露出所需要的方法
export function loadSDK(fileName) {
  if (!fileName) {
    return Promise.reject(new Error('fileName is required'))
  }
  return new Promise((resolve, reject) => {
    loadMpPackage(
      'libs',
      () => {
        const sdk = loadMpPackageModule(`/libs/sdk/${fileName}.js`)
        resolve(sdk)
      },
      ({ mod, errMsg }) => {
        console.log('加载出错', mod, errMsg)
      }
    )
  })
}

// 拿到NIM对象
export async function initNIM({appkey}){
  const { default: NIM } = await loadSDK("im")
  nim = NIM.getInstance(
    {
      appkey,
      debugLevel: 'info',
      apiVersion: 'v2',
      enableV2CloudConversation: true
    }
  )
}

// 导出类型
let V2NIMMessageType;

(async () => {
  const { V2NIMConst } = await loadSDK("im");
  V2NIMMessageType = V2NIMConst.V2NIMMessageType;
})();

export { V2NIMMessageType }

这样的话我就可以在任意需要使用 im 的地方通过获取 im.js暴露出的initNIMV2NIMMessageType,实现 im 的功能。然后我们再看打包的大小:

可以看到,im 插件完全被打包进入了分包内,主包还是只有最初的 100 多 kb,这样我们就实现了对大文件的跨包加载,从而成功的避免了引起主包代码体积的增大。

最佳实践

经过上文的分析,我们已经得出了对于引起超包的三个资源的对应解决方案,那么在实际项目中,我们应该如何具体应用,下面说下我的解决方案。

我会创建一个libs分包专用于管理资源,其下分别分别创建componentssdkstatic三个文件夹分别管理具体的组件、js、静态文件这三种资源。

创建一个 index 页面用于统一引入组件,和引入 js 资源。具体配置如下:

至此,就完美的将引起主包超包的三个罪魁祸首完全的用分包来统一管理了,此后再也不需要担心主包超包的问题了。