☰
LayIM对接环信WebIM SDK:后台网页聊天实战
2026/10/31 1:03:49 网站建设 项目流程

简介:面向 Web 前端开发者与即时通讯入门学习者的 layim 与环信整合聊天演示包,聚焦 PC 端网页 WebIM 场景,帮助读者理解聊天窗口、消息收发、联系人列表等模块的前端组织方式,并可用自己的后端接口与环信 Key 进行替换联调。包内共 190 个文件,压缩后约 1.7MB,以 js 脚本、css 样式与 html 页面为主体,配合大量 gif、png、jpg 界面与表情素材,以及 eot、woff、ttf、svg 等字体图标文件,整体是可直接运行的静态前端工程结构。现有 1530 人学习,适合作为聊天界面搭建与环信接入的练手样例。代码已实现文字、表情发送及图片、文件发送的交互入口,联系人等数据以假数据占位,替换成自己的接口即可继续调试;示例中张三、李四两个账号可用于互发消息,需注意接口以 id 作为用户名键。通过拆解目录与页面组织,读者可快速掌握 layim 皮肤定制、消息列表渲染和第三方 IM SDK 接入的基本流程,也能据此排查接口替换中的常见问题。

1. 后台系统要加聊天:LayIM 负责画皮、环信只负责跑腿

需求来得很日常:一套已经跑了两年的 PC 端后台,产品突然说要在右下角加一个“环信 网页即时聊天”面板,好友列表、群列表、气泡、未读红点都得有,最好这周提下周上。自己撸一套 WebSocket,心跳、重连、离线消息、多端同步、消息去重全得自己扛,前端两个人扛不下来。于是选了个折中方案:LayIM 做界面骨架,消息收发整个甩给云端 IM SDK,这就是这份 demo 的全部思路——纯前端,不碰服务端渲染。它适合两类人:一是在管理后台里塞“内部沟通”“客服面板”的前端,想先跑通再改造;二是想看 LayIM 这种纯 UI 组件怎么跟第三方 IM 对接、事件怎么映射、字段怎么对齐的人。

2. 分层与目录:LayIM 管画皮,SDK 管跑腿,中间那层才是重点

把这两个东西拼起来之前,先得认清楚它们根本不是一类东西。LayIM 是一套 layui 生态里的 UI 组件,它连一个字节的网络请求都不会替你发,好友列表、群列表、我的资料全靠你在init里配的接口去拉;它唯一主动往外抛的,是一个叫sendMessage的事件。环信 WebIM SDK 正好相反,它只管连接、鉴权、投递、好友关系、群关系,界面上一个像素都不管。中间那层没人写,就得你自己写,这份 demo 里它叫im-adapter.js,也是最值得看的部分。

2.1 三层职责边界,以及一条消息走过的完整路径

先把职责摊开,后面所有问题都能对上号。

层具体是谁负责什么不负责什么
UI 层LayIM好友/群列表渲染、聊天窗口、气泡、皮肤、表情、输入框连接、投递、鉴权、消息存储
连接层环信 WebIM SDK长连接、心跳、重连、消息投递、好友关系、群关系DOM、样式、列表数据、业务规则
适配层im-adapter.js事件映射、字段转换、状态同步、去重业务规则、权限判断

一条消息从输入框到对方屏幕,走的是这么一条链路:用户敲字回车 → LayIM 内部抛出sendMessage,回调参数里带着mine(自己)和to(对方或群)以及content→ 适配层把这三个东西拆开,翻译成 SDK 认识的{to, msg, chatType, ext}→conn.send()发出去 → 服务端投递 → 对方 SDK 的conn.listen里onTextMessage被触发 → 适配层再把{from, data, type, ext}翻译回 LayIM 的{id, type, username, avatar, content, fromid}→layim.getMessage()把气泡插进窗口。反方向失败、回执、撤回,都是同一条链路的不同分支。

这里有个血泪经验:适配层真的不能省。LayIM 要的字段是id / type / username / avatar / content / fromid,SDK 给的是from / to / data / type: 'chat'|'groupchat' / ext,两套命名毫无重合。有人图省事直接把 SDK 回调对象塞进layim.getMessage(),结果就是气泡里全是undefined,或者干脆一声不吭什么都不渲染,控制台干干净净,典型的黑匣子现场。

2.2 demo 的目录结构与跑起来的三个动作

这份 demo 的文件组织很朴素,没有什么构建工具,双击index.html理论上能打开,但实际会因为上传接口和跨域挂掉,所以还是得起个静态服务。

layim-easemob-demo/ ├── index.html # 唯一入口,LayIM 的容器就是一个 div ├── lib/ │ ├── layui/ # layui 基础库,LayIM 依赖它 │ ├── layim/ # LayIM 的 css / js / skin │ └── websdk/ # 环信 WebIM SDK,版本换大了 API 会变 ├── js/ │ ├── config.js # appKey、接口地址,改这一处就能跑 │ ├── im-adapter.js # 适配层:LayIM 事件 ←→ SDK 方法 │ └── main.js # 启动顺序:配 LayIM → 连 IM → 挂回调 └── mock/ └── init.json # 没后端时 LayIM init 拿到的假数据

跑起来就三步。第一步改js/config.js,把appKey换成自己控制台里的,把apiURL、xmppURL换成自己环境的域名——这两个地址写错的表现是连接一直转圈,onError里丢一个 404 出来。第二步起静态服务,不要用file://直接开:

# 任选一个,端口别和已有服务冲突 npx serve ./ -l 8080 # 或者 python3 -m http.server 8080

第三步,如果你手上没有可用的后端,把layim.config里的init.url指向mock/init.json,先把界面跑出来,再回头接真接口。这样至少能确认 LayIM 本身是好的,问题出在数据还是出在连接,排查范围直接砍一半。

2.3 选型取舍:为什么不用原生 WebSocket 自己写

自己写长连接不是不行,是隐性成本太高。一个能用的 IM 至少要解决:心跳保活、断线重连与重连后的消息补偿、离线消息拉取、多端登录互踢、消息去重、已读回执、群成员变更通知。这些东西单拎出来都不难,凑一起就是无底洞。云端 IM 把这些打包好了,代价是数据结构受制于人、按量计费、绑定一家服务;LayIM 的代价是界面风格固化,想改皮肤得动它的样式文件,而且上线前得先确认授权范围,它不是那种完全无约束的免费组件。对“后台内部沟通”这种并发不高、界面要求不高的场景,这个交换是划算的;但如果你要做的是对外客服、要接工单系统,那这套壳子撑不住,得换方案。

3. 启动链路:从 layim.config 到 conn.open 的顺序不能反

启动这块最容易翻车的地方不是代码写错,是顺序写错。LayIM 的init接口如果需要拿当前登录用户的信息去查好友列表,那它就必须在拿到业务 token 之后调用;而 IM 的conn.open又需要 IM 侧的账号或 token。这两条线谁先谁后,决定了你第一次进页面会不会看到空列表。

3.1 先配 LayIM:init 接口必须返回的那坨结构

LayIM 的配置项不少,但真正影响能不能跑起来的就那么几个。init是数据来源,uploadImage/uploadFile是两个上传口子,isAudio/isVideo控制语音视频按钮,demo 里没有对应能力就直接关掉,留着按钮点了没反应更难受。

layim.config({ // LayIM 自己会去请求这个地址,拿好友/群/我的资料 init: { url: '/api/im/init', type: 'get', data: { token: localStorage.getItem('token') } }, brief: true, // 简洁模式,列表里不显示个性签名,省一行高度 title: '内部沟通', isAudio: false, // demo 没接语音,关掉避免点了没反应 isVideo: false, isChatLog: false, // 聊天记录走自己的接口,不用它内置的 right: '0px', minRight: '0px', uploadImage: { url: '/api/upload/image', type: 'post' }, uploadFile: { url: '/api/upload/file', type: 'post' }, copyright: false });

init接口返回的结构是死的,字段名一个都不能改:

{ "code": 0, "msg": "", "data": { "mine": { "id": "u_10086", "username": "我", "avatar": "", "sign": "" }, "friend": [ { "groupname": "我的好友", "id": 1, "online": 2, "list": [ { "id": "u_10010", "username": "张三", "avatar": "", "sign": "", "status": "online" } ] } ], "group": [ { "groupname": "我的群", "id": 2, "list": [ { "groupname": "项目组", "id": "g_2001", "avatar": "" } ] } ] } }

三个参数说明一下。id一律用字符串,用数字在某些版本的列表渲染里会出问题;status只认online和offline两个值,写1/0或者true/false都不会报错,但头像永远是灰的;mine.id后面要拿来跟消息的fromid比对,判断气泡在左还是在右,错了整片气泡方向全反。

3.2 再连 IM:config / connection / open / listen 四步

SDK 这边是四步走,顺序同样不能乱:先给全局config赋值,再new一个连接实例,再open登录,最后listen挂回调。很多人把listen写在open前面,其实也行,但onOpened里如果要做拉列表这种依赖连接就绪的事,就必须保证listen已经挂上了。

// 1) 全局配置:appKey 从自己的配置文件读,不要写死在仓库里 WebIM.config = { xmppURL: 'https://im-api.your-domain.com', // 换成自己环境 apiURL: 'https://a1.your-domain.com', appKey: window.IM_APP_KEY, https: true, isMultiLoginSessions: false, // 单人单端,避免自己把自己踢下线 isAutoLogin: true, heartBeatWait: 30000, // 心跳间隔,太小浪费电,太大断线发现慢 autoReconnectNumMax: 5, isDebug: false }; // 2) 建连接 const conn = new WebIM.connection({ isMultiLoginSessions: WebIM.config.isMultiLoginSessions, https: WebIM.config.https, url: WebIM.config.xmppURL, apiUrl: WebIM.config.apiURL, heartBeatWait: WebIM.config.heartBeatWait, autoReconnectNumMax: WebIM.config.autoReconnectNumMax }); // 3) 登录 conn.open({ user: 'u_10086', pwd: '******', appKey: WebIM.config.appKey }); // 4) 挂回调,这里是所有消息进入页面的唯一入口 conn.listen({ onOpened: function () { syncState(); }, // 连接建立,拉好友/拉群/置在线 onClosed: function () { /* 顶栏显示"已断开" */ }, onReconnect: function () { syncState(); }, // 重连成功也要补一次状态 onTextMessage: function (msg) { /* 见 4.3 */ }, onEmojiMessage:function (msg) { /* 表情消息,data 是表情标识 */ }, onPictureMessage: function (msg) { /* 图片,msg.url 是地址 */ }, onPresence: function (msg) { /* 好友申请、上下线 */ }, onError: function (e) { console.error('[IM]', e); } });

heartBeatWait这个参数值得单独说一句。设太小,移动端耗电、服务端压力大;设太大,网络切换后要等很久才发现自己已经掉线了,用户发出去的消息石沉大海。三万字左右的取值是行业里比较常见的折中,具体还得看自己的网络环境。

3.3 登录态串联:别把密码写进前端

demo 为了方便,conn.open里直接塞了明文账号密码,这只能用于本地调试。正常做法是分两条线:业务侧登录拿一个业务 token,业务后端用这个 token 去换 IM 的accessToken,前端只负责拿accessToken调conn.open({ user, accessToken })。这样做的好处是 IM 的账号密码从头到尾没进过浏览器,泄露风险归零;坏处是业务后端多了一次服务端请求,登录链路长了半秒。

有一个坑得提前说:accessToken是有有效期的,过期后conn.open拿旧 token 会直接失败。我一般的做法是在config.js里加一个getToken()函数,每次open之前先去业务后端问一次“还有效吗,没有就刷一个”,刷完再连。多写十行代码,能省掉上线后“用户放一晚上第二天进来全是灰头像”的那类工单。

4. 核心模块拆解:好友列表、发消息、收消息三件事

LayIM 这个组件本身已经把交互做完了,你要写的其实就三块:把 IM 侧的好友/群数据灌进 LayIM 的列表、把 LayIM 抛出来的消息体翻译成 SDK 的格式发出去、把 SDK 收回来的消息翻译成 LayIM 的格式塞回窗口。三块都写通了,一个能用的 PC 端网页聊天就成立了。

4.1 好友与群列表:roster 到 addList 的字段映射

IM 侧的好友关系叫 roster,拉下来是一个数组,每项带name(就是对方的 IM 用户名)、subscription(订阅关系)、groups(分组)。这里第一个要注意的是过滤:subscription有none / to / from / both四种,只有both才是互相同意的好友,其余的是单向关注或者待确认,全灌进列表里会看到一堆莫名其妙的人。

conn.getRoster({ success: function (roster) { // 只取双向好友,单向的塞进去会显示一堆陌生人 const friends = roster .filter(item => item.subscription === 'both') .map(item => ({ id: item.name, // 直接用 IM 用户名当 LayIM 的 id username: item.name, // 有昵称的话从自己的业务库里补 avatar: item.avatar || '/static/avatar/default.png', sign: item.sign || '', status: item.status === 'online' ? 'online' : 'offline' })); // 塞进 LayIM 的默认分组 layim.addList({ type: 'friend', content: friends }); }, error: function (e) { // 九成是没登录或者 token 过期,别在这儿死循环重试 console.error('拉好友列表失败', e); } });

id用item.name是有讲究的。后面发消息的时候,sendMessage回调里的to.id会原样变成 SDK 的to参数,IM 侧只认自己的用户名,如果你在 LayIM 里用了数据库自增主键,那就得在适配层维护一张主键 → 用户名的映射表,多一层转换多一个出错点。直接用 IM 用户名当 id,两边天然对齐。

群列表同理,conn.getGroup(或者按版本叫listRooms)拿到群数组之后,映射成{groupname, id, avatar}塞进layim.addList({type: 'group', content: [...]})。这里有个容易漏的点:群的id是群组 id 字符串,不是群主 id,写错了后面群聊消息会全部发到群主私聊里去,症状是“我明明在群里说话,怎么只有群主收到了”。

4.2 发消息:sendMessage 里组装消息体,顺手把失败兜住

layim.on('sendMessage')是 LayIM 唯一往外抛消息的出口,回调参数里mine是自己、to是对方或群、type是friend或group、content是文本内容。适配层要做的事,就是把这四个东西翻译成 SDK 的消息对象。

layim.on('sendMessage', function (res) { const mine = res.mine; // {id, username, avatar} const to = res.to; // 好友:{id, name, avatar};群:{id, name, avatar} const isGroup = res.type === 'group'; const id = conn.getUniqueId(); // SDK 生成的唯一 id,用来关联成功/失败 const msg = new WebIM.message('txt', id); msg.set({ msg: res.content, to: to.id, // 注意大小写:发送时是驼峰 singleChat / groupChat chatType: isGroup ? 'groupChat' : 'singleChat', // 群聊里 SDK 只给群 id,发送者信息必须自己塞进 ext 带过去 ext: { sendId: mine.id, sendName: mine.username, sendAvatar: mine.avatar }, success: function () { // 拿到回执,可以在这里把气泡上的"发送中"小圈去掉 }, fail: function (e) { // 失败不要静默,用 LayIM 自己的消息接口补一条红色提示 layim.getMessage({ id: to.id, type: res.type, fromid: mine.id, mine: true, username: to.name, avatar: to.avatar, content: '<span style="color:#f00">发送失败,请检查网络后重试</span>' }); } }); conn.send(msg.body); });

几个参数值得掰开讲。chatType发送时是驼峰写法singleChat/groupChat,但接收的时候 SDK 给你的msg.type是全小写的chat/groupchat,两边大小写不一致,这是最经典的翻车点之一,建议在适配层顶部直接定义常量表,别到处手写字符串。ext这个字段是唯一的自由扩展位,群聊里发送者的昵称和头像全靠它带过去,不带的话对方的群消息只能显示一个用户名 ID,界面上就是一堆字母数字。

还有一个隐藏问题:conn.getUniqueId()每次调用都返回一个新 id,如果你在重试逻辑里重新调了一次,同一个逻辑消息就会有两条不同的 id,去重直接失效。正确做法是把 id 生成提到重试逻辑外面,一条消息一个 id,重试复用。

4.3 收消息:onTextMessage 回填到 getMessage,注意气泡方向

收消息是三个模块里最容易写错方向的。layim.getMessage()靠fromid判断这条消息是自己发的还是别人发的,等于当前登录用户就渲染在右侧,否则渲染在左侧。判断逻辑一旦写反,整个聊天窗口左右颠倒。

onTextMessage: function (msg) { const isGroup = msg.type === 'groupchat'; // 接收时全小写 const ext = msg.ext || {}; const myId = layim.cache().mine.id; // 当前登录用户 // 群聊:from 是群 id,发送者藏在 ext 里 // 单聊:from 就是对方 id const chatId = msg.from; const senderId = isGroup ? (ext.sendId || msg.from) : msg.from; layim.getMessage({ id: chatId, // 必须和列表里的 id 完全一致 type: isGroup ? 'group' : 'friend', fromid: senderId, // 决定气泡在左还是在右 mine: senderId === myId, username: ext.sendName || senderId, avatar: ext.sendAvatar || '', content: msg.data, timestamp: msg.time || Date.now() }); }

id这个字段是回填里最关键的。它必须和 LayIM 列表里那一项的 id 完全一致,否则 LayIM 会认为这是一条来自陌生人的消息,要么不显示,要么新建一个空窗口。群聊的时候id是群 id,fromid是发送者 id,这两个别搞反;单聊的时候两个是同一个值,反而不容易出错,所以很多人是在单聊测通了、上线才发现群聊全乱。

4.4 在线状态与未读红点

onPresence是个大杂烩,好友申请、好友上下线、群成员变更、被踢出群,全都从这个回调进来,靠msg.type和msg.status分流。

onPresence: function (msg) { if (msg.type === 'subscribe') { // 别人申请加我好友,demo 里直接同意,生产环境要弹确认框 conn.subscribe({ to: msg.from }); return; } if (msg.type === 'subscribed') { // 我加别人,对方通过了,补一条到列表 return; } // 上下线:status 只有 online / offline / away / busy 几种 if (msg.status === 'online' || msg.status === 'offline') { layim.setFriendStatus(msg.from, msg.status); } }

未读红点用layim.msgbox(num),num是所有未读的总数。这里我一般不在前端自己数,而是每收到一条消息就调一次业务后端的未读接口拿总数,前端只负责显示。原因很简单:用户刷新页面、切设备、看历史记录,本地的计数一定会偏,最后变成一个永远消不掉的红点,用户会以为有消息没看。

5. 避坑与排查:五个把我卡住半天的问题

这套东西跑起来不难,难的是出问题的时候现象很安静——不报错、不白屏,就是“不对”。下面五条是我自己在调试过程中真实卡过的,按现象、原因、解决三段写,遇到类似的可以直接对号入座。

5.1 自己发的消息出现两次

现象:发一条“在吗”,聊天窗口里立刻冒出两条一模一样的,刷新页面后又只剩一条。

原因:发送时做了乐观更新(先手动插一条本地气泡),SDK 投递成功之后服务端又回推了一条onTextMessage,适配层没判断发送者是不是自己,于是又插了一条。刷新页面走的是历史记录接口,所以只剩一条,看起来像“前端重复渲染”。

解决:两条路选一条。要么不做乐观更新,等 SDK 的success回执再插;要么在onTextMessage里加判断,ext.sendId === myId直接return。我更推荐后者加一个clientId去重表,本地插入时记录clientId,回推时比对,命中就丢弃,这样既不牺牲手感也不会重复。

5.2 好友列表一片空白,控制台干干净净

现象:LayIM 的列表区域什么都没有,Network 里init接口 200,返回体看着也有数据。

原因:init返回的结构和 LayIM 要求的不一致。最常见的是friend数组里少了groupname或者online字段,再就是list里的status写成了1/0。这两种情况 LayIM 都不会抛错,它只是安静地不渲染。

解决:拿文档里的示例 JSON 一条一条比对,id必须是字符串、status只能是online/offline、每个分组必须有groupname。比对完还不行,就在init.url指向的接口里直接返回一份写死的假数据,先确认 LayIM 本身没问题,再一层层往下查。

5.3 群消息发出去,对方收不到

现象:单聊完全正常,群里发消息自己的窗口能看到,别人一点反应没有。

原因:三种可能。一是chatType写成了'singleChat',消息被当成私聊发给了群 id,服务端直接丢弃;二是to传成了群主 id 而不是群 id;三是接收侧判断写成了msg.type === 'groupChat',而 SDK 给的是全小写'groupchat',判断永远为假。

解决:把singleChat/groupChat(发送)和chat/groupchat(接收)做成一张常量映射表放在适配层顶部,全项目只从这里取,别手写。再在send之前打一行console.log(chatType, to),一发就能看出来是哪个错了。

5.4 断线重连后界面停在旧状态

现象:笔记本合盖再打开,消息能发出去,但好友头像还是灰的,未读数也不刷新,必须手动刷页面。

原因:SDK 的自动重连只负责把连接接回来,不会重新触发业务侧的初始化。onOpened里的拉列表逻辑只跑过一次,重连之后没有人再去调它,UI 就永远停在断线前的那一刻。

解决:把“拉 roster、拉群、同步未读、重置在线状态”抽成一个syncState()函数,onOpened和onReconnect都调它。再补一个window.addEventListener('online', syncState),覆盖网络切换的场景。这个函数要写成幂等的,重复调不产生副作用。

5.5 图片消息在气泡里显示了,点开是 404

现象:上传回调返回成功,气泡里也出现了缩略图,但点开大图裂了,Network 里是一个奇怪的相对路径。

原因:LayIM 对uploadImage接口的返回格式要求很死,必须是{code: 0, msg: '', data: {src, name}}。随手返回{url: '...'}的话 LayIM 拿不到src,会拿一个空字符串去拼路径,拼出来的东西自然打不开。

解决:严格按格式返回,src给绝对地址或者能被当前域名解析的路径。顺带一提,uploadFile的格式完全一样,两个接口可以共用一个响应封装函数。

6. 二次开发:把 demo 推到能上线的位置

demo 和线上系统之间差的不只是几行配置,主要是四块东西。第一块是鉴权,前面说过,密码不能进浏览器,得走业务后端换accessToken。第二块是好友关系,demo 直接读 IM 的 roster,但真实业务里好友关系往往存在自己的库里(谁加了谁、什么时候加的、备注是什么),IM 的 roster 只当消息通道用,列表数据从自己的接口出。第三块是消息存储,demo 完全没存,刷新就没了,线上至少要有历史消息接口和本地缓存。第四块是上传,demo 里是本地直传,线上要走对象存储加签名。

环节demo 做法上线要改
鉴权页面里写死账号密码业务后端换 accessToken,前端只拿 token
好友关系直接读 IM roster业务库为准,roster 只做通道
消息存储无落库 + 历史消息分页 + 本地缓存
上传本地直传对象存储 + 签名 URL
断线只重连不补偿重连后拉增量消息和未读

上传接口这块最容易被忽略,因为 LayIM 对它返回格式的要求太死,稍微偏一点就静

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

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

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

立即咨询