首页 / 资讯中心 / 文章详情

从零实现WebSocket聊天室:全双工通信与广播机制详解

从零实现WebSocket聊天室:全双工通信与广播机制详解 ★ FEATURED ARTICLE
1. 从零理解Socket聊天室到底在解决什么问题很多人第一次听到用Socket做聊天室脑子里浮现的画面可能是微信或者某个成熟的IM软件。但真动手写的时候往往卡在第一个问题上两个浏览器页面之间凭什么能互相收到对方的消息HTTP请求不是发完就断了吗这就是这个Demo最核心的价值所在——它逼着你去理解全双工通信这件事。平时我们写前端绝大多数场景都是请求-响应模式浏览器发一个请求服务器回一个响应连接就结束了。但聊天室不行A用户发的消息要能主动推给B用户B用户并没有发起任何请求。这种服务器主动推的能力HTTP原生做不到而Socket可以。具体来说这个Demo涉及的技术链路是这样的浏览器端通过WebSocket协议与服务器建立一条持久连接这条连接建立后会一直保持打开状态双方随时可以往对方发送数据。服务器端则需要维护一个连接池记录当前所有在线用户的连接对象当收到某个用户发来的消息时遍历连接池把消息转发给其他所有人。这就是最朴素的广播机制。为什么选WebSocket而不是传统的TCP Socket因为浏览器环境里JavaScript没法直接操作TCP层WebSocket是浏览器提供的、基于TCP之上的应用层协议它保留了Socket的全双工特性同时又能被前端直接调用。所以严格来说前端能做的Socket聊天室用的就是WebSocket。这个Demo适合谁我认为有三类人值得动手做一遍第一类是刚学完前端基础、想找个项目把HTML/CSS/JS串起来练手的同学第二类是准备面试、需要理解网络通信原理的求职者因为WebSocket和HTTP的区别是高频考点第三类是想往全栈方向走、需要接触Node.js服务端编程的开发者。哪怕你之前没写过一行服务端代码这个Demo的门槛也足够低。我见过不少人做这个Demo时直接抄了一份代码跑通就完事了结果面试被问到为什么需要心跳机制断线重连怎么做就答不上来。所以接下来我不会只给你一份能跑的代码而是把每个设计决策背后的原因讲清楚让你真正吃透这套东西。2. 技术选型为什么是WebSocket而不是轮询或SSE2.1 三种服务器推送方案的横向对比在WebSocket普及之前实现服务器主动推消息其实有好几种土办法它们各有各的痛点。我把常见的三种方案拉出来对比一下你就能明白为什么WebSocket是聊天室场景的最优解。方案实现原理实时性服务器压力适用场景短轮询客户端每隔N秒发一次请求问有新消息吗差取决于轮询间隔高大量无效请求对实时性要求极低的场景长轮询请求发出后服务器hold住有消息才返回较好中连接占用多兼容性要求高的老项目SSE服务器单向推送事件流好低只需服务器推、不需客户端推WebSocket一次握手后建立全双工连接极好低聊天、协作、游戏等双向场景短轮询的问题最直观假设1秒轮询一次1000个用户在线服务器每秒要处理1000个请求其中990个可能都是没有新消息的空响应。这纯属浪费。长轮询稍微聪明点但每个连接都要占着服务器资源并发一高就顶不住。SSEServer-Sent Events是个不错的方案它基于HTTP服务器可以持续往客户端推数据。但它有个致命限制只能服务器推给客户端客户端没法通过这条连接往回发消息。聊天室里用户要发消息所以SSE还得配合额外的POST请求架构就复杂了。WebSocket则是一次HTTP握手升级之后连接就变成了全双工通道双方地位对等谁都能随时发。这就是它成为聊天室标配的根本原因。2.2 服务端为什么选Node.js配ws库服务端语言的选择上我强烈建议用Node.js理由有三个。第一是语言统一。前端本来就是JavaScript服务端也用JS你不用在两种语言之间来回切换思维学习成本最低。对于做Demo练手来说这一点非常重要能让你把精力集中在通信逻辑本身而不是被语言语法绊住。第二是生态成熟。Node.js里做WebSocket服务最常用的库就是ws。它轻量、API简单、性能好几行代码就能起一个WebSocket服务器。相比之下如果用Java的Netty或者Python的websockets配置和概念都会多一些。第三是事件驱动模型天然契合。Node.js的异步非阻塞特性处理大量并发连接非常合适。聊天室这种很多连接同时挂着、偶尔来一条消息的场景正是Node.js的强项。安装ws就一行命令npm init -y npm install ws这里有个小细节npm init -y会生成一个默认的package.json如果你后续想用ES Module语法import需要在里面加一行type: module。我建议新手先用CommonJS的require坑少。2.3 前端为什么不用框架这个Demo的前端我建议纯原生不要上React或Vue。原因很简单这个项目的核心是理解WebSocket通信框架会引入额外的构建工具、状态管理概念反而分散注意力。用原生JS操作DOM虽然代码看起来土但每一行你都能看懂它在干嘛。等你把这个原生版本吃透了再把它改造成Vue或React版本那才是进阶练习。先学会走再学跑。3. 服务端核心逻辑连接池与广播机制怎么设计3.1 一个最小的WebSocket服务器长什么样先看服务端最核心的骨架代码我加了详细注释// server.js const WebSocket require(ws); // 创建WebSocket服务器监听8080端口 const wss new WebSocket.Server({ port: 8080 }); // clients集合用来保存所有当前连接的客户端 const clients new Set(); wss.on(connection, (ws) { console.log(有新用户连接进来了); // 把新连接加入集合 clients.add(ws); // 监听该连接发来的消息 ws.on(message, (data) { const message data.toString(); console.log(收到消息:, message); // 广播给所有其他客户端 for (const client of clients) { if (client ! ws client.readyState WebSocket.OPEN) { client.send(message); } } }); // 监听连接关闭 ws.on(close, () { console.log(有用户断开了); clients.delete(ws); }); }); console.log(WebSocket服务器已启动监听8080端口);这段代码不到30行但已经把聊天室的核心逻辑讲完了。关键点有三个clients集合、message事件里的广播循环、close事件里的清理。3.2 为什么用Set而不是Array存连接很多人第一反应是用数组const clients []来存连接。数组也能用但Set有两个优势。第一是自动去重。虽然正常情况下一个连接对象不会重复加入但Set从数据结构层面保证了唯一性省心。第二是删除效率。用户断开时要从集合里移除连接数组的splice需要先indexOf找到位置是O(n)操作Set的delete是O(1)。虽然聊天室规模不大时差别不明显但养成好习惯没坏处。更重要的是Set的遍历语法for...of很干净配合delete方法代码可读性比数组好。3.3 广播时为什么要判断readyState注意广播循环里那个条件client.readyState WebSocket.OPEN。这个判断非常关键新手经常漏掉。WebSocket连接有四种状态CONNECTING0正在连接、OPEN1已打开、CLOSING2正在关闭、CLOSED3已关闭。如果某个客户端正在关闭连接你往它send数据会直接抛错导致整个广播循环中断其他用户就收不到消息了。所以每次发送前必须检查状态。这个细节在Demo阶段可能看不出问题但一旦有用户网络抖动、频繁断连不加这个判断就会出各种诡异bug。我踩过这个坑当时排查了半天才发现是往一个已关闭的连接发消息导致的。3.4 消息格式的设计从纯文本到JSON上面代码里消息是直接透传的字符串。但真实聊天室需要区分谁发的什么时候发的是普通消息还是系统通知。所以消息格式应该升级成JSON。服务端收到消息后可以这样处理ws.on(message, (data) { let parsed; try { parsed JSON.parse(data.toString()); } catch (e) { console.warn(收到非法消息格式已忽略); return; } // 补上服务器时间戳防止客户端时间不准 parsed.timestamp Date.now(); const payload JSON.stringify(parsed); for (const client of clients) { if (client ! ws client.readyState WebSocket.OPEN) { client.send(payload); } } });这里有个经验永远不要信任客户端发来的数据。用try...catch包住JSON.parse防止有人发个乱七八糟的字符串把服务器搞崩。时间戳由服务器统一打避免不同客户端系统时间不一致导致消息顺序错乱。消息体我建议设计成这样的结构{ type: chat, nickname: 张三, content: 大家好, timestamp: 1700000000000 }type字段预留出来以后想加用户加入用户离开正在输入这些系统消息直接扩展type值就行不用改协议结构。4. 前端实现连接建立、消息收发与界面渲染4.1 建立连接与处理各种事件前端第一件事是创建WebSocket实例const ws new WebSocket(ws://localhost:8080); ws.onopen () { console.log(连接已建立); appendSystemMessage(已连接到聊天室); }; ws.onmessage (event) { const msg JSON.parse(event.data); renderMessage(msg); }; ws.onerror (err) { console.error(连接出错, err); appendSystemMessage(连接出现异常); }; ws.onclose () { console.log(连接已关闭); appendSystemMessage(连接已断开); };注意协议前缀是ws://而不是http://。如果将来部署到HTTPS环境要换成wss://这个和HTTP到HTTPS的升级是一个道理。四个事件回调里onmessage是核心负责接收服务器推来的消息并渲染。onclose也很重要它是实现断线重连的入口后面会细讲。4.2 发送消息的完整流程发送消息看起来简单就一句ws.send()但要做好几个检查function sendMessage() { const input document.getElementById(msgInput); const content input.value.trim(); if (!content) return; if (ws.readyState ! WebSocket.OPEN) { appendSystemMessage(连接未就绪消息发送失败); return; } const msg { type: chat, nickname: currentNickname, content: content }; ws.send(JSON.stringify(msg)); input.value ; input.focus(); }三个检查点内容不能为空、连接必须是OPEN状态、发送后清空输入框并重新聚焦提升连续输入的体验。这些细节看着小但决定了Demo用起来顺不顺。4.3 消息渲染与XSS防护渲染消息时最容易犯的错误是直接用innerHTML拼接用户输入的内容。这会导致XSS攻击——如果有人发一段script标签其他用户的页面就会被执行恶意代码。正确做法是用textContentfunction renderMessage(msg) { const container document.getElementById(messages); const div document.createElement(div); div.className message; const nameSpan document.createElement(span); nameSpan.className nickname; nameSpan.textContent msg.nickname; const contentSpan document.createElement(span); contentSpan.className content; contentSpan.textContent msg.content; // 关键用textContent div.appendChild(nameSpan); div.appendChild(contentSpan); container.appendChild(div); // 自动滚动到底部 container.scrollTop container.scrollHeight; }用textContent而不是innerHTML浏览器会自动把特殊字符转义script就变成了纯文本显示不会被执行。这是前端安全的基本功做Demo时就要养成习惯。4.4 自动滚动到底部的时机问题container.scrollTop container.scrollHeight这行代码作用是让消息列表自动滚到最新一条。但有个坑如果消息里包含图片或者异步加载的内容scrollHeight在设置时可能还没更新完导致滚动不到位。稳妥的做法是用requestAnimationFrame包一层requestAnimationFrame(() { container.scrollTop container.scrollHeight; });这样能保证在下一帧渲染前执行滚动此时DOM高度已经计算完毕。虽然纯文字消息一般不会出问题但养成这个习惯以后加图片、表情就稳了。5. 让Demo从能跑到好用的关键细节5.1 昵称系统进入聊天室的第一道门槛一个没有昵称的聊天室消息全是匿名的根本没法用。最简单的做法是进入页面时弹个输入框让用户填昵称存到变量里。let currentNickname ; function initNickname() { const name prompt(请输入你的昵称); currentNickname name name.trim() ? name.trim() : 匿名用户; }这里有个细节用户可能直接点确定但没输入内容或者点取消。所以要判断name是否为空为空就给个默认值匿名用户。别小看这个兜底我见过有人没做这个判断结果昵称显示成null很尴尬。更进一步昵称还可以做长度限制比如最多12个字符和敏感词过滤。Demo阶段做个长度限制就够了。5.2 用户加入/离开的系统通知当有人进入或离开聊天室时广播一条系统消息能让整个聊天室更有活人感。服务端在connection和close事件里各加一段wss.on(connection, (ws) { clients.add(ws); // 这里昵称其实应该由客户端在连接后主动上报 // Demo阶段简化处理先广播一个通用通知 broadcast({ type: system, content: 有新用户加入了聊天室 }, ws); ws.on(close, () { clients.delete(ws); broadcast({ type: system, content: 有用户离开了聊天室 }); }); }); function broadcast(msg, exclude) { const payload JSON.stringify(msg); for (const client of clients) { if (client ! exclude client.readyState WebSocket.OPEN) { client.send(payload); } } }把广播逻辑抽成broadcast函数避免重复代码。前端收到type: system的消息时用不同的样式渲染比如灰色斜体居中和普通聊天消息区分开。5.3 心跳机制为什么连接会假死WebSocket连接理论上会一直保持但现实中经常出现假死——连接对象还在readyState还是OPEN但实际已经收不到数据了。原因可能是中间的网络设备路由器、负载均衡悄悄把空闲连接断掉了但两端都没收到关闭通知。解决办法是心跳机制客户端每隔一段时间比如30秒主动发一个ping消息服务器收到后回一个pong。如果客户端连续几次没收到pong就判定连接已死主动重连。let heartbeatTimer null; let pongTimeout null; function startHeartbeat() { heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); // 设置一个超时如果5秒内没收到pong就认为断了 pongTimeout setTimeout(() { console.warn(心跳超时主动关闭连接); ws.close(); }, 5000); } }, 30000); } ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type pong) { clearTimeout(pongTimeout); return; } renderMessage(msg); };服务端对应处理ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type ping) { ws.send(JSON.stringify({ type: pong })); return; } // ...正常消息处理 });心跳间隔设多少合适太短浪费资源太长发现断线不及时。30秒是业界比较通用的值。这个数字不是拍脑袋定的很多云服务的空闲连接超时时间在60秒左右30秒心跳能保证在超时前刷新连接活跃度。5.4 断线重连让用户无感知连接断了之后不能让用户手动刷新页面。自动重连的逻辑要处理好几件事重连间隔要递增避免服务器刚重启就被大量重连请求打垮、重连成功后要恢复状态。let reconnectAttempts 0; const MAX_RECONNECT_DELAY 30000; function connect() { ws new WebSocket(ws://localhost:8080); ws.onopen () { reconnectAttempts 0; // 重连成功重置计数 startHeartbeat(); appendSystemMessage(已连接到聊天室); }; ws.onclose () { clearInterval(heartbeatTimer); appendSystemMessage(连接已断开正在尝试重连...); // 指数退避1s, 2s, 4s, 8s... 最多30s const delay Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); reconnectAttempts; setTimeout(connect, delay); }; }指数退避这个策略很重要。如果固定1秒重连服务器宕机恢复的瞬间成千上万的客户端同时涌进来直接把服务器又打挂了。指数退避让重连请求分散开给服务器喘息时间。6. 实测中踩过的坑与排查思路6.1 跨域问题前端连不上服务器的常见原因本地开发时如果前端页面是用file://协议直接打开的或者前端跑在3000端口、服务器在8080端口浏览器可能会拦截WebSocket连接。虽然WebSocket的跨域限制比AJAX宽松但Origin头校验还是存在的。排查方法打开浏览器开发者工具的Network面板看WebSocket连接的状态。如果是101 Switching Protocols说明握手成功如果直接失败看Console里的报错信息。解决方式有两种一是让前端页面也由服务器提供用ws库同时起一个静态文件服务二是服务端显式允许跨域。Demo阶段我推荐第一种简单直接。6.2 消息乱序时间戳为什么必须服务器打我遇到过一个问题A用户发的消息在B用户那里显示的时间比C用户发的还晚但实际A是先发的。原因是每个客户端的系统时间不一样如果时间戳由客户端生成就会出现乱序。解决办法就是前面说的时间戳统一由服务器在收到消息时打上。服务器只有一个时钟所有消息的时间基准一致顺序就对了。6.3 中文乱码编码问题排查WebSocket传输的是二进制数据ws库默认会把文本消息按UTF-8处理。但如果你在服务端用了data.toString()而没指定编码某些环境下可能出现中文乱码。稳妥写法是明确指定编码const message data.toString(utf8);或者直接用Buffer拼接。这个问题在Windows环境下更容易出现因为默认编码可能不是UTF-8。我建议所有涉及字符串转换的地方都显式写utf8省得排查。6.4 内存泄漏忘记清理连接集合如果close事件里忘记clients.delete(ws)断开的连接会一直留在集合里。时间一长集合越来越大广播时遍历一堆已经关闭的连接性能越来越差最后服务器内存爆掉。排查方法在connection和close事件里都打印clients.size观察数字是否正常增减。如果只增不减就是漏了清理。这个坑很隐蔽因为Demo阶段用户少、断连少看不出问题。但一旦上线或者做压力测试就会暴露。所以从写第一行代码起就要养成连接加入集合、断开移出集合的对称习惯。6.5 端口占用服务器起不来的排查Error: listen EADDRINUSE: address already in use :::8080这个报错意味着8080端口被别的程序占了。可能是你上次启动的服务器没关干净也可能是别的软件在用这个端口。排查命令不同系统不一样# macOS / Linux lsof -i :8080 # Windows netstat -ano | findstr :8080找到占用进程后要么杀掉它要么换个端口。我习惯在代码里把端口号抽成变量方便随时改const PORT process.env.PORT || 8080; const wss new WebSocket.Server({ port: PORT });这样启动时可以用PORT9090 node server.js临时换端口不用改代码。7. 从Demo到可用产品的扩展方向7.1 私聊功能点对点消息路由群聊是广播私聊就需要精准投递。核心是给每个连接绑定一个用户标识比如昵称或ID维护一个Map结构userId - ws。发私聊消息时根据目标ID找到对应的连接只发给那一个。const userMap new Map(); // 连接建立后等客户端上报昵称 ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type register) { userMap.set(msg.nickname, ws); ws.nickname msg.nickname; return; } if (msg.type private) { const target userMap.get(msg.to); if (target target.readyState WebSocket.OPEN) { target.send(JSON.stringify(msg)); } } });这里要注意昵称可能重复真实产品应该用唯一ID。Demo阶段可以简单处理重名时提示用户换一个。7.2 消息持久化刷新页面后历史还在Demo阶段消息只存在内存里刷新页面就没了。要持久化最简单的方案是存到SQLite或者JSON文件里。每次收到消息追加写入新用户连接时读取最近N条历史发给他。const fs require(fs); const HISTORY_FILE ./history.json; function saveMessage(msg) { let history []; if (fs.existsSync(HISTORY_FILE)) { history JSON.parse(fs.readFileSync(HISTORY_FILE, utf8)); } history.push(msg); // 只保留最近100条 if (history.length 100) history history.slice(-100); fs.writeFileSync(HISTORY_FILE, JSON.stringify(history)); }注意要限制历史条数不然文件会无限增长。真实产品会用数据库加索引Demo用JSON文件足够了。7.3 在线用户列表让聊天室有存在感显示当前在线用户能大幅提升聊天室的体验。实现很简单维护一个用户列表有人加入或离开时广播最新的列表给所有人。function broadcastUserList() { const users Array.from(userMap.keys()); broadcast({ type: userlist, users: users }); }前端收到userlist类型的消息时更新侧边栏的在线列表。这个功能代码量不大但视觉效果提升明显。7.4 部署上线要注意的事本地跑通之后如果想部署到服务器让别人也能访问有几个点要注意。第一协议要升级。如果网站是HTTPS的WebSocket必须用wss://否则浏览器会拦截。这需要在服务器上配置SSL证书用Nginx做反向代理。第二端口要开放。云服务器的安全组默认可能只开了80和443WebSocket用的8080端口需要在安全组里放行。第三进程要守护。直接用node server.js启动关掉终端进程就没了。生产环境要用pm2这类进程管理工具保证崩溃后自动重启。npm install -g pm2 pm2 start server.js --name chat-server pm2 save pm2 startup这几条命令能让服务器在后台稳定运行并且开机自启。8. 我做完这个Demo之后的几点真实体会这个Demo我前前后后写过好几遍每次都有新的收获。第一次写的时候我只关心能不能跑通代码能收发消息就满足了。后来随着理解加深才意识到那些能跑就行的地方恰恰是最值得琢磨的。比如心跳和重连第一次写的时候完全没考虑觉得连接建立了就万事大吉。直到有次把笔记本合盖再打开发现消息发不出去了才明白连接会假死。加上心跳机制之后那种不知道什么时候会断的不安感才消失。再比如消息格式的设计一开始我直接传字符串后来想加时间戳、加昵称、加消息类型发现字符串根本不够用只能推倒重来改成JSON。这让我明白协议设计要留扩展余地哪怕当前用不上也要把结构预留出来。还有一点体会是关于错误处理的。Demo代码最容易忽略的就是各种异常分支JSON解析失败怎么办、连接状态不对怎么办、用户输入为空怎么办。这些分支在顺利的时候永远走不到但一旦出问题没有它们就是灾难。我现在写任何网络相关的代码都会先把错误处理框架搭好再填业务逻辑。最后想说这个Demo虽然叫简易聊天室但它涉及的知识点一点都不简易网络协议、异步编程、状态管理、错误处理、安全防护几乎涵盖了前后端开发的核心能力。把它真正吃透比囫囵吞枣做十个花哨的项目都有用。如果你正在学前端或者想转全栈我强烈建议亲手把这个Demo从零写一遍不要抄遇到问题就查文档、看报错、动手调试。这个过程本身就是最好的学习。
阅读完成 · 觉得有帮助?
咨询建站