拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uniapp小程序IM插件分包实践:突破主包体积限制的完整方案

uniapp小程序IM插件分包实践:突破主包体积限制的完整方案

今年接手的企业服务小程序,业务页面把主包体积顶到了接近2MB红线,产品又提了个需求:接入IM插件,从首页、扫一扫、订阅消息各种入口进去,都要直接跳转到对应的用户聊天界面。硬把IM塞进主包肯定不现实,体积超限先不说,聊天这种要建长连接、拉历史消息的重功能跟着首屏一起跑,冷启动体验会非常难看。最后我选择把整个IM模块拆成分包,按需加载、统一跳转。这篇文章就把分包配置、插件接入、用户界面的跳转链路以及实际踩过的坑完整梳理一遍,给在uniapp项目里打算接IM的同学一个直接可抄的方案。

这里的“用户界面”,在IM场景里通常指用户会话页或用户详情页。我后面所有的跳转设计,都以这两个页面为核心目标。

1. 为什么把IM插件塞进分包,而不是直接硬怼进主包

1.1 主包体积限制不是唯一问题,首屏耗时更致命

先说硬指标。以微信小程序为例,主包和单个分包都有严格的体积上限,超了连上传都过不去,后端同事直接在控制台上看到校验失败。uniapp打包到小程序端,同样受这套规则约束,你在pages.json里写的pages会原样编译成小程序原生配置,不会因为框架帮你“消化”了体积限制。

但体积只是表面问题,真正影响体验的是首屏耗时。IM这种功能有个特点:它要登录、要拿连接、要拉会话列表,整套初始化动作非常重。如果把这些逻辑放在主包里,用户冷启动小程序那一刻,主包所有JS都得解析执行一遍,IM的初始化代码也会跟着跑起来。哪怕用户根本不想聊天,他也要为这个用不上的功能付出一部分启动时间。

我当时看了一下降级方案,把IM插件的初始化推迟到页面再执行,代码不执行确实不占首屏时间了,但体积还在。主包里的JS文件、组件、静态资源不会因为“没执行”就自动变小。所以结论很明确:要么不做,要做就把IM整个模块从主包剥离,放进分包。

1.2 IM插件到底“重”在哪里,选型时该盯几个点

插件市场里的IM插件,大体分两类:一类是纯SDK,只提供底层能力,界面自己画;另一类是SDK加聊天UI组件,开箱即用。后者体积通常更大,因为除了通信逻辑,还要带消息列表、输入框、表情面板、图片预览这些界面组件。

以常见云厂商SDK为例,纯JS版SDK的体量一般在几百KB到1MB不等,这取决于功能裁剪情况。如果选了带UI的完整插件,再叠加一些三方UI库依赖,整体体积很容易超过1.5MB。这个数字对一个小程序来说非常可观,尤其是它还要被编译进主包的时候。

选型时我的建议是盯三个点:一是SDK是否支持按需引入,能不能只保留文字消息、图片消息,砍掉语音视频;二是UI组件是否可定制,否则聊天页风格和你项目对不上,改起来比从零写还难受;三是插件是否依赖额外的UI库,比如某些插件要求项目里已经装了uview-plus这类组件库,这会把依赖链拉得更长,体积控制难度直接翻倍。

1.3 分包边界怎么划,才不会拆完就后悔

分包不是简单地把一些页面挪出去就完事,关键是边界要划得干净。我的划分原则是:首屏一定要用的留在主包,低频且重量级的整个塞进分包,不让两边有复杂的互相依赖。

具体到IM场景,我把会话列表、聊天窗口、用户详情页、初始化中转页全部放进IM分包。主包只保留首页、登录页、tabBar页面,以及一个纯字符串拼接的路由帮助函数。这个帮助函数不引用任何SDK,只负责拼路径和参数,这样主包就不会因为一个import把SDK代码带进来。

边界划分最忌讳的是“主包页面直接import了SDK的某个方法”。比如有人为了省事,在首页里直接调用IM SDK判断用户是否在线,这一行import,就能把整个SDK chunk拽进主包编译产物。真要这样做了,分包拆了等于没拆。

2. 分包配置与IM插件接入的落地实操

2.1 pages.json里的分包与预加载配置,一个都不能少

先看目录结构的设计。我的项目里,主包目录保持默认的pages,IM分包单独建一个目录,名字就叫pagesIM,和主包目录平级:

src/ ├── pages/ │ ├── index/index.vue │ └── login/login.vue ├── pagesIM/ │ ├── chat/chat.vue │ ├── conversation/conversation.vue │ ├── entry/entry.vue │ └── manager/imManager.js └── pages.json

pages.json里对应配置如下:

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } } ], "subPackages": [ { "root": "pagesIM", "pages": [ { "path": "chat/chat", "style": { "navigationBarTitleText": "聊天" } }, { "path": "conversation/conversation", "style": { "navigationBarTitleText": "会话" } }, { "path": "entry/entry", "style": { "navigationBarTitleText": " " } } ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["pagesIM"] } } }

几个容易出错的点说一下。第一,root不要以斜杠开头,直接写pagesIM,和主包目录区分开。第二,preloadRule的packages数组里填的是分包root名称,不是页面路径。第三,network字段设成all表示不管WiFi还是流量都预下载,如果对流量敏感可以设成wifi,但IM场景我建议all,因为用户点击聊天时如果分包还没下载,第一次跳转会明显卡顿。

preloadRule的作用是:用户进入指定页面后,微信后台就开始预下载分包资源。我把触发点设在了首页,这样用户从首页进聊天时,分包大概率已经下载完成,跳转体感接近秒开。

2.2 让插件代码真正落在分包里,而不是偷偷混进主包

从HBuilderX插件市场导入的uni_modules插件,默认会放在项目根目录的uni_modules下面。这个位置很微妙——如果插件组件只在分包页面里使用,微信小程序的编译机制大体上会把组件打进使用它的分包;但如果项目里某个主包页面间接引用了插件模块,或者插件本身有被easycom自动注册到全局,编译产物就可能混进主包。

所以我的做法是不依赖“默认行为”,主动检查。每接入一个插件,我都会在微信开发者工具里打开“代码依赖分析”面板,看主包产物里有没有出现IM相关的chunk。一旦发现,就顺着import链路把引用点从主包页面中摘出去,确保只有分包页面才import插件代码。

另一个常见坑是npm方式引入的SDK。如果你在主包的某个工具模块里写了import IM from 'im-sdk',哪怕只是放在那里没用,构建时这个依赖也可能被打进公共vendor chunk,间接污染主包体积。我的经验是:SDK永远不要在App.vue、main.js、全局store里import,只允许在分包目录下的JS文件里引用。分包内的JS模块在构建时会被打包到分包产物,和主包彻底隔离。

2.3 SDK初始化时机:别让聊天页和初始化抢跑

IM SDK的初始化是异步的,要连接服务器、校验签名、建立长连接。如果把初始化放在聊天页的onLoad里,就会出现一个尴尬的竞态:页面已经渲染出一个空壳,但消息列表还没有数据,用户看到的是一闪而过的空白界面,甚至会因为某些自动拉取逻辑执行太早而直接报错。

我的解法是加一个轻量中转页。所有跳转IM的入口,先统一进入entry中转页,在这个页面里完成SDK初始化和登录,成功后redirectTo到真正的聊天页。中转页不需要渲染任何UI,它的全部职责就是挡在异步逻辑前面,把竞态吃掉。

// pagesIM/manager/imManager.js let imInstance = null let readyPromise = null export function getIMInstance() { if (!imInstance) { imInstance = createIM({ sdkAppId: '你的SDKAppID', userId: getCurrentUserId() }) } return imInstance } export function ensureIMReady() { if (readyPromise) return readyPromise readyPromise = new Promise((resolve, reject) => { const im = getIMInstance() im.on('ready', () => resolve(im)) im.on('error', err => reject(err)) im.login({ userSig: getSigFromServer() }).catch(reject) // 超时兜底,别让Promise无限pending setTimeout(() => reject(new Error('IM初始化超时')), 8000) }) return readyPromise }

中转页代码:

// pagesIM/entry/entry.vue import { ensureIMReady } from '../manager/imManager.js' export default { async onLoad(options) { try { await ensureIMReady() } catch (e) { console.error('IM init failed', e) uni.showToast({ title: '连接服务失败', icon: 'none' }) setTimeout(() => uni.navigateBack(), 1500) return } uni.redirectTo({ url: `/pagesIM/chat/chat?userId=${options.userId}&nickname=${encodeURIComponent(options.nickname || '')}` }) } }

有人会问,中转页会不会让用户觉得多跳了一层?实测下来,只要初始化速度快,用户基本无感。而且从体验完整性上讲,中转页远比“聊天页白屏两秒然后数据刷出来”要好。

3. 从业务入口直接跳转到用户界面的完整链路

3.1 跳转API到底用哪个,一张表说清楚

uniapp提供了一组页面跳转API,用途差别很大。IM场景里用得最多的是navigateTo和redirectTo,但很多同学会把它们搞混,这里整理成一张表:

API行为适用场景
uni.navigateTo新页面入栈,原页面保留,可返回从首页/详情页进入聊天页,用户还要返回
uni.redirectTo关闭当前页面,跳转到新页面中转页初始化完成后,不再需要中转页
uni.reLaunch关闭所有页面,打开新页面会话列表和业务页面完全切换时
uni.switchTab跳转到tabBar页面首页、我的这类tab必须用它

跳转到分包页面的路径规则和主包一样,只要在url里写全分包root加页面路径即可。比如/pagesIM/chat/chat。需要注意,分包页面里不能再跳转到tabBar页面时用navigateTo,必须用switchTab,这是小程序原生规则,uniapp同样继承。

3.2 首页列表、订阅消息、扫一扫三个入口的跳转实现

入口一:首页用户列表点击“发消息”。这是最常见的场景,列表里有个用户,点按钮直接进聊天。我封装了一个路由帮助函数,放在主包的utils目录下,所有入口统一调它:

// utils/imRoute.js export function openUserChat(userId, nickname = '', source = '') { const query = [ `userId=${encodeURIComponent(userId)}`, `nickname=${encodeURIComponent(nickname)}`, `source=${encodeURIComponent(source)}` ].join('&') uni.navigateTo({ url: `/pagesIM/entry/entry?${query}`, fail: (err) => { console.warn('navigateTo IM failed', err) uni.showToast({ title: '功能加载失败,请重试', icon: 'none' }) } }) }

入口二:订阅消息。用户从微信“服务通知”点进小程序时,App.vue的onLaunch或对应业务页面的onLoad能拿到options.query。我在这里做了一个延迟消费的处理:先把目标用户参数存下来,等首页首屏渲染完成后再调openUserChat。不要在冷启动的onLaunch里立刻跳转,页面栈都还没建立,跳转很容易失败或造成页面栈异常。

入口三:扫一扫。扫普通链接二维码进入小程序时,落地页的onLoad(options)里会有options.q或其他自定义参数,把其中的用户ID解析出来,再走openUserChat。扫码场景有个额外的好处,因为用户扫码动作是明确的,他大概率知道自己要干什么,这时候直接进聊天页转化率很高,跳转要果断。

3.3 参数传递:query、eventChannel和中转页取舍

跳转时传参,最直接的方式就是拼query。优点是简单直观,页面刷新、分享链接都能保留参数。缺点是URL有长度限制,传超长文本或者带特殊字符的内容容易出问题,所以query里只传用户ID、昵称、来源这些轻量数据。

eventChannel则适合更复杂的跨页面通信。它通过uni.navigateTo的success回调拿到eventChannel实例,可以向新页面主动emit数据,也能监听新页面抛回的事件。IM场景里,我经常用它来处理“聊天页有未读变化时通知上一页更新角标”之类的需求:

// 发起方,用户详情页 uni.navigateTo({ url: '/pagesIM/entry/entry?userId=10001', events: { onUnreadChange(data) { // 聊天页里有未读变化,回传更新当前页 updateUnread(data.count) } }, success(res) { res.eventChannel.emit('initUserInfo', { userId: '10001', nickname: '张三' }) } })

中转页从this.getOpenerEventChannel()里拿到发起方传过来的数据,存到全局或直接带在redirectTo的URL里继续传给聊天页。需要注意,redirectTo会断掉当前eventChannel链路,所以中转页如果需要把eventChannel数据透传给聊天页,要么暂存到内存,要么拼到URL上。我一般选择拼URL,简单,不引入额外状态。

3.4 小程序端、App端、H5端的平台差异处理

很多同学会误以为写了subPackages,App端包体也会变小。事实不是这样。分包是微信小程序平台的概念,App端有自己的一套页面加载机制,原生插件体积和资源加载策略才是App端要重点关注的。H5端更是完全不存在分包,所有页面都会打进前端bundle,除非你手动做路由级代码分割。

这意味着同一套uniapp代码,在不同端要区别对待。我的处理方式是:小程序端走完整链路,先进中转页;App端和H5端IM初始化通常已经由原生SDK或前端SDK包管理,可以直接进聊天页,不再多绕一层中转页。用条件编译区分:

// #ifdef MP-WEIXIN || MP uni.navigateTo({ url: `/pagesIM/entry/entry?userId=${userId}` }) // #endif // #ifndef MP uni.navigateTo({ url: `/pagesIM/chat/chat?userId=${userId}` }) // #endif

另外,App端如果用的是原生IM插件,需要在manifest.json的App模块配置里勾选对应模块;小程序端则在manifest里配置好各平台appid,否则云厂商控制台里拿到的SDKAppID对应不了当前环境,联调时会一脸懵。

4. 常见问题与排查技巧实录

4.1 页面not found与路径问题速查

跳转报page not found,基本就两个原因:路径拼错,或者分包没编译进去。前者好查,对照pages.json里的root和path逐级核对;后者容易被忽视,改了pages.json之后没有重新编译,HBuilderX里跑了半天还是旧的配置,强制重新运行一次就好。

还有一个非常隐蔽的坑是大小写。Windows开发机上文件系统大小写不敏感,Chat和chat都能访问,但Android真机和微信环境对大小写敏感,本地一切正常一到真机就报not found。这种问题排查起来极其浪费生命,所以我的建议是从一开始就约定好:页面目录和文件名全部小写加连字符,路径里不要出现大写。

4.2 聊天页白屏、事件不响应的排查顺序

白屏的排查顺序,我一般这样走:先看vConsole控制台有没有JS报错,再看Network面板里分包资源是否下载成功,最后看IM SDK的初始化状态。如果发现错误信息是“模块未找到”,基本可以确定是主包页面误import了分包里的SDK代码。

事件不响应,比如发送按钮点了没反应,通常是连接状态已经掉线。IM长连接在弱网环境下会自动断开,如果SDK没有做自动重连或者重连失败,界面还挂在那边,用户点发送就一直失败。我的做法是在聊天页的onShow里检查连接状态,断开时尝试重连,重连失败就弹一个轻提示,别让用户以为按钮坏了。

另一个常被忽略的点是Ionic、Vue生命周期的顺序。分包页面在真机上可能因为资源回收被销毁重建,聊天页的onLoad可能会执行第二次,如果初始化逻辑没有做幂等处理,就会重复创建会话,界面出现消息重复。所以ensureIMReady这个Promise单例模式很重要,它保证整个会话期间SDK只初始化一次。

4.3 分包体积超限的压缩思路

把IM放进分包后,体积问题并没有消失,只是从主包转移到了分包。如果IM分包自身超过限制,同样上不了线。我从几个方向压体积。

第一,SDK功能裁剪。很多云厂商SDK支持按需引入模块,比如只保留文本和图片消息,去掉语音、视频、位置消息,体积能省掉不小一块。第二,组件按需注册。如果IM插件内置了丰富的UI组件,但你真的只用到消息列表和输入框,就手动注册需要的组件,别让easycom把整个组件库都扫进来。第三,静态资源瘦身。聊天里的表情包、贴图资源能放CDN就放CDN,不要都塞在包体里。

还有一个小技巧:如果IM分包完全不依赖主包的公共逻辑,可以把它配置成独立分包,在subPackages里加"independent": true。独立分包可以进一步优化主包和分包之间的依赖关系,但注意它不能使用主包里的静态资源和公共方法,用之前要确认SDK没有此类依赖。

4.4 真机上的诡异问题与小技巧汇总

现象可能原因处理方案
第一次点击跳转聊天没反应分包还没下载完,navigateTo被fail配置preloadRule,fail回调里给用户提示
真机白屏但开发者工具正常大小写问题、分包路径不一致统一小写命名,真机实测为准
主包体积反而变大SDK被主包页面import或引入公共chunk用代码依赖分析查引用链,移除主包import
聊天页消息重复页面被重建,onLoad重复执行初始化逻辑幂等,Promise单例缓存
iOS Safari里canvas导出白图离屏canvas被回收或绘制时机不对避免在onHide期间绘制,图片导出用独立canvas组件

最后补一个周边经验:IM聊天里如果要做图片导出,也就是把聊天记录导出成一张长图,在iOS Safari上用canvas偶尔会导出白图。这不是uniapp的锅,本质是canvas绘制时机和页面生命周期互相干扰,离屏canvas被系统回收。搞定方法是不要用全局唯一的canvas实例,单独维护一个不可见的canvas,画完立即导出,导出完成就销毁,别留在页面里。

最后聊点个人体会

这套方案在我的项目里跑了两个版本,从主包体积、首屏耗时、用户触达三个维度看,收益都比预想大。个人体会是:IM这种低频但重量级的功能,天生适合分包;跳转路径务必收口成统一的帮助函数,别让业务页面里到处散落着写死的路径字符串;初始化异步一定要有超时和兜底,不然线上环境里一个悬而未决的连接就能让用户卡在加载页。

最后一个实用建议:给每个跳转入口都带上source参数,标记这个用户是从首页、扫一扫还是订阅消息进来的。后面做运营分析时,你会发现这个小小的参数能回答很多“用户从哪里来、为什么来”的问题,这个决定绝对值得。

返回列表