简介:一款基于layim前端框架与环信Web IM(pc端)的聊天功能演示工程,面向需要快速搭建网页即时通讯的前端开发者。资源以纯前端方式实现了文字、表情消息发送,并预留了图片与文件传输接口;联系人数据与环信key均需替换为读者自己的配置,便于从假数据平滑迁移到真实业务。压缩包共190个文件,大小约1.7MB,包含js逻辑脚本、css样式、多张gif与png图片素材、html页面以及字体与音频等辅助资源,便于按模块对照调试。目前已有1529人学习下载。案例中预设张三、李四两个可通讯账号,接口需以id作为用户名的key,测试时选择这两个用户即可体验完整收发流程;通过该demo可直观掌握layim界面组件与环信WebIM API的对接方式,理解用户名绑定、消息收发机制、接口替换思路等关键点,适合有前端基础、希望快速集成IM能力的开发者作为起点模板。
1. layim + 环信 WebIM:先把网页即时聊天的两个角色拆清楚
做一个 PC 端网页即时聊天窗口,卡人的通常不是 UI,而是消息链路。我接到这类需求的第一反应是劝退——自己搭 WebSocket 服务端,要把链接管理、心跳、离线消息、历史记录全写一遍,报价翻倍工期翻番。后来改用 layim 负责界面、环信 WebIM 负责消息链路,这个 demo 就是把两道工序粘在一起的前端实现。
这套资源解决的是“怎样用纯前端把一个能收发消息的聊天页跑起来”:layim 提供会话窗口和消息气泡,环信提供实时连接和消息推送,两端不重复造轮子。适合要给现有后台系统嵌聊天模块的前端,也适合接私活先出原型给客户看效果。前提是你不打算自建 IM 服务端。
2. 各管半场:layim 管界面展示,环信管消息链路,谁也别越权
拿到 demo 的人第一反应通常是“layim 不是自带聊天功能吗,为什么还要环信”。这个误会得先说透。layim 确实把聊天窗口长什么样做完了,但它只是“看起来像聊天软件”,消息怎么到达对方电脑、对方离线了怎么办、历史记录存在哪,这些它一概不管。环信 WebIM 补的正是这条链路。把这两个角色分清楚,后面的代码才不会写得四不像。
2.1 layim 的职责边界:会话面板、消息气泡、表情图片,点到为止
layim 是基于 layui 生态的一套聊天界面框架,打开之后自带会话列表、聊天面板、输入框、表情面板、图片和文件上传按钮。它解决的是“聊天页面该有的东西我全给你”这件事。初始化时通过layim.config传入当前用户、群组/好友列表,左侧会话列表和右侧聊天窗口就直接渲染出来。
但它没有传输能力。你在输入框打了一句话点发送,layim 能做的是把这个动作抛给你注册好的回调,告诉你“用户想发这条消息”,至于这条消息走 WebSocket 还是走 HTTP 轮询,它不关心。这就是很多人把 demo 当成完整聊天软件后翻车的地方——界面上能打字、能弹表情,其实消息根本没发出去,刷新页面一切归零。
2.2 环信 WebIM 的职责边界:实时连接、消息路由、离线暂存
环信 WebIM 是典型的云 IM 服务,前端接入它提供的 SDK 后,SDK 会帮你在浏览器里维持一条到消息服务器的长连接。它做的事情可以列成三件:一是消息的实时收发,A 用户发给 B 用户的消息,由服务器路由到 B 的连接上;二是离线消息暂存,B 不在线时消息先放在服务器,B 上线后再推下来;三是多端同步,同一账号在不同设备登录时,消息可以推送到多个端。
SDK 给前端暴露的是一组监听回调:连接打开、连接关闭、连接出错、收到消息。你要做的就是在这些回调里调用 layim 对应的方法,把消息“喂”给界面。注意一个原则:界面只负责表达,链路只负责搬运。环信不会帮你生成会话列表,也不会帮你算未读数,这些属于前端业务数据,SDK 把消息原样交给你,后续怎么展示是你的事。
2.3 选型账本:自研 WebSocket 的成本到底算不算得过来
聊到 IM,很多开发者第一反应是“WebSocket 我熟,自己写一个”。写一个能连上、能互发消息的 WebSocket 确实不难,难的是后面那一串:断线重连要不要做、消息要不要持久化、离线消息怎么补发、历史记录怎么分页、消息顺序怎么保证、在线状态怎么同步。这些在 demo 阶段可以装看不见,一旦要上线,每一项都是实打实的工作量。
我见过同事自己写了三天心跳逻辑,最后换回现成 SDK。原因不是写不出来,而是出了问题没人接盘。用环信这类云服务,连接管理、消息路由、离线存储等于外包出去了,前端只需要维护 UI 状态。对个人开发者来说尤其划算,省下的时间足够把两侧交互打磨得更细。下面是两个组件在 demo 里的分工,我习惯在动手前就把这张表贴在代码目录里:
| 职责 | layim | 环信 WebIM |
|---|---|---|
| 聊天界面渲染 | 负责 | 不负责 |
| 长连接维护 | 不负责 | 负责 |
| 消息收发 | 不负责 | 负责 |
| 会话列表渲染 | 负责 | 不负责 |
| 会话数据存储 | 不负责 | 不负责 |
| 离线消息暂存 | 不负责 | 负责 |
| 未读角标展示 | 负责 | 不负责 |
表格最后一行对应的未读数据其实要前端自己累计,layim 只负责把数字画出来。这个账算清楚之后,整个 demo 的代码结构就清晰了:一层是 layim 的配置和事件,一层是环信的回调,中间夹着一层纯前端的数据维护。
3. 双端握手:初始化、登录令牌与消息回调的三步映射
章 2 已经把职责拆开,这章进入代码层面。能跑通的聊天 demo 本质上只做三件事:把 layim 初始化好,把环信连接建立好,然后把两边的事件互相接上。整个过程里最容易写乱的是消息回调的映射关系,我在下面按顺序拆开讲。
3.1 引入与初始化:layui 模块化加载 layim,WebIM 挂在全局
先引入资源。layim 是 layui 生态的扩展组件,不单独暴露全局变量,必须走layui.use的模块化加载;环信 SDK 则是一个独立脚本,直接挂到全局对象上。两侧引入顺序无所谓,但初始化一定要等到页面 DOM 就绪之后再执行。
<link rel="stylesheet" href="/lib/layui/css/layui.css"> <script src="/lib/layui/layui.js"></script> <script src="/lib/webim/webim.min.js"></script>layim 的初始化入口是layui.use('layim', ...)。在回调里调用layim.config传参。elem指向聊天面板挂载的 DOM 容器;brief控制左侧会话列表是否显示最后一条消息摘要,内部工具建议设成true,节省列表高度;maxLength限制输入框最大字节数,默认值偏大,我一般压到 2000 以内,防止用户一次性粘贴大段文本把消息体撑爆。
layui.use('layim', function () { var layim = layui.layim; layim.config({ elem: '#app-chat', title: '项目组沟通', brief: true, maxLength: 2000, isgroup: false, init: { mine: { id: currentUserId, username: '我', avatar: 'https://example.com/me.png' }, data: [] }, chat: { send: function (res) { // 发送消息的动作会走到这里 } } }); });init.mine是当前登录用户在聊天系统里的身份,id最好和环信的用户名保持一致,后面判断“消息是否自己发的”会依赖这个字段。init.data是左侧会话列表的初始数据,demo 可以先给空数组,等收到第一条消息时再动态加会话。chat.send是整个发送逻辑的钩子,用户点了发送按钮,layim 把所有消息内容打包成res传进来,后续要做的就是把res翻译成环信的消息结构发出去。
3.2 登录态打通:环信 token 换 layim 在线状态
环信 WebIM 的连接对象通过new WebIM.connection()创建。appKey是环信控制台里创建应用后分配的字符串,格式固定为“组织名#应用名”,这个不能填错,填错会在连接阶段直接报 404。isMultiLoginSessions控制同一账号能否多点登录,内部工具场景我习惯设成false,保证同一账号同时只能在一处登录。
var currentUserId = 'u_10001'; var conn = new WebIM.connection({ appKey: 'your-org#your-app', isMultiLoginSessions: false, https: true }); conn.listen({ onOpened: function () { layim.setChatStatus('online'); }, onClosed: function () { layim.setChatStatus('offline'); }, onError: function (err) { console.error('环信连接错误', err); }, onTextMessage: function (message) { // 收到消息时驱动 layim 渲染,后面展开 } }); conn.open({ user: currentUserId, accessToken: accessToken, // 由后端调用环信 REST 接口换来的 token appKey: conn.appKey });conn.open的三个参数里,user必须和init.mine.id保持一致,否则后续没法正确过滤自己发的消息。accessToken的获取要注意一个安全点:不能在前端用账号密码直接调环信接口换 token,正确的做法是浏览器请求自己的后端,后端持环信后台的鉴权凭证去换 token,再下发给前端。conn.listen注册的连接回调就是前面说过的“链路事件”,这里我先把onOpened/onClosed映射到 layim 的setChatStatus,实现在线状态切换。onError一个容易踩的坑是:token 过期这类错误也走这里,但不一定断开连接,界面还显示在线,这个问题在章 5 里细说。
3.3 消息双向驱动:send 事件发给环信,onTextMessage 反哺 layim
发送消息的代码写在layim.config的chat.send回调里。用户输入的内容在res.mine.content,接收方信息在res.to里,res.to.type区分是单聊还是群聊。环信消息体里的toType命名和 layim 不一样,单聊是chat,群聊是chatgroup,这个映射关系写错一次就能看到 404 错误。
chat: { send: function (res) { var mine = res.mine; var to = res.to; var toType = 'chat'; if (to.type === 'group') { toType = 'chatgroup'; } conn.send({ to: to.id, toType: toType, type: 'chat', msg: mine.content, ext: { nickname: mine.username, avatar: mine.avatar, localMsgId: Date.now() } }); } }ext是环信消息体里的自定义扩展字段,可以把昵称、头像这类对方展示时需要的信息塞进去。这是个很容易被忽略的细节:如果只发to和msg,对端收到消息后没有任何渠道知道发送者的头像和昵称,只能显示一个光秃秃的 ID。localMsgId是我用来做本地去重的标记,后面讲到消息双条问题时会用到。
再来看接收方向。环信的onTextMessage回调里拿到的是标准消息对象,message.from是发送者 ID,message.msg是文本内容,message.ext是刚才塞进去的扩展字段。这里要把数据转换成 layim 的receiveMessage需要的结构,等于把环信的“链路消息”翻译成 layim 的“界面消息”。
onTextMessage: function (message) { // 自己发的消息不在这里处理,避免本地回显叠加 if (message.from === currentUserId) { return; } var ext = {}; try { ext = typeof message.ext === 'string' ? JSON.parse(message.ext) : (message.ext || {}); } catch (e) { ext = {}; } layim.receiveMessage({ type: 'friend', id: message.from, avatar: ext.avatar || '', username: ext.nickname || message.from, content: message.msg, timestamp: message.time }); }这段代码最值得注意的有两处。第一,message.ext在部分环信 SDK 版本里会被序列化成 JSON 字符串而不是对象,所以要做一次安全解析,解析失败就退回空对象,不能让头像解析错误把整条消息丢掉。第二,时间戳message.time老版本返回秒,新版本返回毫秒,后面统一换算之后再往界面上放,不然消息排序会乱。在这段逻辑里我只调用了receiveMessage,它负责把消息塞进聊天窗口,但左侧会话列表这时候还不会自动出现新人,这层逻辑放到章 4 单独处理。
4. 会话、未读与历史:环信不替你维护的前端数据层
章 3 跑通之后,你会发现自己能和对方聊天了,但左侧的会话列表还是死的。环信 SDK 推来的消息不会自动变成 layim 左侧列表里的一个会话,init.data只在页面首次渲染时生效。整个会话列表、未读数、历史消息的加载,都要前端自己做一套数据层来支撑。
4.1 会话列表的本地仓库与排序
我习惯维护一个独立于 layim 的会话仓库,结构是两个变量:一个map保存每个会话的完整信息,一个order数组保存按最后消息时间排序的会话 ID。环信每推来一条消息,就先更新这个仓库,再决定怎么刷新界面。
var sessionStore = { map: {}, order: [] }; function upsertSession(from, payload) { var now = payload.time || Date.now(); if (!sessionStore.map[from]) { sessionStore.map[from] = { id: from, name: payload.nickname || from, avatar: payload.avatar || '', lastMsg: payload.content, lastTime: now, unread: 0 }; sessionStore.order.unshift(from); } else { var old = sessionStore.map[from]; old.lastMsg = payload.content; old.lastTime = now; old.name = payload.nickname || old.name; old.avatar = payload.avatar || old.avatar; } sessionStore.order.sort(function (a, b) { return sessionStore.map[b].lastTime - sessionStore.map[a].lastTime; }); }upsertSession是新增和更新合一的方法。新增时先给unread初始化为 0,更新时不重置未读,未读累计由另一段逻辑负责。order数组每次消息进来都重排一次,保证最后聊天的会话排在最上面。排序依据是lastTime,所以时间戳标准化特别重要,秒和毫秒混用会让排序彻底乱掉。这套仓库数据之后既要供给 layim 列表渲染,也要在刷新页面时持久化到 localStorage。
4.2 未读数的累计、清零与跨标签页
未读数是消息进来之后最容易做错的一块。核心逻辑一句话:正在跟这个人聊天时不加未读,不是当前会话就加 1,打开会话时清零。layim 提供updateUnread方法把数字画到会话列表上,但数字本身必须前端自己维护。
function onMessageCome(message) { if (currentChatId === message.from) { return; } var session = sessionStore.map[message.from]; if (session) { session.unread = (session.unread || 0) + 1; layim.updateUnread(session.unread, message.from); } }打开聊天窗口时同步把未读清零。这里要调用 layim 打开会话的方法,在回调里把currentChatId更新成当前会话 ID,然后调用layim.updateUnread(0, id)。常见错误是只加了未读累计、忘了清零,结果是数字越叠越大,怎么点也消不掉。
如果系统允许用户开多个浏览器标签页,未读数还要处理跨标签页同步。简单方案是把sessionStore定期序列化到 localStorage,借助storage事件在标签页之间同步会话数据。这个事件的触发条件是 localStorage 发生变化,浏览器内部自动处理,不需要额外建连接。
4.3 历史消息拉取与时间线拼接
聊天窗口通常不能只显示登录之后的实时消息,还要能翻到更早的记录。环信提供了拉取历史消息的接口,不同版本的 SDK 方法名有差异,新版一般是fetchHistoryMessages,老版本可能是getHistoryMessages,写之前先在 SDK 文件里搜一下方法名确认参数格式。
function loadHistory(userId, beforeTime, pageSize) { conn.fetchHistoryMessages({ queue: userId, limit: pageSize || 20, end: beforeTime || '', success: function (res) { var entities = res.entities || []; entities.forEach(function (item) { appendMessageToPanel(item); }); }, error: function (err) { console.error('拉取历史失败', err); } }); }fetchHistoryMessages拉回来的消息数组,时间排序在不同版本里也可能不一样,有的从旧到新,有的从新到旧。稳妥的做法是拉回来之后先按item.time做一次升序排序再往聊天面板里追加,别直接依赖接口返回顺序。追加之前同样要先过滤自己发的消息,历史记录里自己的消息渲染右侧,别人的渲染左侧。这里还有一个接口设计上的注意点:end参数传上一页最后一条消息的时间戳实现翻页,demo 里可以先固定拉最近 20 条,翻页逻辑等有需要再加。
5. 避坑清单:连接断开、消息双条、头像错位与 token 失效的五个现场
这章列的是我实际跑这个 demo 时踩过的几个具体问题,每一条都按现象、原因、解决三步写清楚。
5.1 连接静默断开,界面还挂在“在线”
现象:页面开着不动一段时间,自己发消息对方收不到,对方发的消息自己也不推送,但 layim 左上角头像一直显示“在线”。原因:WebSocket 长连接在空闲一段时间后会被网络层断开,环信的onClosed回调被触发,但 layim 的在线状态没有同步更新,界面停留在虚假的“在线”上。解决:在conn.listen里把onClosed和onError都挂上状态切换,onClosed时调用layim.setChatStatus('offline'),并在 UI 上给出重连状态。另外建议加一个应用层面的保活机制,比如每 30 秒由前端主动发一条心跳消息,让连接保持活跃。
5.2 自己发的消息被回调推回本地,聊天框出现双条
现象:发一句话出去,自己的聊天窗口里立刻出现两条一模一样的内容,对方那边只收到一条。原因:环信 WebIM 的连接回调在某些版本里会把“自己发给对方”的消息也推到本地监听里,如果发送时做了本地回显,又没在onTextMessage里过滤来源,就会出现双条。解决:在onTextMessage入口处判断message.from === currentUserId,是则直接 return。本地回显的职责完全交给chat.send回调,不要依赖推到本地的服务端回执;为了更保险,用发送时塞进ext.localMsgId的字段做一次消息去重,重复到的直接丢弃。
5.3 头像错位:ext 字段没解析或没传
现象:自己和对方聊天时,双方显示的头像都是当前登录人的头像,或者全是默认灰头像。原因:头像信息是通过消息体里的ext字段传递的,如果发送方没把avatar和nickname塞进ext,接收方就无从知道对方长什么样。另一个常见坑是ext被 SDK 序列化成字符串,接收方没有做JSON.parse,导致ext.avatar读出来是undefined。解决:发送时固定把avatar和nickname写进ext;接收时统一走一次安全解析,解析失败就回退成空字符串,宁可显示默认头像,也不能让异常把整条消息处理流程打断。
5.4 token 过期后所有消息发送失败
现象:页面放了一夜,第二天还能打开,连接也显示在线,但点发送一直报错,控制台打出 401 或 400 的鉴权错误。原因:环信连接成功后使用的 accessToken 有有效期,过期后链路虽然还没断,但服务端开始拒绝所有消息请求。解决:前端不要只存一个 token 用到底。规范做法是后端下发 token 时带上有效期,前端在过期前主动向后端换取新 token,然后重新调用conn.open。同时在onError回调里识别 token 失效的错误码,触发一次自动重登,让用户无感续期。
5.5 刷新页面后会话列表全空
现象:F5 刷新后,左侧会话列表空空如也,未读数和历史会话全部消失。原因:sessionStore是纯内存数据,刷新页面即销毁,环信 SDK 本身不维护会话列表的持久化。解决:把sessionStore在每次变更后序列化写入 localStorage,刷新页面后先读取 localStorage 恢复map和order,再发起环信连接。注意恢复时要连unread和lastTime一起存,否则未读数和会话排序都跟着丢。推荐在upsertSession方法和未读清零方法里统一调用一个persistSessions()函数,避免散落各处导致漏存。
6. 再往交付走一步:时间轴、发送占位与我的固定流程
前面几章跑通了基础聊天,但这个程度交给别人看还是会显得粗糙。这章补两个体验细节,再加上一个我养成的工作习惯。
消息时间轴的渲染是聊天界面观感的重要一环。收到消息时,对比新消息时间和聊天面板里上一条消息的时间,如果间隔超过五分钟就插入一条时间分隔线,否则直接接在后面。这样连续对话不会满屏时间戳,隔了许久的消息又能让用户感知到时间跨度。实现时维护一个prevMsgTime变量,每次渲染消息后更新它。
function appendMessage(item, prevTime) { var diff = item.time - prevTime; if (!prevTime || diff > 5 * 60 * 1000) { renderTimeDivider(item.time); } renderBubble(item); }发送占位是一个容易被忽略但很拉好感的细节。点击发送后,先把消息以灰色气泡插入聊天窗口,状态标记为“发送中”,等环信发送成功的回调回来后再把气泡状态改成“已发送”;发送失败则允许用户点击气泡重发。这样断网状态下消息不会凭空消失,用户也知道这条到底发出去没有。实现上可以用发送时生成的localMsgId在环信成功回调里反查对应气泡,再更新它的状态样式。
做完这个 demo 后我养成了一个固定习惯:每次接 IM 需求,先写一张三行清单——界面归谁、链路归谁、数据持久化归谁,写清楚再动手。layim 和环信都不是万能的,把分界画清楚之后,剩下的工作就只是按键映射和数据搬运,翻车率直线下降。希望帮到你。
本文还有配套的精品资源,点击获取