☰
LayIM+环信WebIM前端实战:适配层统一会话状态与消息闭环
2026/10/31 1:10:44 网站建设 项目流程

简介:这份源码包基于 layui 的 LayIM 组件对接环信,给出一个 PC 端网页即时聊天(WebIM)的完整前端实现,面向需要在后台系统或业务站点中快速嵌入在线客服、同事私聊等聊天模块的前端开发者,可直接二次开发。压缩包共 190 个文件、约 1.7MB,以 33 个 js 脚本、14 个 css 样式、6 个 html 页面为主体,配合 75 个 gif 与 37 个 png 表情及图标素材,以及 eot、woff、ttf、svg 等字体文件,构成一套可运行的聊天界面资源。已有 1530 人学习下载。工程内已实现文字与表情发送,图片、文件发送及联系人、会话列表等接口均使用作者自建的假数据,读者按注释把地址换成自己的后端接口、将环信 key 替换为自己的即可接入;测试时选取张三、李四两个账号即可通信,接口中以 id 作为用户名 key 的约定也一并保留,便于对照排查联调问题。

1. 从「界面动了就算成功」到「状态对得上才算成功」

做环信 网页即时聊天的 PC 端 webIM,很多人第一反应是把长连接打通、消息能弹出来就算完事。真正拖进度的是另一件事:界面里那套会话状态和服务端的消息状态各写一份,刷新一次两边就开始互相矛盾——红点没了、历史消息重了、自己发的消息跑到左边去了。这套 LayIM + 环信的前端 demo 做的就是把这些状态统一到同一份数据流上:好友、群、最近会话、未读角标全部由 LayIM 负责渲染,消息收发、离线补发、附件上传交给环信 SDK,中间夹一层薄适配层做字段翻译和事件转接。适合正在做课程设计、毕业设计里需要一块能现场演示的即时聊天模块的人,也适合后端接口已经就绪、只缺一个前端会话壳子的项目直接拿去改。

2. 总体架构设计:UI 渲染层、适配层、IM 服务层三段式分工

2.1 三层各管什么,一次消息要经过几道手

我一般把这块拆成三层,边界划清楚,后面改需求才不至于牵一发动全身。

UI 渲染层是 LayIM,它只认自己的数据结构:mine、friend、group三块,加上会话窗口和消息气泡。它不知道环信是什么,也不需要知道。IM 服务层是环信 Web SDK 加业务后端接口,负责长连接、消息投递、附件上传、历史漫游。中间那层适配层,是整个工程里唯一同时认识两边字段的文件,通常就叫adapter.js。

一条发出的消息会走六步:用户点发送 → LayIM 触发sendMessage事件并给出to / content / type→ 适配层补上chatType、ext、时间戳,构造环信消息体 → SDK 投递 → 回调拿到消息 id → 更新本地最近会话缓存。接收方向短一些:SDK 回调拿到消息对象 → 适配层归一化字段 → 去重 →layim.getMessage()渲染并落缓存。

分层的好处很实际:将来把环信换成别的 IM 云,只动 adapter 一个文件;LayIM 升级换了字段名,也只动 adapter;调试的时候在 adapter 里打一条日志,进来的消息长什么样、出去的消息长什么样,一眼就能看见,相当于给整条链路装了个黑匣子。不做这一层,业务代码里到处是msg.msg、msg.chatType这种字段判断,改一次需求要全局搜一遍。

2.2 会话与消息的数据模型,两张表对齐着看

字段对不齐是这类项目最常见的返工来源,我先把它列成一张对照表,写代码时照着填。

含义LayIM 侧字段环信消息字段注意事项
会话对象 idid单聊from/to,群聊to单聊取对方 id,群聊取群 id
会话类型type: 'friend' / 'group'chatType: 'singleChat' / 'groupChat'双向映射写反,消息会进错窗口
文本内容contentmsg图片/文件消息不用这个字段
时间timestamp(毫秒)time混用秒和毫秒会把消息排到列表末尾
昵称username无原生字段,靠ext带群聊里必须有,否则只显示 id
头像avatar无原生字段,靠ext带同上
消息唯一标识无id用来做去重,别丢

表里最容易被忽略的是最后一行和昵称那一行。环信的消息体本身只保证「谁发给谁、什么内容」,昵称和头像属于业务信息,得在发送时塞进ext一起带过去。我一般发消息时统一塞{nickname, avatar}两个字段,接收端直接从ext取,取不到再退化到本地好友缓存里查,查不到就显示 id。这个兜底顺序别省,群聊里一定会遇到ext为空的历史消息。

2.3 适配层门面:把版本差异挡在一个文件里

环信 Web SDK 不同大版本之间的消息构造方式差别不小,有的版本一个onMessage收全部消息,有的版本按onTextMessage、onPictureMessage、onFileMessage分开回调。适配层的第二个职责就是把这类差异挡在外面。

// adapter.js —— 全工程唯一同时认识 LayIM 字段和环信消息体的地方 const ChatAdapter = (function () { let conn = null; // 环信连接实例 let selfId = ''; // 当前登录用户 id,必须与 LayIM 的 mine.id 完全一致 // chatType 与 LayIM type 双向映射:写反了消息会跑到错误的会话窗口 const toChatType = (t) => (t === 'group' ? 'groupChat' : 'singleChat'); const toLayimType = (c) => (c === 'groupChat' ? 'group' : 'friend'); // 环信不同字段混用秒和毫秒,统一成毫秒再交给 LayIM const toMs = (t) => (!t ? Date.now() : t > 1e12 ? t : t * 1000); // 兼容两种消息构造方式,换版本时业务代码不用动 function buildText({ to, content, chatType, ext }) { if (typeof conn.createTextMessage === 'function') { return conn.createTextMessage({ chatType, to, msg: content, ext }); } const m = new WebIM.message('txt', conn.getUniqueId()); m.set({ msg: content, to, chatType, ext }); return m; } return { // 页面初始化时注入连接实例和当前用户 id bind(connection, userId) { conn = connection; selfId = userId; }, // 发送:LayIM 的 sendMessage 事件只给到 to / content / type 三个值 async sendText({ to, content, type, ext = {} }) { if (!conn) throw new Error('IM 尚未连接'); const msg = buildText({ to, content, chatType: toChatType(type), ext }); // 新版 conn.send 返回 Promise;老版本在构造消息时就把 success/fail 传进去 return conn.send(msg); }, // 接收:把环信消息对象翻译成 LayIM getMessage 需要的结构 toLayim(msg) { const isGroup = msg.chatType === 'groupChat'; return { id: isGroup ? msg.to : (msg.from === selfId ? msg.to : msg.from), username: (msg.ext && msg.ext.nickname) || msg.from, avatar: (msg.ext && msg.ext.avatar) || '', type: toLayimType(msg.chatType), content: msg.type === 'txt' ? msg.msg : (msg.url || ''), timestamp: toMs(msg.time), mine: msg.from === selfId }; } }; })();

几个参数值得展开说。toLayim()里的id分支判断是关键:单聊时如果消息是自己发的,msg.from就是自己,不能拿它当会话 id,得取msg.to;群聊则永远是群 id。mine字段决定气泡显示在左边还是右边,LayIM 内部也是靠这个判断的,所以它必须由from === selfId推出来,不能靠界面猜。ext里的昵称头像就是前面说的业务补充信息。至于conn.send的返回形式,用哪个版本就按哪个版本的文档来,适配层存在的意义就是让这个差异不出这个文件。

3. LayIM 界面层落地:init 的数据块、会话缓存与事件钩子

3.1 mine / friend / group 三块数据怎么填

LayIM 的初始化基本就是把三块数据喂进去。mine是当前登录人,friend是按分组组织的好友,group是按分组组织的群。结构上都是「分组 + list」的嵌套,别拍平成一个数组,否则左侧面板的分组标题就没了。

layim.config({ brief: false, // 关掉简洁模式,PC 端要显示分组和签名 title: '消息中心', init: { // 这里的 id 必须与环信登录使用的 user 完全一致 mine: { username: '张三', id: 'u_1001', status: 'online', sign: '', avatar: '' }, friend: [ { id: 1, groupname: '我的好友', list: [ { username: '李四', id: 'u_1002', avatar: '', sign: '', status: 'online' }, { username: '王五', id: 'u_1003', avatar: '', sign: '', status: 'offline' } ] } ], group: [ { id: 2, groupname: '我的群', avatar: '', list: [{ id: 'g_2001', groupname: '项目讨论组', avatar: '' }] } ] }, isAudio: false, // PC 端录音依赖浏览器权限,演示项目先关掉 isVideo: false, // 视频通话要额外插件,demo 阶段不建议开 chatLog: true // 打开聊天记录面板,答辩时是个加分项 });

mine.id和环信登录用的 user 必须是同一个字符串,这是整篇里我最想强调的一条。两者不一致时,你会看到自己发的消息全跑到对面那一侧,排查半天以为是渲染 bug,其实是身份没对上。好友list里的id就是环信的 user,群list里的id就是环信的 group id,不需要另外维护一套内部编号——多一套映射就多一个对不上的机会。

3.2 最近会话与未读:本地缓存先渲染,服务端再对齐

刷新页面后最近会话消失、未读红点全清零,是这类 demo 被问得最多的问题。原因是 LayIM 的最近会话和未读计数都活在内存里,页面一刷就没了。我的做法是本地存一份摘要,进页面先用它渲染,再去服务端拉真实会话做合并。

// 最近会话只存摘要,不存完整消息体,避免 localStorage 撑爆 const RECENT_KEY = `chat_recent_${selfId}`; function saveRecent(item) { const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); const idx = list.findIndex(v => v.id === item.id && v.type === item.type); const next = { id: item.id, type: item.type, username: item.username, avatar: item.avatar, content: item.content, timestamp: item.timestamp, unread: item.unread || 0 }; if (idx > -1) { // 以时间戳大的为准,防止服务端返回的旧数据把新消息盖掉 if (next.timestamp >= list[idx].timestamp) list.splice(idx, 1, next); } else { list.unshift(next); } list.sort((a, b) => b.timestamp - a.timestamp); localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, 50))); }

合并规则就一条:谁的时间戳大用谁。服务端通过环信 REST 接口拉到的会话列表,时间通常比本地缓存新,但网络慢的时候也可能反过来,所以不能无脑覆盖。未读数这里有个现实问题——LayIM 没有公开接口能直接设置某个会话的角标数字,我一般是在最近会话面板对应条目上自己画一个角标元素,点开该会话时清零并同步回服务端。绕了一点,但比硬改 LayIM 内部 DOM 结构稳,升级版本也不会崩。

3.3 事件钩子:sendMessage 里别急着渲染

LayIM 的sendMessage事件是发送链路的入口,参数里带着to、content、type,正好对上适配层的入参。这里有个取舍:是让 LayIM 立刻把消息画上屏,还是等发送成功回调再画。

layim.on('sendMessage', async function (res) { const { to, content, type } = res; try { // 先交给适配层,成功后再回填气泡,失败就标红等重发 const sent = await ChatAdapter.sendText({ to: to.id, content, type, ext: { nickname: mine.username, avatar: mine.avatar } }); layim.getMessage({ id: to.id, username: to.name, avatar: to.avatar, type: type, content: content, timestamp: Date.now(), mine: true }); saveRecent({ id: to.id, type, username: to.name, avatar: to.avatar, content, timestamp: Date.now(), unread: 0 }); } catch (e) { // 发送失败不要静默吞掉,界面上必须有可重试的标记 markFailed(to.id, content); } return false; // 部分版本支持用 false 阻止默认渲染,不支持就去掉这行 });

return false这行要不要留,取决于你锁定的那个 LayIM 版本。支持的话最省事,发送失败的消息不会先画上屏再消失,用户体验干净;不支持的话就保留默认渲染,失败时在对应气泡上加一个感叹号图标,点一下重发。两条路都能走通,但一定要选一条,不能既渲染了又不处理失败——那种「点发送没反应也没报错」的体验是这类项目最容易被挑刺的地方。

同样值得挂的还有ready事件,它在界面初始化完成后触发,是拉取离线消息、恢复最近会话、建立长连接的好时机。顺序上我一般是先连 IM 再拉数据,因为拉数据的过程本身可能就需要连接已就绪。

4. 环信 SDK 接入:登录鉴权、连接状态与消息闭环

4.1 登录链路:token 从哪来,什么绝对不能放前端

完整的登录链路是这样的:用户在业务系统里完成登录 → 业务后端用应用密钥向环信换取该用户的 token → 后端把user和token一起返回给前端 → 前端拿着这两个值建立长连接。这里有一条红线:应用密钥只能待在后端,前端代码里出现它等于把整个应用交出去。

连接建立之后才能发消息,这个顺序不能反。我见过有同学在页面加载时就conn.send,结果消息全进黑洞,因为连接还没 opened。稳妥的做法是把连接状态暴露成一个全局变量,发送前先判断:

// 连接状态机:idle -> connecting -> opened -> closed -> reconnecting let imStatus = 'idle'; function setStatus(s) { imStatus = s; // 界面上必须有一根状态条,用户要知道现在能不能发出去 renderStatusBar({ idle: '未连接', connecting: '连接中…', opened: '', closed: '连接已断开,正在重连', reconnecting: '重连中…' }[s]); }

状态条这个东西不起眼,但它决定了用户对「消息发没发出去」的判断。没有它,断网时用户点十次发送,界面毫无反应,最后来一句「你这聊天是坏的」。有它,至少知道是在重连而不是卡死。

4.2 连接生命周期:监听哪几个事件,重连怎么不失控

SDK 的连接事件里,onOpened、onClosed、onOffline、onOnline、onError这几个是必须处理的。onOpened里做三件事:改状态、拉离线消息、刷新最近会话。onClosed和onOffline只改状态,不要在里面直接调重连。

重连失控是血泪经验。早期版本我在onError里无脑调open(),结果 token 过期时它一直失败一直重试,控制台刷屏,后端接口被打了几百次。后来改成指数退避:第一次 2 秒后重试,第二次 4 秒,第三次 8 秒,最多五次,五次之后停下来提示用户手动刷新,并且重试前先向业务后端要一次新 token。这个策略写起来不到二十行,但能省掉一次线上事故。

心跳间隔一般不要动。SDK 默认的心跳值是按网络环境调过的,改小会让移动网络下频繁断连,改大会让断线发现变慢。真要调,先在弱网环境(浏览器开发者工具切到慢速网络)里跑一遍再定。

4.3 消息收发闭环:去重、渲染、落缓存

接收端是整个链路里最需要小心的地方,因为重连之后服务端可能把同一条消息补发两次。

// 消息去重集合,页面生命周期内有效;刷新后靠服务端漫游兜底 const seen = new Set(); conn.listen({ onOpened: () => { setStatus('opened'); pullOffline(); }, onClosed: () => setStatus('closed'), onOffline: () => setStatus('offline'), onOnline: () => setStatus('opened'), onError: (e) => handleError(e), // 内部走退避重试,不要在这里直接 open // 新版统一入口;老版本换成 onTextMessage / onPictureMessage / onFileMessage onMessage: (msg) => { // 1) 去重:消息 id 是最可靠的键,没有就退化成 from + time const key = msg.id || `${msg.from}_${msg.time}`; if (seen.has(key)) return; seen.add(key); // 2) 归一化:字段翻译统一在适配层做 const payload = ChatAdapter.toLayim(msg); // 3) 渲染:窗口已打开会直接追加,未打开则累加未读 layim.getMessage(payload); // 4) 落缓存:刷新后最近会话还在 saveRecent({ ...payload, unread: 0 }); } });

这段里每一步都有讲究。去重键优先用msg.id,因为它是服务端生成的唯一标识,重连补发时不会变;实在拿不到 id 才退化成from + time组合,这个组合在极端情况下会误判(同一用户同一毫秒发两条),但概率极低。渲染那一步不用自己判断窗口开没开,LayIM 内部处理了。落缓存这一步容易被忘,忘了的结果就是刷新之后会话列表空了,但消息其实还在。

4.4 附件消息与历史漫游:两条独立的链路

图片和文件消息跟文本走的是两条链路。文本是「构造消息体 → 发送」,附件是「压缩 → 上传拿到地址 → 构造只带元信息的消息体 → 发送」。顺序不能颠倒,先发消息再传文件的话,对方会收到一条打不开的图片。

图片我一般先用 canvas 压一道,宽度超过 1280 就等比缩下来,质量给 0.8,一张几兆的截图能压到几百 K,上传快很多,对方加载也快。文件消息记得把文件名做一次 URI 编码,中文文件名不编码在部分浏览器上会拿到乱码。语音消息在 PC 端 demo 里我直接砍掉了——要处理浏览器录音权限、编码格式兼容,投入产出比不高,答辩时也很难演示。

历史消息不要指望客户端能拿到全部。环信的历史漫游是服务端接口,前端做的是分页拉取:进入会话时拉最近 20 条,滚到顶部再拉上一页,拉到hasMore为 false 就停。这里要注意分页游标不要用时间戳,用服务端返回的游标值,否则同一毫秒的多条消息会漏。

5. 避坑排查:五个最容易在上线前翻车的点

5.1 连接与鉴权上的三个坑

坑一:自己发的消息显示在对面那一侧。现象很直观,发出去的消息气泡跑到左边,看着像对方发的。原因几乎always 是 LayIM 的mine.id和环信登录用的 user 不是同一个值——有的是业务系统用数字 id、环信用邮箱,有的是大小写不一致,有的是带了个前缀。解决办法是把 id 统一这件事写进接口约定里,登录成功后立刻把mine.id和conn.user打一条日志对比,不一致直接抛错,别让它跑到界面上再查。

坑二:token 过期后无限重连打爆后端。现象是控制台每秒刷一条重连日志,后端接口错误率飙升。原因是onError里直接调了conn.open(),失败又触发onError,形成死循环。解决分三步:加最大重试次数(我一般给 5 次)、加指数退避间隔、重试前先向业务后端重新要 token。第三步是关键,token 过期的情况下不换 token,重试一万次也连不上。

坑三:断网时发消息进黑洞。现象是点了发送,输入框清空了,消息没出现在任何地方,也没有报错。原因是conn.send在未连接状态下被拒绝,而 Promise 的 reject 被吞掉了。解决办法是发送前判断imStatus === 'opened',不满足就进「待发送队列」并在气泡上标记感叹号,连接恢复后自动补发。这个队列不用做太复杂,一个数组加一个flush()就够,但一定要有,不然演示时一断网就露馅。

5.2 消息渲染与状态上的两个坑

坑四:刷新后未读全清零。现象是关着窗口收了三条消息,角标显示 3,一按 F5 变成 0。原因是 LayIM 的未读计数只存在内存里,页面刷新就丢了。解决办法是把未读数跟最近会话一起写进 localStorage,进页面时先按缓存渲染角标,再从服务端拉真实数据进行合并。服务端那份数据需要用环信 REST 的会话接口拿,前端拿不到就别硬编,直接告诉用户「已读状态以服务端为准」。

坑五:新消息插到列表最底下或者最顶上。现象是刚收到的消息跑到历史消息下面去了,或者时间显示成 1970 年。原因是时间戳单位不统一,环信的某些字段是秒,LayIM 要的是毫秒,差了一千倍。解决办法就一个函数:t > 1e12 ? t : t * 1000,所有进 LayIM 的时间戳都过一遍。这个函数我一般直接写在适配层里,不给业务代码绕过它的机会。

6. 验收与答辩:用一张检查表证明这套 webIM 真能跑

功能做完不等于能交。答辩现场最尴尬的不是功能少,是演示到一半卡住,或者被问一句「刷新一下还在吗」就哑了。我一般提前一天按下面这张表跑一遍,每一项都要动手操作,不能靠「应该没问题」。

验收项操作动作通过标准
单聊文本A 发三条给 BB 窗口即时出现,刷新后仍在,顺序不乱
群聊三人群,一人发消息其余两人都收到,昵称和头像正确
断线重连开发者工具切 offline,等 15 秒再恢复状态条先转为断开,恢复后自动补收期间消息
图片消息发一张 2MB 以上截图发送前有压缩,对方能正常打开
未读角标关闭会话窗口,收三条消息角标显示 3,点开后清零
历史漫游会话内上拉每次加载 20 条,不重复不缺失
身份一致性检查自己发的消息位置全部在右侧,无一条跑偏

表格之外还有两个演示技巧。一是账号准备:别用两个普通窗口演示,标签页切换容易看花眼,用「一个正常窗口 + 一个隐身窗口」,两边提前登录好、会话窗口提前打开,现场只需要点发送。二是把适配层的日志面板打开放在副屏或者浏览器另一个标签里,评委问「消息怎么走的」的时候,直接指给他看进来的原始消息体和翻译后的结构,比嘴上讲三分钟管用。

答辩被问到技术选型时,回答的落点放在「为什么这么分」而不是「用了什么」。LayIM 解决的是界面和交互,环信解决的是连接和投递,中间那层适配层解决的是两者字段对不上——这三句话讲清楚,比背一遍 API 列表有说服力。如果被追问难点,就讲身份 id 统一和时间戳单位这两件事,都是具体、可验证、有排查过程的问题,讲起来不虚。

从那以后我每次接 IM 类的前端,都强制先把三件事钉死:id 全链路统一、时间戳统一进适配层、连接状态必须在界面上可见。这三条守住,剩下的都是体力活。这套 demo 的前端工程我把 LayIM 配置、适配层和上面那份验收表整理在一起了,需要的话直接拿去对照着改,比从零拼快得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询