做即时通讯,绕不开 WebSocket,但如果你不是直接上手现成的框架,而是用 H5 + CSS + JS 从零手写一套,很多坑只有踩过才知道。这篇文章可以看作是基础收发流程之后的第二篇,重点不是怎么建立一次连接,而是怎么把一个只能“通”的 Demo 打磨成一个能稳定跑、能上线、能扩展的聊天模块。
这篇文章适合谁?已经跑通了 WebSocket 最基本建立连接、发送接收文本消息的流程,但还不清楚消息怎么设计、断线怎么处理、界面怎么和通信状态联动、上线后怎么排查问题的人。你不需要了解什么高深的前端工程化知识,也不用引入任何依赖,所有代码都是纯手写,能直接复制到本地跑起来。
1. 从“能收发”到“能上线”:你还缺什么
很多人的第一个 WebSocket 程序长这样:页面放一个输入框、一个按钮、一个 div,点按钮就ws.send(text),收到消息就往 div 里innerHTML +=。这样确实能通,但也仅此而已。一旦你开始认真考虑把它当做一个真正的即时通讯模块,你会发现问题一个接一个冒出来。
第一,消息格式怎么定。你说一句话发给服务端,服务端再转发给别人,这没问题。但如果服务端要推送“系统通知”“用户上线提醒”“在线人数变化”,甚至三条消息之间还要区分谁是谁,那裸字符串完全不够用。你不可能靠判断字符串内容来区分这是聊天文本还是系统通知,这活儿往后没法干。
第二,连接稳定性怎么保证。外网的移动网络切换、后端服务重启、反向代理超时断连,这些情况随时可能发生。浏览器里的WebSocket对象一旦断开,它不会自己重新连接,你必须主动写重连逻辑。而且重连不是简单setInterval里反复 new 一个,搞不好会把自己服务器打崩。
第三,界面和状态怎么联动。连接中是禁用输入框还是允许先缓存消息?断线了用户界面需不需要给出提示?消息气泡怎么区分自己发的和别人发的?这些看起来是 UI 问题,实际上和 WebSocket 的状态机强相关。
所以这篇文章里,我把它拆成四个核心部分来讲:消息协议设计、页面结构与样式、前端 JS 封装、问题排查。这四个部分合在一起,就是一个单人能维护的轻量级即时通讯前端。
2. 消息协议设计:先把通信规则定死
2.1 为什么 JSON 消息结构是底线
我见过有人直接约定:消息文本里打个前缀,比如 “@me@” 开头代表私聊,“#all#” 开头代表群发。这种做法在只有两个客户端联调的时候能跑通,但一旦消息种类增多,或者要附加额外的发送时间、发送人信息,解析字符串就成了噩梦。
正确的做法是用 JSON 作为消息载体。服务端和前端统一约定好字段含义,发送消息时JSON.stringify,收到消息时JSON.parse,解析失败就当垃圾消息丢掉。我在实际项目里的消息结构一般是这样的:
{ "type": "chat", "from": "用户ID或者昵称", "to": "目标标识,群聊用 room 名,单聊用对方ID", "payload": { "content": "消息正文", "timestamp": 1713849600000, "msgId": "550e8400-e29b-41d4-a716-446655440000" } }每次收到消息,前端先看type,再决定怎么渲染。chat类型走聊天消息的渲染逻辑;system类型走系统提示的渲染逻辑;heartbeat类型直接忽略,不渲染到界面。这个规则定了之后,前后端各干各的,不会互相踩脚。
2.2 消息类型至少要有这几种
无论你是做客服系统、游戏聊天还是简单的房间讨论组,消息类型基本绕不开这几类,我列个表你就明白了:
| 消息类型 | 方向 | 用途 | 前端处理方式 |
|---|---|---|---|
chat | 双向 | 普通聊天消息 | 渲染成聊天气泡 |
system | 服务端->客户端 | 上线、下线、警告等系统通知 | 居中灰字提示 |
online_count | 服务端->客户端 | 在线人数统计 | 更新顶部状态栏 |
heartbeat | 客户端<->服务端 | 心跳保活 | 不发消息时回复 pong,不渲染 |
error | 服务端->客户端 | 错误反馈 | 弹提示或日志输出 |
有了这个type字段,以后要加“图片消息”,不需要在原有逻辑上打补丁,只要新增一个image类型,payload 里放图片 URL 就行。扩展开来非常舒服。
2.3 消息 ID 和时间戳不是可有可无
很多初学者会忽略msgId和timestamp,觉得聊天消息还需要这个?我自己在写重连逻辑时被坑过一次:断线期间用户发了三条消息,重连后服务端把历史消息补回来,跟用户刚发的消息在本地产生了重复渲染。这时候你只能靠msgId去重。客户端维护一个本地消息 ID 集合,渲染前先判断这个 ID 是否出现过,重复的丢到一边。
timestamp的用途更多。你可以用它做消息时间分组的展示(比如今天、昨天),可以用来排序,甚至服务端在做消息持久化时也需要。前端生成时间戳只要一行代码:
const timestamp = Date.now();但消息 ID 不行,Math.random()有概率重复,我推荐统一用crypto.randomUUID()来生成。现代浏览器基本都支持,代码写起来也简单,兼容性不够时你可以找个简单的 UUID 生成函数做兜底。服务端补消息时,客户端确认哪些消息是自己发的,就看有没有匹配的msgId。
3. 页面结构升级:像一个正经聊天室
协议定好了,接着看界面。这一节只讲 H5 + CSS 的部分,但也别小看它,一个即时通讯页面,布局做不好,消息一多直接卡死或者滚动错乱。
3.1 HTML 骨架怎么排
聊天页面的经典三段式结构,从上到下分别是:顶栏、消息区、输入区。顶栏放在线状态和人数,消息区放气泡,输入区放文本框和发送按钮。
<div class="chat-app"> <header class="chat-header"> <span class="status-dot"></span> <span id="statusText">连接中...</span> <span class="online-count">在线:<span id="countNum">0</span></span> </header> <main class="chat-messages" id="messageList"> <!-- 动态渲染的消息气泡 --> </main> <footer class="chat-input-bar"> <input type="text" id="messageInput" placeholder="输入消息,按回车发送" /> <button id="sendBtn">发送</button> </footer> </div>关键点在于:消息区一定要独立成一块可滚动的区域,别把整个 body 撑得老长。即时通讯里消息会不断追加,如果页面整个高度跟着内容走,用户很快就滑不到底部了,体验非常差。我的做法是给.chat-messages设置固定高度(或者用flex: 1撑满剩余空间),内部overflow-y: auto,新消息到来时手动把scrollTop滚到底部。
3.2 连接状态的视觉反馈
网页顶部的那个小圆点,应该有三种颜色:绿色代表已连接,黄色代表重连中,红色代表断开。这个设计不是为了好看,是为了让用户一眼就知道现在能不能发消息。我在CSS里预置了三组状态类:
.status-dot { width: 10px; height: 10px; border-radius: 50%; background-color: #e6a23c; } .status-dot.is-connected { background-color: #67c23a; } .status-dot.is-disconnected { background-color: #f56c6c; }默认状态是“黄色重连中”,JS 拿到onopen后加.is-connected,onclose后移除并加.is-disconnected。同时按钮的禁用状态也跟着变。连接正常时按钮可用,断开时禁用,重连成功自动恢复,这比什么提示都直观。
3.3 消息气泡怎么区分自己和别人
H5 的聊天界面不用搞太复杂,两个类就够了:
.message-item { display: flex; margin-bottom: 12px; } .message-item.message-self { justify-content: flex-end; } .message-item .bubble { max-width: 70%; padding: 8px 12px; border-radius: 8px; background-color: #f0f0f0; word-break: break-word; line-height: 1.5; } .message-item.message-self .bubble { background-color: #95ec69; }自己发的消息右对齐绿气泡,别人发的左对齐灰气泡。就这么简单,但有一个细节容易忽略:max-width: 70%一定要加,否则一条很长的英文或数字字符串会把整个气泡撑满屏幕。配合word-break: break-word,长链接、长单词都会被正确换行,不然你的布局会被一个超长 URL 戳破。
4. 核心 JS:封装一个“靠得住”的聊天客户端
上面的协议和页面都是准备工作,真正的工程量集中在 JS 这里。我强烈建议用模块化的思路写,把 WebSocket 的整个生命周期封装成一个类。别把ws.onmessage这类回调函数直接写在全局作用域里,一旦消息多了、页面还加了些其他功能,全局变量会把你自己绕晕。
4.1 客户端类的基本骨架
这一段是核心逻辑。我直接贴代码,然后逐行解释,你复制到本地就能用:
class ChatClient { constructor(options) { this.url = options.url; this.onMessage = options.onMessage || function() {}; this.onStatusChange = options.onStatusChange || function() {}; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.heartbeatTimer = null; this.reconnectTimer = null; this.ws = null; this.manualClose = false; } connect() { this.manualClose = false; this.ws = new WebSocket(this.url); this.ws.onopen = () => this.handleOpen(); this.ws.onmessage = (event) => this.handleMessage(event); this.ws.onerror = (error) => this.handleError(error); this.ws.onclose = () => this.handleClose(); } handleOpen() { this.reconnectAttempts = 0; this.startHeartbeat(); this.onStatusChange('connected'); } handleMessage(event) { let data; try { data = JSON.parse(event.data); } catch (e) { console.warn('收到非 JSON 消息,已丢弃:', event.data); return; } if (data.type === 'heartbeat') { this.send({ type: 'pong' }); return; } this.onMessage(data); } handleError(error) { console.error('WebSocket 错误:', error); } handleClose() { this.stopHeartbeat(); this.onStatusChange('disconnected'); if (this.manualClose) return; this.scheduleReconnect(); } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(typeof data === 'string' ? data : JSON.stringify(data)); return true; } return false; } close() { this.manualClose = true; clearTimeout(this.reconnectTimer); this.ws && this.ws.close(); } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { this.send({ type: 'heartbeat' }); }, 30000); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.onStatusChange('give-up'); return; } const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); this.reconnectAttempts++; this.onStatusChange('reconnecting'); this.reconnectTimer = setTimeout(() => this.connect(), delay); } }你观察一下这个类的设计思路。reconnectAttempts字段记录重连次数,重连间隔用1秒、2秒、4秒、8秒...的方式翻倍,到 30 秒封顶。这就是指数退避策略。你可能会问,为什么不直接固定 3 秒重连一次?想象一下:某个时刻你服务器的网络出问题了,500 个在线用户同时掉线,然后每人每 3 秒重连一次,你的服务端瞬间收到每秒 160 多个连接请求,可能直接把服务压垮。用指数退避,大家的重连节奏会逐渐错开,同时给服务端恢复留出时间。
4.2 为什么心跳机制如此重要
浏览器里的 WebSocket 不会永远存活。有些时候连接明明已经断了,但浏览器不知道,因为底层 TCP 连接并没有触发 close 事件。这在移动端尤其常见,比如你从 WiFi 切到 4G,IP 地址变了,之前的连接实际上已经失效,但有些浏览器不会立刻感知。
这时候就要靠心跳检测了。我上面的实现是每 30 秒发一条{ type: 'heartbeat' },服务端收到后回一条{ type: 'pong' }。如果客户端连续几次发心跳都没收到回复,说明连接大概率已经处在半死状态,客户端可以主动ws.close()触发重连流程。不过实现完整的心跳检测需要在handleMessage里记录最后收到消息的时间,再用一个定时器检查超时,你可以在自己的代码里加上这部分改进。
4.3 断线期间用户发送的消息怎么办
这是我在做的时候踩过最深的坑。用户在断线时点“发送”,按上面的代码,send()返回false,消息没发出去,也没有任何提示。对业务来说这就是事故。用户以为自己发出去了,对方却半天收不到。
我的方案是加一个离线消息队列。send()返回false的时候,把消息推入一个数组缓存起来,同时界面里提示“连接已断开,消息将在恢复后发送”。等到handleOpen()重新触发,先清空队列,把缓存的消息逐条发送出去。伪代码大致是这样的:
// 在类里加一个 pendingMessages 数组 queueMessage(data) { this.pendingMessages.push(data); } flushPending() { while (this.pendingMessages.length > 0) { const msg = this.pendingMessages.shift(); this.send(msg); } } // handleOpen 里调用 handleOpen() { // ...原有逻辑 this.flushPending(); }注意缓存消息时要保留用户看到的消息顺序,所以我用了shift()从头部取出,而不是从尾部 pop。否则用户发的是“先吃了吗”“我到了”两条,重连后变成“我到了”“先吃了吗”,对方看着就像神经病。
4.4 页面可见性恢复时主动检查连接
还有一个细节:用户把浏览器标签页切到后台很长时间,浏览器可能会主动挂起 WebSocket 连接。等用户切回来的时候,连接状态可能已经不正常了。处理方式是在页面visibilitychange事件里做检查:
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (chatClient.ws && chatClient.ws.readyState === WebSocket.CLOSED) { chatClient.connect(); } } });这段代码放在初始化部分即可。用户从别的标签页切回来,如果发现旧的连接已经关闭,就直接重新建立连接,不用傻等下一次心跳超时。
5. 完整案例代码:一段可以直接跑起来的实现
讲了一堆思路,还是给一份能直接动手的完整代码。下面这个 Demo 把所有上述内容都串起来了,你复制成 HTML 文件,双击打开就能用(需要本地起一个 WebSocket 服务端,或者用一个在线的测试服务)。
5.1 完整 HTML + CSS + JS
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>手写 WebSocket 聊天 Demo</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background-color: #f5f5f5; display: flex; justify-content: center; padding-top: 40px; } .chat-app { width: 100%; max-width: 480px; height: 700px; background-color: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; overflow: hidden; } .chat-header { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid #e8e8e8; background-color: #fafafa; } .status-dot { width: 10px; height: 10px; border-radius: 50%; background-color: #e6a23c; transition: background-color 0.3s ease; } .status-dot.is-connected { background-color: #67c23a; } .status-dot.is-disconnected { background-color: #f56c6c; } .online-count { margin-left: auto; font-size: 13px; color: #999; } .chat-messages { flex: 1; overflow-y: auto; padding: 16px; background-color: #fafafa; } .message-item { display: flex; margin-bottom: 12px; } .message-item.message-self { justify-content: flex-end; } .message-item .bubble { max-width: 70%; padding: 8px 12px; border-radius: 8px; background-color: #fff; word-break: break-word; line-height: 1.5; font-size: 14px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .message-item.message-self .bubble { background-color: #95ec69; } .message-item.message-system .bubble { background-color: transparent; color: #999; font-size: 12px; text-align: center; box-shadow: none; max-width: 100%; width: 100%; } .chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e8e8e8; } .chat-input-bar input { flex: 1; height: 40px; padding: 0 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; outline: none; } .chat-input-bar input:focus { border-color: #409eff; } .chat-input-bar button { width: 72px; height: 40px; border: none; border-radius: 8px; background-color: #409eff; color: #fff; font-size: 14px; cursor: pointer; } .chat-input-bar button:disabled { background-color: #a0cfff; cursor: not-allowed; } </style> </head> <body> <div class="chat-app"> <header class="chat-header"> <span class="status-dot" id="statusDot"></span> <span id="statusText">连接中...</span> <span class="online-count">在线:<span id="countNum">0</span></span> </header> <main class="chat-messages" id="messageList"></main> <footer class="chat-input-bar"> <input type="text" id="messageInput" placeholder="输入消息,按回车发送" /> <button id="sendBtn" disabled>发送</button> </footer> </div> <script> // 工具函数:格式化时间 function formatTime(timestamp) { const d = new Date(timestamp); const pad = (n) => (n < 10 ? '0' + n : n); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } // 消息渲染 function appendMessage(message, type) { const list = document.getElementById('messageList'); const item = document.createElement('div'); item.className = 'message-item ' + (type || ''); const bubble = document.createElement('div'); bubble.className = 'bubble'; if (type === 'message-system') { bubble.textContent = message.content; } else { bubble.textContent = formatTime(message.timestamp || Date.now()) + ' ' + message.content; } item.appendChild(bubble); list.appendChild(item); list.scrollTop = list.scrollHeight; } // ChatClient 类 class ChatClient { constructor(options) { this.url = options.url; this.onMessage = options.onMessage || function() {}; this.onStatusChange = options.onStatusChange || function() {}; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.heartbeatTimer = null; this.reconnectTimer = null; this.ws = null; this.manualClose = false; this.pendingMessages = []; } connect() { this.manualClose = false; this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectAttempts = 0; this.startHeartbeat(); this.flushPending(); this.onStatusChange('connected'); }; this.ws.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch (e) { console.warn('收到非 JSON 消息:', event.data); return; } if (data.type === 'heartbeat' || data.type === 'pong') { return; } this.onMessage(data); }; this.ws.onerror = (error) => { console.error('WebSocket 错误:', error); }; this.ws.onclose = () => { this.stopHeartbeat(); this.onStatusChange('disconnected'); if (this.manualClose) return; this.scheduleReconnect(); }; } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); return true; } return false; } queueMessage(data) { this.pendingMessages.push(data); } flushPending() { while (this.pendingMessages.length > 0) { const msg = this.pendingMessages.shift(); this.send(msg); } } close() { this.manualClose = true; clearTimeout(this.reconnectTimer); if (this.ws) { this.ws.close(); } } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { this.send({ type: 'heartbeat', timestamp: Date.now() }); }, 30000); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.onStatusChange('give-up'); return; } const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); this.reconnectAttempts++; this.onStatusChange('reconnecting'); this.reconnectTimer = setTimeout(() => this.connect(), delay); } } // 页面初始化 const client = new ChatClient({ url: 'ws://你的服务端地址/ws', // 替换为实际地址 onMessage: (data) => { if (data.type === 'chat') { const isSelf = data.from === 'me'; appendMessage(data.payload, isSelf ? 'message-self' : ''); } else if (data.type === 'system') { appendMessage(data.payload, 'message-system'); } else if (data.type === 'online_count') { document.getElementById('countNum').textContent = data.payload.count; } }, onStatusChange: (status) => { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); const btn = document.getElementById('sendBtn'); dot.classList.remove('is-connected', 'is-disconnected'); if (status === 'connected') { dot.classList.add('is-connected'); text.textContent = '已连接'; btn.disabled = false; } else if (status === 'reconnecting') { text.textContent = '重连中...'; btn.disabled = true; } else if (status === 'disconnected') { dot.classList.add('is-disconnected'); text.textContent = '已断开'; btn.disabled = true; } else if (status === 'give-up') { text.textContent = '连接失败,请刷新重试'; btn.disabled = true; } } }); client.connect(); // 发送逻辑 const input = document.getElementById('messageInput'); const sendBtn = document.getElementById('sendBtn'); function sendMessage() { const content = input.value.trim(); if (!content) return; const message = { type: 'chat', from: 'me', payload: { content, timestamp: Date.now(), msgId: crypto.randomUUID() } }; const sent = client.send(message); if (!sent) { client.queueMessage(message); appendMessage({ content: '[连接断开,消息将在恢复后发送]' }, 'message-system'); } input.value = ''; input.focus(); } sendBtn.addEventListener('click', sendMessage); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { sendMessage(); } }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (client.ws && client.ws.readyState === WebSocket.CLOSED) { client.connect(); } } }); // 页面关闭前主动关闭连接 window.addEventListener('beforeunload', () => { client.close(); }); </script> </body> </html>这段代码就是把前面讲的所有机制落到了实际实现里,包括心跳、重连、离线消息缓存、滚动条自动到底、状态点切换等等。服务端地址换成你本地的地址直接就能跑。
5.2 服务端要配合的事情
如果你是 Node.js 环境起服务端,最简写法只需要在一个 WebSocket 库的帮助下完成回显转发。前端发送一条chat消息,服务端广播给所有连接。这里不展开服务端代码,但提醒一个关键约束:服务端在处理心跳消息时一定要回pong,同时要顺手解析 payload 里的事件类型,否则客户端的type约定就白做了。
另外,服务端要支持转发消息时补上自己生成的msgId吗?如果服务端做消息持久化,通常会用服务端生成的消息 ID 作为权威 ID。这时你认真设计和约定字段就体现出价值了。
6. 常见问题与排查技巧实录
6.1 本地连接远程 WebSocket 地址失败
本地调试时,如果你用ws://192.168.x.x:8080这种地址,最基本的问题是服务端绑定的 IP 和端口。服务端如果只绑定了127.0.0.1,局域网内其他机器当然连不上。Windows 下防火墙也会拦截连接请求,需要写允许入站规则。
还有一个特隐蔽的问题:浏览器在处理 WebSocket 握手时发送的Origin字段。部分服务端会校验Origin,如果你的页面是file://协议打开,Origin是file://或null,服务端可能直接拒绝握手。所以调试阶段尽量在本地起一个 HTTP 静态服务,用http://localhost:8080打开页面,而不是直接双击 HTML 文件。
6.2 页面一切正常,但消息发不出去
我排查这个问题时有几个固定顺序。第一,先看服务端是否收到了消息;第二,如果没有,前端ws.readyState是否为OPEN;第三,检查有没有把连接状态和界面按钮状态做双向绑定。很多时候是用户在断线期间发送,按钮没禁用,只是前端没做拦截。
如果你用了上面的send()方法,返回值false代表当前连接不可用,你需要在 UI 层做明确反馈,而不是默默丢消息。我见过一个线上事故,就是断线期间用户发了十几条消息,全部send()后石沉大海,用户以为对方不回消息。最终排查发现是前端在onclose的时候忘记禁用发送按钮了。
6.3 心跳导致的“幽灵连接”
加了心跳之后,有时你会发现服务端显示连接一直存在,但用户早就关掉页面了。原因可能是用户网络出口设备(比如路由器)的 NAT 映射还在,服务端收不到心跳超时的主动清除,就保留着已经失效的 TCP 连接。
解决思路是:不能只靠客户端发心跳,服务端也要定期检测。如果超过一定时间(比如 90 秒)没有收到客户端的任何消息,服务端应当主动close这个连接。这需要前后端配合,前端多做无益。后端主动关,前端onclose触发,然后走重连逻辑,最终要么恢复连接,要么放弃。
6.4 重连风暴问题
指数退避能缓解大部分重连风暴,但还有个小细节:用户在页面切后台很长一段时间,重连逻辑还在跑。如果刚好赶上服务端长时间维护,用户每次切回页面都会触发一次新的重试,几个小时后回来界面仍然现实“重连中”。
我加的逻辑是:如果页面切到后台,超过 60 秒,先停掉重连定时器;回到前台时再判断连接状态并手动触发一次connect()。这样既不会在后台狂发请求,又能保证用户回到页面时快速恢复。
6.5 关于 wss 和反向代理的部署提醒
虽然文章重点在纯前端,但既然说“能上线”,部署环节绕不开。线上环境推荐用wss://(即 WebSocket 走 TLS 加密),不然你的聊天内容在公共 WiFi 下等于明文裸奔,任何人截个包都能看。
用反向代理做负载均衡时有个大坑:默认超时时间太短。WebSocket 连接空闲一段时间后,如果反向代理层把空闲连接当成超时连接杀了,客户端不会立刻感知,直到发送消息失败或者心跳超时才触发重连。所以你至少要把代理层对/ws路径的空闲超时时间调到比心跳间隔更长,同时开启WebSocket的协议升级支持。有些反向代理配置需要在 HTTP 头里加Connection: Upgrade和Upgrade: websocket,漏了任何一个握手都会失败。
6.6 消息粘包和拆包的问题
很多人第一次接触 WebSocket 会问:TCP 是流式协议,消息会粘包吗?答案是,WebSocket 协议在 TCP 之上有独立的帧格式,所以从浏览器的API层面讲,WebSocket 的每个消息天然是按帧分割的,消息之间不会粘。
但这里有个性能上的注意点:如果一次收到一条 1MB 的大消息(比如图片 base64),直接JSON.parse可能会卡一下 UI。处理方式是把消息体大小限制交给服务端校验,前端只做渲染前检查。我在实际里会把超过一定大小的消息内容做截断预览,点开才看全文,避免一次渲染大段内容造成白屏。
7. 这段代码的后续扩展思路
写到这基本把我踩过的坑都过了一遍。最开始我习惯一有通信需求就引入一个成熟的即时通讯框架,后来发现很多场景其实没必要那么重。一个轻量聊天室、一个工单回复面板、一个内部的运营通知推送,手写这套完全够用,还能让你对连接的生命周期心里有数。
如果以后需要扩展,我的建议玩法是:支持多个房间就加roomId字段;支持历史消息回放就约定history消息类型,服务端拉取消息列表后用同样的消息结构重放;支持图片等文件消息就把payload.content换成对象{ type: 'text' | 'image', url: '', content: '' },渲染层根据类型分支处理。
有一点想特别说明:我在实际项目中,从来不让业务逻辑直接依赖ChatClient的ws.onmessage回调,而是让回调统一收拢到业务层的onMessage里,再往 UI 分发。界面代码和通信代码之间隔着这一层,后面你加消息去重、渲染顺序调整、未读计数等逻辑,会轻松非常多。
跑一遍这套代码,把服务端地址改成自己的,然后试着在断网状态下发一条消息,你会看到消息缓存和恢复发送的效果。这比任何教程都有说服力。