导读:小程序的聊天功能原来是接 GoEasy 的,第三方服务要钱还要看脸色,后来换成自己后端 WebSocket。换的时候我做了个关键决定:对外暴露的 API 保持和 GoEasy 兼容,业务页面不用大改,只换底层。这篇讲 imClient.js 的封装思路——兼容层、连接态管理、会话列表防抖、语音时长归一化,还有本地消息状态机踩过的坑。
为什么要套一个兼容层
GoEasy 的写法是imClient.connect()、imClient.im.sendMessage()、事件用imClient.on()监听。业务代码里几十处这么调的,如果自建后全改一遍,光改页面就要一两天。
所以我在 utils 里写了imClient.js,对外 API 形状跟 GoEasy 一致:IM_SCENE、IM_EVENT常量、connect/on/off/sendMessage/history全保留,底层换成自己的socketService。业务层只把 import 路径换一下,其余代码几乎不动。
场景和事件常量:先定契约
一上来先把常量对齐,后续所有方法都引用这两组:
constIM_SCENE={PRIVATE:'private',GROUP:'group'};constIM_EVENT={PRIVATE_MESSAGE_RECEIVED:'private_message',GROUP_MESSAGE_RECEIVED:'group_message',MESSAGE_DELETED:'message_deleted',MESSAGE_RECALLED:'message_recalled',CONVERSATIONS_UPDATED:'conversations_updated',USER_ONLINE:'user_online',USER_OFFLINE:'user_offline',PEER_TYPING:'peer_typing',PEER_STOP_TYPING:'peer_stop_typing',};on(event, handler)里做个映射,业务传IM_EVENT.PRIVATE_MESSAGE_RECEIVED,底层转成private_message交给 socketService,接口不变:
on(event,handler){constsocketEvent=IM_EVENT[event]||event;socketService.on(socketEvent,handler);},连接态管理:ensureConnected 是灵魂
最容易出问题的就是连接状态。用户在弱网下点发送,Socket 可能根本没连上,直接 send 会静默失败。ensureConnected把三种状态统一处理:
ensureConnected(){constcachedUser=uni.getStorageSync('userInfo')||{};if(!cachedUser.token){returnPromise.reject(newError('未登录'));}if(socketService.isReady()){returnPromise.resolve();}conststatus=socketService.getConnectionStatus();if(status==='connecting'){returnsocketService.waitForReady();}returnnewPromise((resolve,reject)=>{socketService.connect({id:cachedUser.id,name:cachedUser.nickname,avatar:cachedUser.avatar,token:cachedUser.token},{onSuccess:resolve,onFailed:(err)=>reject(newError((err&&err.content)||'Socket 连接失败')),});});}已连接直接返回;连接中就去等waitForReady();完全断开才发起新连接。所有 send 之前先ensureConnected().then(sendFn),从根上杜绝"没连上就发消息"。
会话列表 280ms 防抖:别把后端打穿
聊天页进入时,会话列表会同时被多个事件触发刷新——收到新消息、进入页面、切回前台。不加防抖,一秒可能发好几个请求。封装里用了一个 pending + timer 的写法:
letconvListDebounceTimer=null;letconvListPending=null;functionflushConversationListRequest(){constpending=convListPending;convListDebounceTimer=null;convListPending=null;if(!pending)return;constcachedUser=uni.getStorageSync('userInfo')||{};if(!cachedUser.token){pending.onFailed&&pending.onFailed({code:401,content:'未登录'});return;}uni.$u.http.get('/api/chat/getConversationList',{params:{roleCode:cachedUser.memberRole||'member'},custom:{isFactory:true},}).then((res)=>{pending.onSuccess&&pending.onSuccess({content:res});}).catch((err)=>{pending.onFailed&&pending.onFailed({code:500,content:err.message||'获取会话列表失败'});});}latestConversations({onSuccess,onFailed,immediate=false}){convListPending={onSuccess,onFailed};if(convListDebounceTimer){clearTimeout(convListDebounceTimer);convListDebounceTimer=null;}if(immediate){flushConversationListRequest();return;}convListDebounceTimer=setTimeout(flushConversationListRequest,280);}关键点:只保存最后一次的 onSuccess/onFailed,防抖窗口内的刷新合并成一次请求,页面拿到的永远是最新结果。immediate=true支持强制刷新(比如下拉刷新)。
语音时长归一化:毫秒还是秒,别让 UI 猜
uni.chooseVideo的 duration 是秒,但有些来源给的是毫秒(录音组件、老版本插件),直接展示会把 60 秒显示成 60000。加了层归一化:
functionnormalizeAudioDuration(raw){constn=Number(raw)||0;if(n<=0)return1;// 大于 120 视为毫秒(语音最长约 60s)if(n>120)returnMath.max(1,Math.ceil(n/1000));returnMath.max(1,Math.ceil(n));}规则简单粗暴:大于 120 就当毫秒转秒。语音最长 60 秒,不会有正常值超过 120 秒。
踩坑:图片上传失败,消息框里永远显示"发送中"
问题现象:弱网下发图片,上传失败后气泡一直转圈,没有任何失败提示,用户以为发出去了,实际对方没收到。
排查过程:看createImageMessage的实现,uploadFile(filePath).then(...).catch((err) => onFailed && onFailed(err)),但页面侧 onFailed 里只弹了 toast,没有把本地消息状态改掉。本地消息buildLocalMessage里 status 默认是sending,失败后没人把它改成fail,气泡就一直转。
定位思路:消息状态机不完整——sending应该有对应的success和fail两个出口,fail 的出口必须由调用方处理,但调用方经常偷懒只处理成功。
最终解决:在 sendMessage 的 catch 里统一把message.status = 'fail',再调 onFailed;同时页面侧约定:status 为 fail 的气泡要展示重发按钮。状态机闭环后,失败的消息不再假装在发送。
可直接复用的清单
- 换 IM 底层服务时,对外 API 保持兼容,业务层只改 import;
- 所有发送前
ensureConnected(),已连接/连接中/断开三种状态分开处理; - 会话列表刷新做防抖,只保留最后一次回调;
- 语音时长按
>120 转毫秒归一化,UI 永远拿秒; - 本地消息状态机
sending → success | fail,失败必须有出口和重发入口。
这个 IM 封装在 job-uniapp 小蓝直聘移动端。项目源码:https://gitee.com/gzqkl/job-uniapp