☰
从零搭建WebSocket网页聊天室:心跳重连与多房间实战
2026/10/9 6:25:04 网站建设 项目流程

简介:这是一份面向Web开发初学者与即时通讯应用实践者的WebSocket网页聊天室项目源码,帮助读者理解全双工实时通信的建立、消息收发与连接管理流程,可用于课程设计、技术练手或二次开发。压缩包共5个文件,约3KB,包含Python后端脚本、前端页面、依赖清单、说明文档及Git忽略配置,分别承担服务端逻辑、界面交互、环境依赖与使用指引等职责,结构精简便于快速上手。目前已有62人学习下载。通过阅读与运行,读者可掌握客户端升级请求、帧数据收发、多客户端广播等核心机制,并了解XSS、CSRF防护及wss加密、连接数管理等安全与性能优化思路,为构建社交、在线协作等实时应用打下基础。

1. 从零搭一个基于 WebSocket 的网页聊天室:为什么它比轮询更值得做

做过网页聊天的人多半经历过这个场景:前端用setInterval每两秒拉一次消息接口,用户少的时候还行,一旦在线人数上百,服务器日志里全是空请求,延迟还压不下去。基于 WebSocket 的网页聊天室解决的正是这个问题——它把「客户端反复问服务器有没有新消息」换成「服务器主动推给客户端」,一条长连接撑住整个会话。这个方向适合谁?适合想搞懂实时通信底层、又不满足于调第三方 SDK 的前后端开发者。你需要的技术栈不复杂:一个能跑 WebSocket 的服务端(Node.js 的ws库或 Python 的websockets都行)、一个浏览器端WebSocket对象、再加一点房间和用户管理逻辑。整篇笔记我会按「先跑通最小闭环,再补心跳和重连,最后处理多房间和消息可靠性」的顺序讲,每一步都给能直接抄的代码和参数说明。WebSocket 使用里最容易翻车的不是握手,而是连接断了没人知道,所以心跳机制实现会占不小篇幅。

2. 最小可跑通的 WebSocket 聊天室:服务端与浏览器端各写什么

先把「一条消息从 A 的输入框到 B 的屏幕」这条链路跑通,别一上来就搞房间和鉴权。最小闭环只需要一个服务端广播逻辑和一个前端收发逻辑,代码量控制在百行以内,跑起来之后再往上叠功能,这样出问题你能快速定位是握手阶段还是业务阶段。

2.1 用 Node.js 的 ws 库起一个广播服务端

选ws而不是socket.io,是因为ws更贴近原生 WebSocket 协议,你能看清每一层在干什么,排查问题时不会被封装层挡住。先初始化项目并装依赖:

mkdir ws-chat && cd ws-chat npm init -y npm install ws

然后写服务端server.js:

// server.js const WebSocket = require('ws'); // 监听 8080 端口,这个端口要和前端连接地址一致 const wss = new WebSocket.Server({ port: 8080 }); // clients 保存所有活跃连接,用于广播 const clients = new Set(); wss.on('connection', (ws) => { clients.add(ws); console.log('新连接加入,当前在线:', clients.size); // 收到消息后原样广播给所有客户端 ws.on('message', (data) => { const text = data.toString(); for (const client of clients) { // readyState 为 1 表示连接处于 OPEN 状态 if (client.readyState === WebSocket.OPEN) { client.send(text); } } }); // 连接关闭时从集合里移除,避免向死连接发消息 ws.on('close', () => { clients.delete(ws); console.log('连接断开,当前在线:', clients.size); }); }); console.log('WebSocket 服务已启动:ws://localhost:8080');

逻辑说明:clients用Set而不是数组,是因为增删查都是 O(1),连接多了也不会有性能抖动。广播前判断readyState === WebSocket.OPEN是关键,正在关闭的连接直接send会抛错。参数上,port换成你实际部署的端口即可,本地开发用 8080 不冲突。

2.2 浏览器端建立连接并渲染消息

前端一个 HTML 文件就够,核心是new WebSocket()和onmessage回调:

<!-- index.html --> <!DOCTYPE html> <html> <body> <div id="messages" style="height:300px;overflow-y:auto;border:1px solid #ccc;"></div> <input id="input" placeholder="输入消息" /> <button id="send">发送</button> <script> // 地址协议是 ws://,不是 http://,这是新手最常写错的地方 const ws = new WebSocket('ws://localhost:8080'); const messages = document.getElementById('messages'); const input = document.getElementById('input'); ws.onopen = () => { console.log('连接已建立'); }; ws.onmessage = (event) => { const div = document.createElement('div'); div.textContent = event.data; messages.appendChild(div); messages.scrollTop = messages.scrollHeight; // 自动滚到底部 }; ws.onclose = () => { console.log('连接已关闭'); }; document.getElementById('send').onclick = () => { if (ws.readyState === WebSocket.OPEN && input.value) { ws.send(input.value); input.value = ''; } }; </script> </body> </html>

逻辑说明:onmessage里用textContent而不是innerHTML,是为了防止别人发<script>标签造成 XSS。scrollTop = scrollHeight让新消息自动可见,聊天室体验上少不了这一行。参数上,ws://localhost:8080里的协议头必须是ws,部署到 HTTPS 站点时要换成wss://,否则浏览器会拦截混合内容。

2.3 验证最小闭环是否真的通了

启动服务端node server.js,用浏览器打开index.html,再开一个无痕窗口打开同一个文件。两个窗口互相发消息,如果都能看到对方的内容,说明广播链路通了。这时候打开浏览器开发者工具的 Network 面板,筛选 WS,你能看到一条状态为 101 Switching Protocols 的连接,点进去看 Messages 标签,收发的帧都在里面。这一步别跳过,后面加心跳和重连时,这个面板是你判断「到底是没发出去还是没收到」的唯一依据。

3. WebSocket 心跳机制实现:怎么判断连接是假死还是真断

最小闭环跑通后,真正的坑才开始。浏览器标签页切到后台、手机锁屏、中间网络设备超时清理会话,这些情况都会让 TCP 连接看起来还在,但实际已经发不出消息——这就是所谓的假死连接。心跳机制就是定期发一个轻量包,让对方回一个,收不到回应就主动断开重连。这一章把心跳的服务端和客户端两侧都写清楚,参数怎么定也给出来。

3.1 心跳包该由谁发、发什么内容

常见做法是客户端定时发ping,服务端收到后回pong。为什么不让服务端主动发?因为服务端要管成千上万个连接,定时器开销大;客户端各自管自己的连接,压力分散。心跳内容用固定字符串就行,比如{"type":"ping"},别用空字符串,因为有些中间层会丢弃空帧。服务端收到后回{"type":"pong"},客户端在收到pong时刷新一个「最后活跃时间」变量。

3.2 客户端心跳与超时重连的完整实现

把前端脚本改成下面这样,加入心跳定时器和重连逻辑:

// 心跳与重连封装 let ws = null; let heartbeatTimer = null; let lastPong = Date.now(); const HEARTBEAT_INTERVAL = 30000; // 30 秒发一次心跳 const PONG_TIMEOUT = 10000; // 10 秒没收到 pong 就判定断线 function connect() { ws = new WebSocket('ws://localhost:8080'); ws.onopen = () => { lastPong = Date.now(); startHeartbeat(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'pong') { lastPong = Date.now(); // 收到 pong,刷新活跃时间 return; } // 正常消息渲染逻辑 renderMessage(msg); }; ws.onclose = () => { stopHeartbeat(); // 1 秒后重连,避免服务端刚重启就被打爆 setTimeout(connect, 1000); }; } function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (Date.now() - lastPong > PONG_TIMEOUT) { console.warn('心跳超时,主动断开重连'); ws.close(); // 触发 onclose,走重连流程 return; } if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } connect();

逻辑说明:lastPong记录最后一次收到pong的时间,心跳定时器每次触发时先检查「距离上次 pong 是否超过 10 秒」,超了就主动close,让onclose里的重连逻辑接管。参数上,HEARTBEAT_INTERVAL设 30 秒是经验值——太短浪费流量,太长(比如 60 秒以上)中间设备可能已经把你的连接清掉了。PONG_TIMEOUT设 10 秒,给网络抖动留了余量,又不至于让用户等太久才发现断线。

3.3 服务端配合心跳的处理

服务端要能识别ping并回pong,同时把非心跳消息正常广播:

ws.on('message', (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch (e) { return; // 非 JSON 消息直接丢弃,防止解析报错拖垮服务 } if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return; } // 正常消息广播,带上时间戳 const payload = JSON.stringify({ type: 'message', content: msg.content, time: Date.now() }); for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(payload); } } });

逻辑说明:try/catch包住JSON.parse是必须的,客户端可能因为 bug 发出非法 JSON,不捕获的话整个服务端进程会崩。心跳消息不广播,只回给发送者,这样心跳不会污染聊天记录。参数上,time用服务端时间戳而不是客户端时间,避免用户改系统时间导致消息顺序错乱。

4. 多房间与用户标识:从「所有人看到所有消息」到「各聊各的」

广播给所有人只适合演示,真实聊天室要分房间,还要知道谁在说话。这一章把房间管理和用户标识加上,同时处理一个容易被忽略的问题:用户断线后房间里的成员列表怎么更新。

4.1 用 Map 管理房间与成员

把服务端的clients集合升级成「房间 -> 成员集合」的结构:

// rooms: Map<房间名, Set<ws>> const rooms = new Map(); function joinRoom(roomName, ws) { if (!rooms.has(roomName)) { rooms.set(roomName, new Set()); } rooms.get(roomName).add(ws); } function leaveRoom(roomName, ws) { const room = rooms.get(roomName); if (room) { room.delete(ws); if (room.size === 0) { rooms.delete(roomName); // 空房间及时清理,防止内存泄漏 } } } function broadcastToRoom(roomName, payload) { const room = rooms.get(roomName); if (!room) return; const text = JSON.stringify(payload); for (const client of room) { if (client.readyState === WebSocket.OPEN) { client.send(text); } } }

逻辑说明:用Map存房间,Set存成员,查找和删除都是常数时间。空房间及时delete是血泪经验——早期版本没做这一步,跑了一周内存涨了几百兆,全是没人用的空房间对象。参数上,房间名由客户端在连接后第一条消息里带上,服务端不做格式限制,但建议前端限制成字母数字,避免奇怪字符。

4.2 连接时绑定用户信息与房间

客户端连接后先发一条join消息,服务端记录用户信息:

ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'join') { ws.userName = msg.userName || '匿名'; ws.roomName = msg.roomName || 'default'; joinRoom(ws.roomName, ws); broadcastToRoom(ws.roomName, { type: 'system', content: `${ws.userName} 加入了房间` }); return; } if (msg.type === 'message') { broadcastToRoom(ws.roomName, { type: 'message', userName: ws.userName, content: msg.content, time: Date.now() }); } }); ws.on('close', () => { if (ws.roomName) { leaveRoom(ws.roomName, ws); broadcastToRoom(ws.roomName, { type: 'system', content: `${ws.userName} 离开了房间` }); } });

逻辑说明:把userName和roomName直接挂在ws对象上,是最省事的做法,不用额外维护映射表。close事件里先leaveRoom再广播离开消息,顺序不能反,否则离开的人还会收到自己的离开通知。参数上,userName默认给「匿名」,防止前端没传时出现undefined。

4.3 前端切换房间的处理

前端加一个房间输入框,切换时先发join再清空消息区:

function switchRoom(roomName) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'join', userName: currentUser, roomName: roomName })); document.getElementById('messages').innerHTML = ''; // 清空旧房间消息 } }

逻辑说明:切换房间本质是让服务端把你从旧房间移到新房间,但上面的服务端代码只做了joinRoom,没做leaveRoom。所以更严谨的做法是在join处理里先判断ws.roomName是否存在,存在就先leaveRoom再joinRoom。这个细节很多人第一次写会漏,导致用户在多个房间里同时收到消息。

5. 避坑与排查:WebSocket 聊天室上线前必须过的五道坎

这一章全是踩过的坑,每条按「现象 -> 原因 -> 解决」写。你上线前对照着过一遍,能省下大量半夜爬起来查日志的时间。

5.1 现象:本地好好的,部署到服务器就连不上

原因:浏览器在 HTTPS 页面里禁止用ws://明文连接,必须用wss://。另外很多云服务器的安全组默认只开 80 和 443,8080 端口没放行。解决:前端连接地址改成wss://你的域名/ws,服务端用 Nginx 反向代理把/ws转到本机 8080,同时在安全组放行对应端口。Nginx 配置里要加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";,这两行不加,握手会失败。

5.2 现象:连接建立后几分钟没消息就自动断了

原因:中间的反向代理或负载均衡有空闲超时,比如 Nginx 默认proxy_read_timeout是 60 秒,超过没数据就掐断。解决:把心跳间隔设得比代理超时短,比如代理 60 秒,心跳就设 30 秒。同时把 Nginx 的proxy_read_timeout和proxy_send_timeout调到 300 秒以上,给心跳留足余量。

5.3 现象:消息偶尔丢失,用户说「我明明发了」

原因:ws.send()是异步的,如果连接正在关闭,消息会静默丢弃,不报错。解决:发送前判断readyState === WebSocket.OPEN,并且在客户端加一个待发送队列,连接恢复后重发。更简单的做法是给每条消息加一个客户端生成的msgId,服务端收到后回ack,客户端没收到ack就重发。

5.4 现象:在线人数一多,服务端 CPU 飙升

原因:每次广播都遍历所有连接,连接数上千后 O(n) 的遍历加上 JSON 序列化,开销很可观。解决:JSON 序列化只做一次,把结果字符串复用给所有连接,别在循环里反复JSON.stringify。房间粒度也要控制,别让一个房间塞几千人,按业务拆小。

5.5 现象:用户刷新页面后,旧连接没断,在线人数虚高

原因:刷新时浏览器会建立新连接,旧连接的close事件可能延迟触发,导致短时间内同一个用户占两个连接。解决:给每个用户生成唯一userId,存在sessionStorage里,服务端在join时检查同userId的旧连接,主动close掉再接受新连接。这样在线人数才准。

6. 消息可靠性与断线重连的进阶技巧:把「偶尔丢消息」压到最低

前面把功能跑通了,但聊天室真正让人信任的是「消息不丢、顺序不乱」。这一章讲两个进阶点:断线重连后的消息补偿,以及用序号保证消息顺序。最后给一个我自己的习惯收尾。

先说断线重连补偿。客户端在onclose里重连成功后,应该带上「我最后收到的消息序号」发给服务端,服务端从内存或 Redis 里取出该序号之后的消息补发。实现上,服务端给每条广播消息递增一个seq,客户端记录lastSeq:

// 服务端广播时带上递增序号 let globalSeq = 0; function broadcastToRoom(roomName, payload) { const room = rooms.get(roomName); if (!room) return; payload.seq = ++globalSeq; const text = JSON.stringify(payload); for (const client of room) { if (client.readyState === WebSocket.OPEN) { client.send(text); } } }

客户端重连后发{ type: 'resume', lastSeq: lastSeq },服务端把seq > lastSeq的消息补发。注意,内存里只保留最近 N 条消息(比如 200 条),太老的补不了,这时候要提示用户「消息可能不完整」。参数上,N 取 200 是平衡内存和体验的经验值,房间多的话可以降到 50。

再说消息顺序。WebSocket 本身保证单条连接上的消息有序,但重连补偿时补发的消息和实时消息可能交叉。解决办法是客户端收到消息后按seq排序再渲染,用一个待渲染缓冲区,seq连续才上屏,断了就等补发。这个逻辑不复杂,但能避免「先发的消息后出现」这种玄学问题。

最后说一个我自己的习惯:每次改完服务端代码,先不急着开浏览器,而是用wscat命令行工具连上去手动发几条消息,确认服务端行为符合预期,再开前端联调。wscat -c ws://localhost:8080这个命令帮我省下了无数次「到底是前端还是后端的问题」的纠结。工具不复杂,但把排查边界划清楚了,效率完全不一样。希望帮到你。

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

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

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

立即咨询