
简介面向网页即时通讯入门与前端联调场景的 LayIM 与环信 PC 端 WebIM 聊天示例适合具备一定前端基础、希望快速搭建聊天界面的开发者参考。示例实现文字与表情发送、联系人列表、会话窗口等基础交互联系人等数据为本地假数据替换成自己的接口即可环信部分需换成个人 Key且接口中须以 id 作为用户名 key。压缩包共 190 个文件以 33 个 js 脚本、14 个 css 样式、6 个 html 页面为主另含 gif、png、jpg 等表情与图标素材及字体文件整体约 1.7MB。其中页面负责界面骨架脚本处理会话与消息逻辑样式和图标素材支撑聊天窗口与表情面板结构轻量便于查阅。已有 1530 人学习可据此观察 LayIM 页面组织、消息收发流程与环信初始化方式并在此基础上替换联系人接口、补全图片和文件发送能力作为课程设计、内部工具原型或前端 IM 入门的起步代码。1. 一个 PC 端网页即时聊天 Demo 的拆法layim 皮肤 环信长连接后台管理台做到一半产品经理过来说“顺手加个内部聊天吧”。这时候从零写 WebSocket 服务、消息表、在线状态、群组关系工期直接翻倍。这个 demo 走的是另一条路界面用 layui 体系里的 layim 模块会话列表、聊天气泡、表情、消息盒子都是现成的连接、投递、离线、多端同步这些麻烦事交给环信 WebIM SDK。前端要做的事被压缩成一件——把两边的数据模型翻译对。适合谁手上有环信 appKey、想在一天内把 PC 端网页即时聊天跑起来的前端以及已经跑通 demo准备往真实业务里塞消息类型、未读红点、历史记录的人。下面按目录、链路、坑位、改造顺序拆。2. 工程结构与依赖适配层、状态层、视图层怎么摆2.1 目录结构与 SDK 引入方式这个 demo 的目录不复杂关键是别把所有东西塞进一个chat.js。我见过太多版本是一个文件两千行改个气泡样式要滚半天。合理的切法是这样webim-pc/ ├── index.html # 主入口layui 与 WebIM SDK 都在这里引 ├── lib/ │ ├── layui/ # layui 全量包layim 在 layui/modules/layim.js │ └── easemob/ # 环信 WebIM SDKsdk 与 strophe 两个文件 ├── config.js # 唯一配置出口appKey、网关、业务接口地址 ├── adapter/ │ ├── conn.js # 连接对象、监听注册、重连 │ ├── receive.js # SDK 消息 - layim 消息体 │ └── send.js # layim 发送事件 - SDK 报文 ├── store/ │ ├── roster.js # 好友、群组、会话缓存 │ └── unread.js # 未读计数 └── view/ ├── panel.js # layim.config 的 init 数据装配 └── msgbox.html # 消息盒子页面index.html里引 SDK 的顺序不能反。环信 WebIM 依赖底层长连接库必须先引底层再引 SDK 本体否则控制台会甩一个Strophe is not defined新手容易在这卡十分钟。!-- 顺序底层长连接库 - WebIM SDK - layui - 业务代码 -- script src./lib/easemob/strophe.js/script script src./lib/easemob/websdk.js/script script src./lib/layui/layui.js/script script src./config.js/script script src./adapter/conn.js/script这三行脚本的顺序就是最小可运行骨架。websdk.js暴露全局WebIMlayui 暴露全局layui两者互不感知中间靠adapter/里那几个文件粘起来。把config.js单独抽出来是有私心的appKey、网关地址、业务接口前缀这些东西测试环境和生产环境不一样散落在业务代码里上线前改漏一处就是线上连不上。2.2 三层拆法为什么不让 layim 直接调 SDKlayim 提供的是 UI 层 API核心就四个layim.config配面板、layim.getMessage往聊天窗口塞一条消息、layim.on(sendMessage)接用户发出的消息、layim.cache()拿当前缓存。环信 SDK 提供的是通道层 APIconn.open登录、conn.listen注册回调、conn.send发包。这两套 API 的字段名没有一个对得上。如果直接在layim.on(sendMessage)里写conn.send在onTextMessage里直接调layim.getMessage短时间能跑后面就难受了。加个“消息撤回”要改两处加个“已读回执”要改三处因为转换逻辑散在回调里。抽一个适配层之后所有字段映射集中在一个函数里改协议只动一个文件。适配层的职责边界要划清楚它只做“形状转换”和“状态标记”不碰 DOM不碰 layui 的弹层。视图层只负责把适配层产出的标准结构喂给 layim。状态层store/管的是“会话列表当前有哪些人、各有多少未读”跟 SDK 无关纯内存对象需要的话落 localStorage。这三层之间的调用方向是单向的SDK 回调 → 适配层 → 状态层 → 视图层反向只有“用户点击发送”这一条。提示适配层的函数一律写成纯函数输入 SDK 消息对象、输出 layim 消息体中间不读全局变量。这样写单元测试最省事也方便你在控制台手动构造一条消息验证渲染。2.3 初始化顺序与配置项初始化的坑集中在“谁先谁后”。正确顺序是先赋值 SDK 配置、创建连接对象再layui.use拿 layim 配置面板最后等onOpened回调触发后才去拉好友列表。很多 demo 图省事页面onload就发请求拿好友数据结果面板出来了但头像全空因为那时候连接还没建立后端拿不到你的在线身份。// config.js —— 全项目唯一配置出口 window.IM_CONFIG { sdk: { appKey: orgName#appName, // 格式固定组织名#应用名中间是井号 url: https://im-api-vip6-v2.easemob.com/ws, // 长连接网关 apiUrl: https://a1-vip6.easemob.com, // REST 网关登录鉴权走这个 heartBeatWait: 30000, // 心跳间隔毫秒 autoReconnectNumMax: 10 // 自动重连上限 }, api: { buddyList: /api/im/friends, // 返回 layim 好友分组结构 groupList: /api/im/groups, // 返回群组列表 history: /api/im/history, // 历史消息 upload: /api/upload // 图片、文件上传 } };appKey的格式是最容易写错的一项必须是组织名#应用名不是后台看到的 API Key也不是 Secret。url和apiUrl是两个完全不同的网关前者走长连接收发消息后者走 HTTP 做登录鉴权和好友关系查询混填的结果是登录能过但消息发不出去或者反过来。heartBeatWait别调太小30 秒是常见值调到 5 秒在某些网络环境下会被服务端判定为异常连接直接踢下线。// adapter/conn.js —— 创建连接对象全局只创建一次 layui.use([layer], function () { var cfg window.IM_CONFIG.sdk; WebIM.config { xmppURL: cfg.url, apiURL: cfg.apiUrl, appKey: cfg.appKey, heartBeatWait: cfg.heartBeatWait, autoReconnectNumMax: cfg.autoReconnectNumMax, isAutoLogin: false // 手动登录等业务侧拿到账号密码再连 }; var conn new WebIM.connection({ url: WebIM.config.xmppURL, apiUrl: WebIM.config.apiURL, appKey: WebIM.config.appKey, https: WebIM.config.https, isAutoLogin: WebIM.config.isAutoLogin, heartBeatWait: WebIM.config.heartBeatWait, autoReconnectNumMax: WebIM.config.autoReconnectNumMax, autoReconnectInterval: 3000 // 重连间隔单位毫秒 }); // 挂到全局业务侧只认 window.IM 这一个入口 window.IM { conn: conn, ready: false, myId: }; });这里new WebIM.connection的参数和WebIM.config有大量重复不是冗余是 SDK 的历史设计WebIM.config是给内部工具方法读的全局单例connection构造参数才是这个连接实例真正生效的配置。两个都写一遍最保险少写一边偶尔会出“配置改了但没生效”的玄学问题。isAutoLogin我一般设false因为真实业务里账号密码往往是登录业务系统之后才拿到的让 SDK 自己去读本地缓存先连一把反而会跟业务登录态打架。autoReconnectInterval给 3 秒别给 0给 0 就是断线瞬间疯狂重连服务端会直接把你 IP 限流。3. 核心对接一次收发消息在 layim 与 SDK 之间的完整链路3.1 主面板数据装配layim.config 的 init 与数据格式layim 的主面板不自己存数据它要你给一个接口地址自己去请求然后按固定结构渲染。这个结构必须严格对齐字段少一个就会导致分组出不来或者头像不显示。// view/panel.js layim.config({ init: { url: window.IM_CONFIG.api.buddyList, // GET返回 {code:0, msg:, data:{mine, friend, group}} type: get, data: {} // 额外参数比如业务 token }, uploadImage: { url: window.IM_CONFIG.api.upload, type: post }, uploadFile: { url: window.IM_CONFIG.api.upload, type: post }, brief: true, // 面板收起时只显示头像 min: false, // 是否默认最小化 isAudio: true, // 是否显示语音按钮 isVideo: false, // PC 端一般关掉视频 right: 30px, // 主面板距离右边的距离 msgbox: ./view/msgbox.html, // 消息盒子页面地址 chatLog: ./view/chatlog.html, // 历史记录页面地址 find: ./view/find.html // 查找页面地址 });init.url返回的data里必须有三块mine当前登录人的 id、username、avatar、sign、friend数组每项含groupname和listlist里每项含id、username、avatar、sign、group数组每项含id、groupname、avatar。这三个结构直接来自后端拼装前端不要在init里做二次加工因为 layim 请求回来就立刻渲染没有钩子给你改。uploadImage和uploadFile的返回结构是有硬约定的必须是{code:0,msg:,data:{src:https://...}}code非 0 layim 就当失败处理。这条约定不满足图片气泡就是破图而且控制台不报错属于典型的黑匣子问题。3.2 登录链路conn.open 与 onOpened 之后才有数据登录这一步的逻辑顺序是死的conn.open发出去onOpened回调里才能做业务请求。中间这段时间面板可以显示骨架屏但绝不能提前拉数据。// adapter/conn.js 续 function imLogin(imUser, imPwd) { // imUser / imPwd 由自家后端用 appKey 换发前端不接触 appSecret IM.conn.open({ user: imUser, // 环信用户名通常和业务用户 id 做一张映射表 pwd: imPwd, appKey: window.IM_CONFIG.sdk.appKey }); } IM.conn.listen({ onOpened: function () { // 连接真正建立这时候身份才可用 IM.ready true; IM.myId IM.conn.user; // SDK 回填的当前用户 id loadRoster(); // 拉好友与群组 }, onClosed: function () { IM.ready false; layui.layer.msg(连接已断开正在尝试重连); }, onError: function (e) { // e.type 常见值201 未登录、206 密码错误、214 重连超限 console.error([IM] error, e.type, e); } });onOpened里只做三件事置位ready、记录myId、拉列表。myId一定要从conn.user取不要用业务系统里的用户 id这两个不是一回事——环信账号是独立体系映射关系在后端。onError里的type是排查的主要线索206基本就是密码错了201说明连接建立但登录态没带上214是重连次数超限这时候要引导用户刷新页面而不是干等。3.3 收消息conn.listen 回调转 layim.getMessage收消息是适配层的主要工作量。SDK 回调按消息类型分开注册每种类型的消息体结构还不一样文本消息内容在msg.data图片消息在msg.url语音在msg.url且带msg.filename。layim 这边只认一个结构所以先写一个统一转换函数。// adapter/receive.js function toLayimMsg(msg, chatType) { var isGroup chatType groupchat; var mine msg.from IM.myId; return { id: isGroup ? msg.to : msg.from, // 会话 id群聊用群 id单聊用对方 id username: isGroup ? msg.to : msg.from, // 会话标题真实项目里应换成备注名/群名 avatar: (msg.ext msg.ext.avatar) || ./img/default.png, type: isGroup ? group : friend, content: msg.data, cid: mine ? (msg.id || ) : , // 非 0 表示这条是自己多端同步过来的 mine: mine, fromid: msg.from, timestamp: msg.time || Date.now() }; } IM.conn.listen({ onTextMessage: function (msg) { // 自己发出的消息不再渲染避免本地已经画过一次导致重复 if (msg.from IM.myId) { return; } layim.getMessage(toLayimMsg(msg, msg.ext msg.ext.chatType)); }, onPictureMessage: function (msg) { if (msg.from IM.myId) { return; } var data toLayimMsg(msg, msg.ext msg.ext.chatType); data.content msg.url; // 图片消息 layim 认 url不认 msg.data layim.getMessage(data); } });toLayimMsg里最关键的三个字段是id、type、mine。id决定这条消息落在哪个会话窗口里写错就会跑到别人的聊天窗口type只有friend和group两个合法值mine决定气泡在左边还是右边。cid是 layim 用来标记“同一条消息的多个副本”的字段多端同步场景下有用单端 demo 可以直接留空。注意timestamp必须是毫秒。SDK 有些字段给的是秒级时间戳直接用会让聊天记录显示成 1970 年这个坑踩一次记一辈子。3.4 发消息layim.on(sendMessage) 里做协议转换反方向简单一些因为 layim 给的结构是固定的。sendMessage回调参数里res.mine是发送者信息res.to是会话对象含id、type、usernameres.mine.content是文本内容。// adapter/send.js layim.on(sendMessage, function (res) { var mine res.mine, to res.to; var isGroup to.type group; var payload { type: txt, // 文本统一走 txt图片见第 4 章的做法 id: to.id, // 单聊对方 im id群聊群组 id msg: mine.content, from: mine.id, ext: { chatType: isGroup ? groupchat : chat, // 接收端靠它区分会话类型 avatar: mine.avatar, // 对方渲染头像用 nickname: mine.username } }; var sent IM.conn.send(payload); // 发送失败本地先画一条失败态别让用户以为发出去了 if (!sent || !sent.id) { layim.getMessage({ id: to.id, username: to.username, avatar: to.avatar, type: to.type, content: span classim-fail发送失败点击重试/span, mine: true, timestamp: Date.now() }); } });ext这个字段是环信消息体里留给业务自定义的扩展位服务端原样透传接收端能拿到。chatType放在ext里是有原因的SDK 本身不强制群聊消息带类型标记你只能靠 id 猜。把chatType、头像、昵称都塞进ext接收端就不用再查一次用户资料接口少一次请求少一次失败。conn.send的返回值是 SDK 在本地构造的消息对象id字段由 SDK 分配。这个返回值有两个用处一是判断发送是否成功没有id说明本地就没发出去二是拿它做“发送中 → 已送达”的状态更新。真实项目里我会维护一张待确认消息表收到服务端回执后再把状态改掉。4. 消息类型与 UI 细节文本、图片、表情、未读红点怎么落地4.1 类型映射表与统一转换函数PC 端网页聊天实际会碰到的消息类型就那么几种但每个类型在 SDK 和 layim 两边的字段位置都不一样。先把对应关系列清楚后面加类型就是填表。业务类型SDK 回调内容字段layim 渲染方式备注文本onTextMessagemsg.datacontent直接塞支持表情字符图片onPictureMessagemsg.urlcontent塞 url需配uploadImage语音onAudioMessagemsg.url自定义模板PC 端建议关掉文件onFileMessagemsg.urlmsg.filename自定义模板需要文件服务自定义onCmdMessagemsg.actionmsg.ext自定义模板撤回、已读都用它表格里最后一行值得多说一句。环信的cmd消息不落历史、不进会话专门用来做信令消息撤回、正在输入、已读回执这些“不是内容的内容”都走它。用普通文本消息模拟撤回也能做但会在对方历史记录里留一条脏数据后期清理很麻烦。// 统一入口所有消息类型先转换再分发 var MSG_TPL { txt: function (m) { return m.data; }, img: function (m) { return img classim-img src m.url /; }, file: function (m) { return a href m.url download m.filename /a; } }; function dispatch(msg, chatType, kind) { var tpl MSG_TPL[kind] || MSG_TPL.txt; var data toLayimMsg(msg, chatType); data.content tpl(msg); layim.getMessage(data); }这个分发表的好处是加类型只改一个对象。kind从哪来从ext.msgType里取发送端写什么接收端读什么。默认落回txt保证未知类型不会白屏。4.2 图片与文件上传走谁这里有个常见误区以为图片消息必须调 SDK 的图片发送接口。实际上 SDK 的图片接口要求传本地File对象由 SDK 完成上传上传进度、压缩、失败重试全在 SDK 内部出问题很难查。并且上传域名和自家业务域名往往不同跨域配置又是一轮折腾。我一般走另一条路图片先用 layim 自带的uploadImage传到自家文件服务拿到 url然后用文本消息 ext.msgType img发出去接收端按 url 渲染img气泡。// 发送端劫持图片上传走自家服务 layim.on(uploadImage, function (res) { // layim 已经把文件 POST 到 config 里配的 uploadImage.url // 这里拿到的 res 是上传结果结构必须是 {code:0, data:{src:...}} var url res.data.src; IM.conn.send({ type: txt, id: currentChat.id, // 当前会话 id from: IM.myId, msg: url, ext: { msgType: img, chatType: currentChat.type, avatar: myAvatar } }); });这么做的代价是图片不走 SDK 的存储历史记录里的图片地址依赖自家文件服务是否长期可用。收益是上传逻辑可控、能接自家的鉴权和防盗链、出错时日志在你手上。中小型项目我倾向前者至少排障的时候不用两头猜。uploadImage返回结构那条约定再说一遍code必须是数字0data.src必须是完整可访问地址。有次后端同事把code返回成字符串0layim 判断严格等于失败图片全挂查了两小时。4.3 会话未读与红点layim 社区版对未读红点的支持有限只有两个 API 能用layim.msgbox(n)控制左下角消息盒子的数字layim.setChatStatus(html)给当前打开的聊天窗口加一个状态条。会话列表每一项右上角的小红点原生没有。// store/unread.js —— 自己维护未读计数 var unread {}; // { friend_1001: 3, group_2002: 12 } function increase(sessionKey) { unread[sessionKey] (unread[sessionKey] || 0) 1; layim.msgbox(total()); // 消息盒子总数 renderBadge(sessionKey, unread[sessionKey]); // 自己画红点 } function clear(sessionKey) { delete unread[sessionKey]; layim.msgbox(total()); renderBadge(sessionKey, 0); } function total() { return Object.keys(unread).reduce(function (s, k) { return s unread[k]; }, 0); }renderBadge的实现就是查 DOMlayim 会话列表每项有固定的 class 结构找到对应>// 打开会话时拉最近 20 条 function loadHistory(sessionId, type, page) { layui.$.get(window.IM_CONFIG.api.history, { target: sessionId, chatType: type, page: page || 1, size: 20 }, function (res) { // 返回结构自己定前端转成 layim 消息体后逐条 getMessage 即可 (res.data.list || []).reverse().forEach(function (item) { layim.getMessage({ id: sessionId, username: item.fromName, avatar: item.avatar, type: type, content: item.content, mine: item.from IM.myId, timestamp: item.createdAt }); }); }); }reverse()那一步不能少。后端一般按时间倒序返回最新在前layim 是按调用顺序从上往下追加的不反转的话聊天记录顺序是反的。这个 bug 表现得很直观——你一眼就能看出来但第一反应往往是怀疑后端排序错了。5. 避坑与排查连接、消息、渲染三类高频翻车现场5.1 连接类连不上、连上又掉、掉了不回来现象页面打开后主面板一直空白控制台没有明显报错。原因在conn.open之后立刻发起好友列表请求此时长连接还在握手后端拿不到你的在线身份返回空列表。 解决所有业务请求挂到onOpened里面板先渲染骨架。同时给conn.open加一个超时兜底8 秒还没onOpened就提示“连接超时请检查网络”。现象登录成功过一两分钟就掉线onClosed反复触发。原因heartBeatWait设得太小或者网络中间层对长连接有空闲超时。 解决把心跳调回 30 秒这个常见值同时检查autoReconnectInterval不要设成 0。如果是公司网络有代理设备需要在 SDK 配置里把连接方式调整为走标准端口。现象onError里type是 206。原因用户名或密码不对。别急着怀疑 SDK先确认后端下发的这个账号是不是已经在环信侧创建过。环信账号不是注册即有需要服务端调接口创建很多 demo 跑不通就卡在这一步。 解决让后端在登录接口里加一步“账号不存在则创建”的兜底逻辑。5.2 消息类发出去重复、群聊串会话、ext 丢了现象自己发的消息在聊天窗口显示两遍。原因layim.on(sendMessage)已经把消息画在本地了SDK 又把这条消息通过onTextMessage回调推回来一次多端同步机制。 解决接收回调里判断msg.from IM.myId直接 return。如果确实需要多端同步比如另一个浏览器发的消息要出现那就改成判断消息来源设备只丢弃当前端发出的那条。现象群聊消息显示在某个单聊窗口里。原因转换函数里会话 id 一律用了msg.from。单聊时from是对方没问题群聊时from是群里的某个成员消息就串到那个人身上了。 解决群聊用msg.to群 id作为会话 id。判断依据靠ext.chatType所以发送端必须写这个字段。现象接收端拿不到头像和昵称气泡全是默认图。原因ext在某个环节被丢了。最常见的是服务端做消息转发时只透传了msg字段把ext过滤掉了。 解决让服务端原样透传ext前端在转换函数里兜底ext为空时去查一次本地缓存的好友资料。5.3 渲染类破图、时间错乱、样式失效现象图片上传成功但气泡里显示破图。原因uploadImage接口返回的 JSON 结构不是{code:0, data:{src:...}}code类型不对字符串0或者字段名叫url不叫src。 解决在浏览器开发者工具的 Network 面板里看这个请求的原始响应逐字段对比。这条最省事的排查方式就是看一眼原始响应别猜。现象聊天记录时间显示成 1970 年。原因timestamp传了秒级时间戳layim 按毫秒解析差了一千倍。 解决统一在转换函数里做一次判断小于 1e12 的一律乘 1000。现象自定义气泡样式不生效。原因layim 的消息内容支持 HTML但样式类被 layui 的全局样式覆盖了或者 CSS 加载顺序在 layim 之后被重置。 解决给自定义元素加独立前缀如im-样式表放在 layim 样式之后引入必要时用!important兜底。这种做法不优雅但在这个场景下最省时间。6. 二次开发从 demo 到能天天用的 PC 工作台跑通 demo 只是第一步真要在业务里用缺的是三样东西断线重连的状态收口、本地消息缓存、自定义消息类型。先说重连。SDK 自带autoReconnectNumMax但它只负责“连”不负责“连上之后恢复什么”。我一般维护一个三态模型状态触发要做的动作connecting调conn.open面板骨架、禁用输入框onlineonOpened重拉好友列表、补拉离线期间的消息、恢复心跳offlineonClosed/ 重连超限输入框置灰、提示、提供手动重连按钮状态收口的好处是所有 UI 只需要读一个变量不用到处判断IM.ready。onOpened里“补拉离线消息”这一步最容易被漏掉漏了的表现是网络闪断后中间那几十秒别人发的消息永远看不到。再说缓存。store/roster.js里的好友和群组数据我是这么处理的登录成功后立即请求一次并写入localStorage下次打开页面先用缓存渲染onOpened之后再异步刷新覆盖。这样首屏不会白用户感受完全不一样。代码很短function loadRoster() { var cached JSON.parse(localStorage.getItem(im_roster) || null); if (cached) { layim.cache().friend cached.friend; renderPanel(cached); } $.get(window.IM_CONFIG.api.buddyList, function (res) { localStorage.setItem(im_roster, JSON.stringify(res.data)); renderPanel(res.data); }); }最后是自定义消息类型这是二次开发里最有价值的部分。业务上经常要在聊天窗口里发卡片比如一条订单、一个工单、一段审批。做法就是在ext.msgType上再加一个值渲染时走自定义模板MSG_TPL.order function (m) { var d JSON.parse(m.data); return div classim-card p classim-card-title d.title /p p classim-card-desc d.desc /p a href d.link target_blank查看详情/a /div; };发送时把结构化数据JSON.stringify进msg接收端按msgType找模板。注意m.data一定要做try/catch历史消息里只要有一条格式不对的脏数据整个聊天窗口就会白屏。踩过的坑里印象最深的一次是群聊消息串窗口排查了一整个下午最后发现就是转换函数里一个msg.from和msg.to写反了。从那以后我每次做会话 id 映射都强制在控制台打一行日志把from、to、chatType、最终会话 id 四个值一起输出看着它跑一遍单聊和群聊再收工。这个习惯帮我省下的时间比任何调试技巧都多。希望帮到你。本文还有配套的精品资源点击获取