尧图精选

H5聊天系统源码实战:WebSocket心跳、重连与离线补偿

🕒 发布时间:2026/9/11 8:08:04 📁 来源:尧图网络
简介一套完整的H5聊天/即时通讯系统源码面向需要快速搭建聊天、交友、客服等Web与移动端应用的开发者或企业。基于风车IM框架内置安卓与苹果端APP支持自动化注册、一键登录、设备UUID绑定、web端管理、群成员人数控制、登录界面视频动态等功能并针对英文版本与后台防护做了增强。压缩包共2040个文件大小约250.46MB其中以1080个js脚本、250个vue组件为主配合335个json配置、114个css样式及html页面构成前后端完整工程便于二次开发。目前已有139人学习下载。资源涵盖IM登录注册流程、消息与图片加密接口、后台IP白名单防护、域名防劫持等关键实现适合研究跨端即时通讯、客服系统源码的开发者可直接运行并参考其工程结构、界面代码与配置说明用于快速搭建稳定可用的即时通讯服务。1. H5聊天系统的价值点为什么源码贵在消息链路上一套标价8000的H5聊天系统源码值钱的部分不在那几十个页面组件而在一条WebSocket长连接从建立到断线重连之间发生的所有事。风车IM这类即时通讯源码把聊天、交友、客服三个场景压进同一套消息链路单聊和群聊靠路由客服靠在线分配交友靠资料匹配底层都是同一张消息表加一条双向通道。适合三类人接外包要快速交付的、公司要自建客服系统的、想从源码里抄一套可靠心跳机制回去的。H5聊天和APP聊天最大的差别是浏览器生命周期不可控切后台、锁屏、弱网都会掐断连接所以真正要啃的是重连策略和离线消息补偿。下面按消息链路从前到后过一遍协议怎么定、心跳怎么设、多端适配踩什么坑、上线前怎么压测。2. 风车IM的即时通讯链路H5端WebSocket从握手到消息路由2.1 为什么H5聊天要把轮询换成WebSocket长连接先明确一个前提H5聊天不是不能用HTTP。轮询、SSE都能传消息但轮询的每个请求都带完整HTTP头在移动网络下往返一次几百毫秒还要处理并发请求的顺序问题。SSE只有服务端到客户端单向客户端上行还得另开通道而聊天里“正在输入”“已读回执”都是上行消息。WebSocket一次握手升级协议后双向发送数据帧只有几个字节开销服务端能主动推消息延迟取决于链路本身。在微信、钉钉、企业微信自带浏览器里wss都是支持的。一个容易被忽略的细节页面是HTTPS时WebSocket必须走wss否则浏览器会拦截混合内容这也是工单里“线上连不上、本地http能连”的高频原因。风车IM这类源码的选型思路基本一致WebSocket做实时通道HTTP负责登录、拉历史记录、上传图片等一次性的数据交换。把两条链路分开实时通道只跑实时消息不要把文件上传塞进WebSocket否则一条大图的二进制帧会堵住后面的文本消息。2.2 最小可复现的H5消息发送与转发代码先看客户端连接与发送。这段代码不依赖任何框架直接放在H5页面里就能跑// 当前页面是HTTPS就用wssHTTP就用ws避免混合内容被拦截 const wsUrl ${location.protocol https: ? wss : ws}://${location.host}/ws?token${encodeURIComponent(token)}; const socket new WebSocket(wsUrl); socket.onopen function () { // 连上后第一件事是鉴权不是发消息 socket.send(JSON.stringify({ type: auth, data: { uid: currentUser.uid, platform: h5 } })); }; socket.onmessage function (evt) { const msg JSON.parse(evt.data); if (msg.type chat msg.to currentUser.uid) { renderMessage(msg); } }; function sendText(toUid, content) { const msg { type: chat, msg_id: ${Date.now()}-${Math.random().toString(16).slice(2)}, from: currentUser.uid, to: toUid, payload: { kind: text, content }, ts: Date.now() }; socket.send(JSON.stringify(msg)); }msg_id在前端生成而不是等后端返回是刻意的设计。弱网下消息发出后没收到确认客户端要重发接收端靠msg_id把重复消息丢掉如果等后端分配ID重发逻辑就得多一次请求。token放在URL的query上是WebSocket握手阶段唯一能带到服务端的鉴权位注意用encodeURIComponent处理。服务端转发用Node.js加ws库几十行就能撑起单机演示const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080, path: /ws }); const online new Map(); // uid - socket wss.on(connection, (socket, req) { const uid new URL(req.url, http://localhost).searchParams.get(token); online.set(uid, socket); socket.on(message, (raw) { const data JSON.parse(raw); if (data.type chat) { const toSocket online.get(data.to); if (toSocket toSocket.readyState WebSocket.OPEN) { toSocket.send(raw); // 原样转发msg_id和ts保持一致 } else { saveOfflineMessage(data); // 目标不在线写入离线表 } } }); socket.on(close, () online.delete(uid)); });在线表用Map存uid到socket的映射单机够用。多实例部署时Map就失效了要换成Redis的pub/sub或消息队列online表放到Redis并设置TTL为心跳间隔的三倍防止实例宕机后留下僵尸记录。saveOfflineMessage在演示里可以落到MySQL生产环境建议写Redis list用户上线后按序弹出来。2.3 即时通讯消息协议的字段设计消息协议字段是这套源码里最值得抄的部分。字段不是越多越好但下面这六个缺一个都会在后面补得很难受字段类型作用typestring消息类型auth / chat / sync / ping / pong / ackmsg_idstring客户端生成全局唯一去重与重发判定from / tostring发送方与接收方uid客服场景to可以是客服组IDpayloadobject消息体kind区分text / image / voice / videotsnumber毫秒时间戳排序与延迟统计都用它type里保留ack很关键。客户端收到一条chat消息后回一条ack服务端据此把离线表里的消息标记为已读重连后只拉未ack的部分。如果不做ack离线表只能按时间全量重拉用户消息一多每次重连都拉几百条。payload里的kind不建议直接用字符串裸传图片消息至少要有url、width、height、size四个字段语音要有duration这样渲染层不用再额外请求详情。字段对齐之后聊天、客服、交友三个模块可以共用同一套消息收发代码只换payload的解析逻辑。3. 消息可靠性设计心跳间隔、断线重连与离线补偿3.1 心跳参数怎么定浏览器挂起与网关超时H5页面的连接被掐断最常见的原因不是服务器挂了而是中间网关超时。nginx默认proxy_read_timeout是60秒60秒内没有数据流动网关主动断开连接。浏览器为了省电切后台时会节流JS定时器iOS Safari甚至完全暂停所以“看起来没断其实早断了”。心跳的作用就是让连接在网关注册的有效期内持续有数据流动。参数设置有一组我反复用的初始值参数推荐值依据心跳发送间隔25秒60秒网关超时的1/2以下留一倍余量等待pong超时10秒间隔的1/3左右快速判定死链重连初始延迟1秒立即重连会让服务端SYN队列被打满重连最大延迟30秒超过30秒说明网络长时间不可用不用再翻倍心跳不能用setInterval无脑发。页面在后台时定时器被节流到每分钟一次甚至暂停代码要配合页面可见性API页面回到前台时立刻补发一次心跳并检查连接状态。实现上客户端发ping服务端回pong超过10秒没收到pong就主动close触发重连。let pongReceived false; const HB_INTERVAL 25000; const HB_TIMEOUT 10000; let hbTimer null; let hbTimeoutTimer null; function startHeartbeat() { stopHeartbeat(); hbTimer setInterval(() { if (socket.readyState ! WebSocket.OPEN) return; pongReceived false; socket.send(JSON.stringify({ type: ping, ts: Date.now() })); hbTimeoutTimer setTimeout(() { if (!pongReceived) { socket.close(); // 主动关闭走重连逻辑 } }, HB_TIMEOUT); }, HB_INTERVAL); }3.2 指数退避重连与H5页面可见性监听重连策略的要点是第一次断线立即重越往后间隔越长避免大量客户端同时重连造成服务端雪崩。let retry 0; let manualClose false; function connect() { socket new WebSocket(wsUrl); socket.onopen () { retry 0; startHeartbeat(); }; socket.onclose () { stopHeartbeat(); if (manualClose) return; // 退出登录主动关闭不重连 const delay Math.min(30000, 1000 * Math.pow(2, retry)); retry 1; setTimeout(connect, delay); }; } document.addEventListener(visibilitychange, () { if (!document.hidden socket.readyState WebSocket.CLOSED) { connect(); } });指数退避里的retry在onopen时归零这样网络恢复后第一次重连就能用1秒的短延迟。visibilitychange监听是H5特有的用户切走再切回如果连接已经断了不用等下一次心跳发现立即重连。uniapp打包成APP后对应的是onShow生命周期里做同样的检查。一个常见的误用是重连成功后不重新鉴权。WebSocket握手时token验过一次但长时间连接里token可能过期重连后直接发消息会被服务端以未鉴权丢弃。可靠做法是重连onopen后重新走auth流程再触发sync拉离线消息。3.3 离线消息补偿序号、去重与拉取重连只是把通道恢复通道断开期间的消息要补偿。补偿的游标用自增序号而不是时间戳时间戳在同一毫秒内可能有好几条消息序号不会重复。服务端为每个用户维护一个last_seq发消息时把当前seq附在消息里。用户重连后带自己的lastSeq请求同步socket.onopen function () { socket.send(JSON.stringify({ type: sync, data: { last_seq: localLastSeq } })); };服务端返回从last_seq到最新seq之间的消息列表客户端逐条应用后更新localLastSeq。离线消息的存储用Redis的list每个用户一个key消息按seq入队客户端上线消费后清理已ack的部分。去重仍然靠msg_id。sync拉下来的消息和重发上行的消息可能在客户端撞在一起渲染前用一个Set维护最近200条msg_id重复的直接丢弃。200条的上限是内存换性能的折中超出后按时间顺序淘汰正常聊天场景足够。4. 聊天APP多端适配微信公众号H5、uniapp打包与客服分配4.1 uniapp打包后WebSocket连不上的三个原因“websocket运行到h5可以连接打包为app连接不了”是个高频搜索词也是H5聊天系统交付时必然遇到的一关。三个原因按出现频率排第一地址写死了localhost。浏览器里localhost指向开发机打包成APP装到真机上localhost指向手机自己自然连不上。用uniapp时页面请求和socket地址都要按环境切换不能只换一个。第二Android 9及以上默认禁止明文流量ws://在真机上直接被拦H5的http环境反而没这个问题。调试阶段先确认AndroidManifest里有没有配usesCleartextTraffic生产环境直接上wss一劳永逸。第三wss的证书链不完整。H5浏览器遇到证书链缺中间证书会自动补全APP的原生socket实现不会直接报错。这个坑最隐蔽因为“内网测试用的自签名证书在H5里能用”不代表APP里也能。平台差异用uniapp的条件编译处理// #ifdef H5 const socket new WebSocket(wss://api.example.com/ws?token token); // #endif // #ifdef APP-PLUS const socket uni.connectSocket({ url: wss://api.example.com/ws?token token, complete: () {} }); // #endifAPP端用uni.connectSocket拿到的socket对象和浏览器WebSocket的API有差异事件名从onopen/onmessage变成了onOpen/onMessage。如果源码里封装过一层统一的SocketService这层就负责把这些差异消化掉上层页面不用感知平台。4.2 微信公众号H5里的定位与返回按钮适配聊天APP做交友场景定位是刚需。微信公众号里的H5页面不能直接用navigator.geolocation微信内置浏览器对原生定位接口的支持时好时坏正路是微信JS-SDK。import wx from weixin-js-sdk; // 签名由后端生成access_token - jsapi_ticket - signature wx.config({ debug: false, appId: wxYOURAPPID, timestamp: sign.timestamp, nonceStr: sign.nonceStr, signature: sign.signature, jsApiList: [getLocation, hideOptionMenu, closeWindow] }); wx.ready(() { wx.getLocation({ type: gcj02, success: (res) { // 坐标是火星坐标系直接给地图组件用别当原始GPS数据 sendLocation(res.latitude, res.longitude); } }); });签名是很多团队卡住的地方。JS-SDK的签名基于jsapi_ticket而jsapi_ticket依赖access_tokenaccess_token两小时过期jsapi_ticket是7200秒且调用频率受限后端必须做缓存不能每次请求都去刷新。签名算法是SHA-1参数拼接顺序错了就会一直报invalid signature。H5页面在微信小程序里用web-view内嵌时工具栏左侧返回箭头消失通常是web-view的src发生跳转导致页面栈变化。H5内部用history.pushState做单页路由会产生新的历史记录返回箭头行为会被打乱。解法是页面内部路由尽量用replaceState把H5的会话路径收敛在web-view的初始src里。钉钉容器里的H5应用要调起录音会报no permission info for action:device.audio.startrecord本质是钉钉对原生能力做了权限拦截必须在钉钉开放平台申请对应权限并把H5域名加进授权列表不是前端代码能绕过的。这类容器限制和微信JS-SDK是同一个思路容器能力必须走容器提供的桥接。4.3 客服系统的会话分配与排队风车IM这类源码里客服模块和聊天模块共用消息链路差别只在上游多了一层分配逻辑。最简可用的分配策略是“当前会话数最少优先”。function pickAgent(agents, session) { const online agents .filter(a a.status online) .sort((a, b) a.activeSessions - b.activeSessions); return online[0] || null; // 全部忙进排队队列 }单机内存版够演示多实例部署时activeSessions各自的计数器不一致要用Redis的原子自增。给客服创建会话时INCR一个key会话结束时DECR。排队队列用一个Redis list用户进队后定时查自己的排位客服空闲时从队头弹出一个用户。客服消息和普通聊天的路由差异在于to字段普通聊天to是用户uid客服场景to是客服组ID。服务端维护一个“组ID到在线客服列表”的映射消息到达后先查组内是否有空闲客服没有就转排队。这套逻辑放在服务端客户端只感知到“我在和一个叫客服的账号对话”换人不换会话ID历史消息不丢。5. 压测与日志排错验证风车IM源码上线前的硬指标5.1 一条命令验证连得上代码跑起来后先用命令行做冒烟验证别急着写页面。wscat是Node系最常用的命令行WebSocket客户端npx wscat -c ws://127.0.0.1:8080/ws?tokenu_1001连上后手动输入一行JSON观察另一端是否收到。这一步验证的是握手鉴权、消息转发、在线表映射这条主链路。没有Node环境就用浏览器控制台自测两行代码的事。5.2 千连接压测脚本与指标再往下是并发验证。下面这个脚本建立1000个连接统计全部建连耗时和失败数const WebSocket require(ws); const TOTAL 1000; let connected 0; const start Date.now(); for (let i 0; i TOTAL; i) { const ws new WebSocket(ws://127.0.0.1:8080/ws?tokenu_${i}); ws.on(open, () { connected 1; if (connected TOTAL) { console.log(全部连接耗时: ${Date.now() - start}ms); } }); ws.on(error, () connected - 1); }压测前先把文件描述符上限调大ulimit -n 65535否则第几百个连接就抛EMFILE。看三个指标连接成功率要接近100%全部建连耗时在2秒内算正常压测过程中服务端内存保持平稳不再增长。内存持续上涨说明有连接泄漏重点查有没有在close事件里清理online表。5.3 排错参数速查现象先查这里频繁心跳超时nginx的proxy_read_timeout是否小于心跳间隔建议75秒高峰期连不上ulimit -n文件描述符上限、nginx的backlogH5正常APP失败证书链是否完整、ws/wss是否按环境切换重连后消息丢失重连有没有重新走auth和sync而不是只重发最后一条顺手的做法是把服务端每次close的code打日志统计一下1006占比超过5%就怀疑网络层或证书1000是正常断开1001是页面刷新。按code归类排查比看无差别错误日志高效得多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →