
上一次聊 WebSocket 的时候我们还在理论层打转——握手怎么完成、连接状态怎么切换、帧格式里掩码和长度字段是怎么回事。这次换个务实角度直接把它写出来跑一遍。还是那个最经典的需求一个浏览器页面一个服务端用户打开页面就能进聊天室别人发消息我立刻能看到不用刷新页面也不用几秒钟轮询一次。适合刚看完理论想动手的人也适合总觉得“好像懂了但一写就卡壳”的同学。第二篇的核心不是堆功能而是把一条 WebSocket 链路完整走通服务端负责接入和广播H5 页面负责建立连接和渲染消息中间再夹着几个不踩不舒服的坑。先说结论整个项目没有用到任何框架。前端是原生 HTML CSS JavaScript服务端用 Node.js 加一个ws库。之所以这么选是因为 WebSocket 本身就是浏览器和服务器之间一条保活的双向通道框架在这个场景里反而会掩盖底层细节——连接什么时候真正建立、消息以什么格式到达、断线了是什么表现这些只有用原生代码才能看得清楚。等这条链路跑通了再去套 Vue、React 或者 Socket.IO 也就顺理成章因为底层原理已经在手里了。1. 第二篇的破题思路不走全栈框架就抓一条双向通道很多教程一上来就给你一个聊天室项目前后端几十个文件看起来功能很多但跑起来之后你根本不知道哪里是核心。我这里刻意做减法。要实现的目标就三条多个浏览器标签页同时打开任意一个页面发送的消息其他页面都能收到页面顶部显示当前在线人数有人进入或退出时这个数字要自动变化断线之后页面要有明确提示不能静默失败也不能让用户一直对着一个假聊天界面。这三件事听着简单但它们恰好覆盖了 WebSocket 的四个关键能力建立连接握手、双向消息传递、服务端主动推送、连接状态感知。浏览器端的new WebSocket()只负责把通道建起来剩下的事情——怎么解析消息、怎么区分用户、怎么处理服务端主动下发的系统通知——全都要靠我们在代码里自己约定。1.1 为什么不用 Socket.IO 这类封装库ws库只是实现了 WebSocket 协议的服务器端细节本质上和浏览器内置的 WebSocket API 是对应的浏览器发ws://握手请求服务端返回 101 状态码然后这条 TCP 连接就升级成了 WebSocket 连接。ws库帮你处理了协议层的握手、帧收发和底层 socket 管理但业务逻辑——比如消息怎么广播、连接断掉之后集合怎么清理——还得自己写。不用 Socket.IO 的原因更直接Socket.IO 在最上层封装了自动重连、事件广播、房间管理这些东西确实好用但它自己的握手机制和协议格式跟原生 WebSocket 完全不同。如果你用 Socket.IO 练手学到的其实是 Socket.IO 的 API不是 WebSocket 本身。我这篇想让你看到的是浏览器ws://地址背后的原生链路。等这条链路理解透了再回头看 Socket.IO 也就一层窗户纸。1.2 项目结构和验收标准项目只有两个文件硬性要求是它们能在同一个目录下直接跑起来chat-demo/ ├── server.js // WebSocket 服务端 └── index.html // 浏览器端页面验收标准很简单终端执行node server.js启动服务然后用浏览器打开index.html开三个标签页在任意一个输入框发一句话另外两个页面要在 1 秒之内显示出来。如果有人关掉了一个标签页剩下的页面顶部在线人数要自动减一。就这么简单。能跑通这三条WebSocket 的日常核心用法就已经拿下了。2. 先搭服务端连接管理、广播推送与断线清理WebSocket 服务端是整个链路的数据中枢。所有客户端都直连这个服务端消息发送方向是“A 客户端 → 服务端 → 所有其他客户端”。如果服务端不维护一份“当前有哪些连接还活着”的名单广播就无从谈起。2.1 服务端要做的事拆开就三件第一件事维护连接集合。每来一个客户端WebSocketServer 就会触发一次connection事件我们把这个连接的实例存进一个Set。用Set而不是数组是因为删除操作方便——客户端断开时直接从集合里移除对应连接即可。第二件事消息广播。收到某个客户端的消息要把这条消息转发给集合里的所有其他客户端。这里有两个细节容易翻车一是不能原样转发二进制帧就完事得在业务层定义一个 JSON 结构把消息类型、发送者、内容、时间都装进去二是遍历集合时每个连接在发送前都要判断readyState因为集合里可能有已经断开但还没来得及清理的僵尸连接。第三件事断线感知与动态上下线通知。浏览器标签页被关掉连接会触发close事件。在这个事件里把连接从集合中移除同时广播一条“xxx 离开聊天室”的系统消息和最新在线人数。2.2 服务端代码基于 ws 库的完整实现// server.js const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080 }); // 用一个 Set 保存所有存活连接 const clients new Set(); wss.on(connection, (ws, request) { // 给连接打一个临时编号方便日志排查 const id ${request.socket.remoteAddress}:${Date.now()}; ws.id id; clients.add(ws); // 1. 新用户进入给当前连接回一条欢迎消息 ws.send(JSON.stringify({ type: system, content: 欢迎进入聊天室, time: Date.now() })); // 2. 广播给所有其他客户端有人进来了 最新人数 broadcast({ type: system, content: 新用户加入当前在线 ${clients.size} 人, time: Date.now() }, ws); // 3. 处理收到的消息 ws.on(message, (data) { let parsed; try { parsed JSON.parse(data); } catch (e) { // 非 JSON 数据直接丢弃不能让脏数据拖垮整条链路 return; } if (parsed.type chat) { broadcast({ type: chat, name: parsed.name || 匿名用户, content: parsed.content, time: Date.now() }, null); } }); // 4. 连接断开时从 Set 里移除并广播离开消息 ws.on(close, () { clients.delete(ws); broadcast({ type: system, content: 有用户离开当前在线 ${clients.size} 人, time: Date.now() }, null); }); // 5. 异常兜底出现 error 时打印日志避免进程直接挂掉 ws.on(error, (err) { console.error(连接异常:, err.message); }); }); function broadcast(data, excludeWs) { const payload JSON.stringify(data); for (const client of clients) { // 排除消息来源不需要把用户自己的消息转一圈再发回来 if (client excludeWs) continue; // 只给就绪状态的连接发消息 if (client.readyState client.OPEN) { client.send(payload); } } } console.log(WebSocket 服务已启动监听端口 8080);2.3 启动验证用浏览器直接看服务端是不是活的终端里运行node server.js看到“WebSocket 服务已启动监听端口 8080”后服务端就已经在跑。验证服务端有没有起来可以打开 Chrome 开发者工具的 Console直接执行一段前端代码去连接const ws new WebSocket(ws://localhost:8080); ws.onopen () console.log(连接成功); ws.onmessage (e) console.log(收到消息:, e.data);如果能打印出“连接成功”和那条“欢迎进入聊天室”的 JSON说明握手正常、服务端消息下发也正常。这一步通过了再往下写页面才不会两眼一抹黑。2.4 服务端端口和监听地址的细节端口我选了 8080没有特殊原因避开常见端口而已。需要注意的是new WebSocketServer({ port: 8080 })默认监听在所有网络接口上如果你只想本机调试建议改成host: 127.0.0.1。还有一个小坑如果前端页面用http://打开服务端连接地址写成ws://是没问题的但如果你把前端页面部署到了 HTTPS 站点WebSocket 地址就必须是wss://否则浏览器会直接拦截混合内容。这个后面踩坑部分还会展开。3. H5 页面从连接建立到消息渲染的完整实现服务端通了接下来是前端的活。这里标题里写的“H5CSSJS”就体现出来了一个 HTML 文件里面塞了页面结构和样式再写一段原生 JavaScript 管理 WebSocket 生命周期。3.1 页面骨架和样式页面结构不复杂一个头部栏显示标题和在线人数中间是消息滚动区域底部是输入框加发送按钮。CSS 部分我只做两件关键事让消息区域可以滚动以及区分系统消息和聊天消息的视觉样式。!-- index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleWebSocket 聊天 Demo/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; background: #f5f6fa; } .chat-app { max-width: 720px; margin: 30px auto; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; } .chat-header { padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; } .chat-header .title { font-size: 18px; font-weight: 600; color: #2c3e50; } .chat-header .status { font-size: 13px; color: #95a5a6; } .chat-header .status.online { color: #27ae60; } #messages { height: 420px; overflow-y: auto; padding: 16px 20px; background: #fafbfc; } .msg { margin-bottom: 12px; } .msg .meta { font-size: 12px; color: #aaa; margin-bottom: 3px; } .msg .bubble { display: inline-block; padding: 8px 12px; border-radius: 8px; background: #e2e8f0; max-width: 70%; line-height: 1.5; word-break: break-word; } .msg.mine .bubble { background: #3498db; color: #fff; } .msg.system .bubble { background: transparent; color: #7f8c8d; font-size: 13px; text-align: center; width: 100%; } .chat-input { display: flex; gap: 10px; padding: 14px 20px; border-top: 1px solid #eee; } .chat-input input { flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 8px; outline: none; font-size: 14px; } .chat-input button { padding: 10px 20px; border: none; background: #3498db; color: #fff; border-radius: 8px; cursor: pointer; font-size: 14px; } .chat-input button:hover { background: #2980b9; } /style /head body div classchat-app div classchat-header span classtitleWebSocket 即时聊天/span span idonlineCount classstatus连接中.../span /div div idmessages/div div classchat-input input idcontent typetext placeholder输入消息回车发送 button idsendBtn发送/button /div /div script // 见下方 JavaScript 部分 /script /body /html3.2 原生 JavaScript连接生命周期管理前端 JavaScript 部分拆成四个环节创建连接、监听事件、解析数据、渲染消息。状态管理用readyState就够了——浏览器 WebSocket 对象自带这个属性它只有四个状态CONNECTING(0)、OPEN(1)、CLOSING(2)、CLOSED(3)。const onlineCountEl document.getElementById(onlineCount); const messagesEl document.getElementById(messages); const contentInput document.getElementById(content); const sendBtn document.getElementById(sendBtn); let ws null; let userName 用户 Math.floor(Math.random() * 1000); function connect() { // 连接地址要和服务端保持一致 ws new WebSocket(ws://localhost:8080); ws.onopen () { onlineCountEl.textContent 已连接; onlineCountEl.classList.add(online); }; ws.onmessage (event) { let data; try { data JSON.parse(event.data); } catch (e) { // 服务端发来了一段无法解析的文本这里直接忽略 return; } if (data.type system) { appendMessage({ name: 系统, content: data.content, time: data.time, isSystem: true }); } else if (data.type chat) { appendMessage({ name: data.name, content: data.content, time: data.time, isMine: data.name userName }); } }; ws.onclose () { onlineCountEl.textContent 连接断开; onlineCountEl.classList.remove(online); }; ws.onerror () { onlineCountEl.textContent 连接异常; appendMessage({ name: 系统, content: 与服务端的连接出现异常请检查服务端是否启动, isSystem: true }); }; } function appendMessage({ name, content, time Date.now(), isMine false, isSystem false }) { const item document.createElement(div); item.className msg (isMine ? mine : ) (isSystem ? system : ); if (!isSystem) { const meta document.createElement(div); meta.className meta; meta.textContent ${name} · ${formatTime(time)}; item.appendChild(meta); } const bubble document.createElement(div); bubble.className bubble; bubble.textContent content; item.appendChild(bubble); messagesEl.appendChild(item); messagesEl.scrollTop messagesEl.scrollHeight; } function formatTime(t) { const d new Date(t); const pad (n) String(n).padStart(2, 0); return ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}; } function sendMessage() { const content contentInput.value.trim(); if (!content) return; if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: chat, name: userName, content })); contentInput.value ; } else { appendMessage({ name: 系统, content: 连接已断开无法发送消息, isSystem: true }); } } sendBtn.addEventListener(click, sendMessage); contentInput.addEventListener(keydown, (e) { if (e.key Enter) { e.preventDefault(); sendMessage(); } }); // 页面加载即建立连接 connect();3.3 消息渲染的三个核心细节第一个细节是isMine标记。用户自己发的消息显示成蓝色靠左别人的消息显示成灰色靠左。很多人都想做靠左靠右区分但那个前提是服务端替每条消息标了唯一 ID前端要靠对比 ID 才能准确判断。这个 Demo 里我用data.name userName这种弱判断消息少够用但坑也很明显——如果两个人起了同一个名字消息归属就乱了。这个后面讲协议设计时再展开。第二个细节是messagesEl.scrollTop messagesEl.scrollHeight。每次渲染完消息都自动把滚动条拉到底否则新消息会一直在视线之外。这个操作要在appendChild之后执行因为scrollHeight是拿到渲染完成后真实内容的高度。第三个细节是WebSocket.OPEN的判断。Mozilla 的标准写法里可以用WebSocket.OPEN这个常量也可以用数字1两者等价。发送前判断连接状态是为了避免用户在连接没建立好或者已经断开时点击发送按钮向一个不存在的通道发送数据。3.4 页面首次连接失败的表现如果服务端没启动浏览器拿到ws://localhost:8080会发现端口不通。此时onerror和onclose都会触发页面上会显示“连接异常”“连接断开”。很多人第一次跑这种项目看到状态栏一行红字就慌了其实这是预期内的WebSocket 连接失败不会让页面崩溃只会触发错误事件和关闭事件代码里把这两个事件的 UI 反馈做好就可以了。4. 踩坑实录本地调试 WebSocket 最容易翻车的四个细节这部分算是我实际调试时印象最深的地方。理论上链路很清晰但真跑起来问题全出在一些很不起眼的地方。4.1 用 http 协议去连 ws 地址最基础也最容易犯的错误是把前端的连接地址写成http://localhost:8080而不是ws://localhost:8080。浏览器会直接抛一个SyntaxError: Failed to construct WebSocket: The URLs scheme must be ws or wss. 这个错误你在 Console 里一眼能看到。解决方式就是前端地址用ws://服务端监听用端口两者协议一致即可。4.2 服务端广播时没判断 readyState服务端broadcast函数里如果少了client.readyState 1这个判断你会间歇性遇到一个诡异问题某个标签页已经关了但服务端向它发送消息时明显变慢甚至进程直接报错。原因很简单close事件从触发到真正把连接从Set里移出去是有时间窗口的这个窗口内连接可能已经半死。所以在写广播逻辑时每个连接发送前先检查readyState这是服务端代码的基本素养。下面是这个问题的完整排查链路值得看一眼现象可能的根因排查方法解决办法消息时灵时不灵僵尸连接还在集合里在broadcast里打印每个client.readyState发送前判断OPEN其他标签页收不到消息消息体 JSON 解析失败被丢弃服务端打印data原文前端用JSON.stringify严格封装页面显示连接失败服务端没启动或端口被占用终端运行node server.js看报错检查端口冲突换 8080关闭一个标签页其他人看到人数没减close事件没绑定或集合没删干净服务端日志观察connection与close是否成对出现确认clients.delete(ws)4.3 连接事件、消息事件的顺序问题有读者可能觉得代码里ws.onopen写在new WebSocket()之后会不会太晚了实际上不会。new WebSocket()是异步握手监听器的绑定发生在握手完成之前是来得及的。但这个顺序有一个需要注意的场景如果服务端特别快在connection事件里立刻send()了一条欢迎消息而前端还没来得及绑定onmessage监听那这条欢迎消息可能丢。理论上 TCP 层会缓存不会丢但要做到稳妥我习惯在onopen里主动向服务端要一次最新状态。4.4 直接把服务端代码复制到生产环境这也是新手特别容易踩的坑。本地是ws://localhost:8080一旦把前端代码部署到https://域名下控台会报一堆混合内容警告浏览器会拦截ws://的请求。这个没有捷径生产环境必须让服务端走 TLS前端连接地址改成wss://。如果你的服务端前面套了 NginxNginx 里要加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;这两行缺失服务端握手永远过不去浏览器只会反复重连然后失败。4.5 用同一个端口跑 HTTP 和 WebSocket 的问题在这个 Demo 里index.html是用file://协议直接打开的没有走 HTTP 服务所以不存在同源冲突。但如果你想用http://localhost:8080直接打开页面而 8080 又被 WebSocket 占用了就会撞端口。解决办法有几种一是把静态页面丢给一个独立 HTTP 服务跑WebSocket 换端口二是用同一个 Node 服务既托管静态页面又挂 WebSocket server。第二种方式写起来也不复杂const http require(http); const { WebSocketServer } require(ws); const server http.createServer((req, res) { // 放一个最简单的静态文件响应 res.end(hello); }); const wss new WebSocketServer({ server }); server.listen(8080);这种方式的好处是同一个握手端口浏览器的ws://localhost:8080和http://localhost:8080不冲突因为 WS 升级请求和普通 HTTP 请求在 TCP 层面靠协议头区分。条件允许的话建议直接采用这种方案省掉静态服务器和 WS 服务端之间的端口协调工作。5. 消息协议设计客户端和服务端要提前约法三章这个章节看起来不起眼实际上是整个 Demo 能不能进化成真正聊天室的关卡。前面代码里频繁出现type、name、content、time这些字段这就是协议。前后端对一条消息的“长相”达成一致代码才能各干各的活。5.1 为什么一定要 JSON 结构WebSocket 原生消息可以是文本也可以是二进制帧。如果客户端直接发一段裸文本大家好服务端原样广播出去前端收到的也是大家好初跑能通但实现不了任何逻辑——你分不清这是聊天内容、系统通知还是心跳包。定义 JSON 结构的目的是给消息加上“信封”type字段决定这条消息干什么用其他字段决定消息内容是什么。就像快递箱上写着这是“文件”还是“生鲜”派件的人才知道怎么处理。5.2 消息类型设计这个 Demo 里定义了四种typetype方向用途载荷字段system服务端 → 客户端上下线通知、欢迎语、系统状态content、timechat客户端 → 服务端 → 其他客户端聊天消息name、content、timeheartbeat双向保活检测timeuserList服务端 → 客户端在线人数/用户列表count、userssystem和其它类型的区别在于没有name字段因为系统消息不是某个用户发的。前端渲染时看到type system就走居中灰色小字的分支看到type chat就走气泡分支。这个分支判断写得越清晰后面加新消息类型就越容易。5.3 心跳保活与断线检测WebSocket 本身有 TCP 层面的保活但浏览器端杀掉标签页、笔记本电脑合盖休眠都可能让连接处于“看似存在实则死了”的状态。这时候服务端往这条连接发消息TCP 层会超时或者收到 RST但这个过程有时候长达几十秒甚至几分钟。要缩短这个感知时间需要心跳机制。ws库自带一套基于 ping/pong 的心跳示例服务端每隔 30 秒给所有客户端发 ping客户端在协议层自动回 pong。服务端用isAlive标记记录每个连接是否在最近一个周期内回应过 pong没回应的就terminate()掉。这套机制不用改前端代码因为 ping/pong 是 WebSocket 协议内置的控制帧浏览器会自动回复。前端也可以自己做应用层心跳每隔 20 秒发一条{ type: heartbeat, time: Date.now() }服务端收到后回一条 pong。这样做的意义是当服务端挂了、网络闪断时前端能在较短周期内感知连接失效从而触发重连逻辑。注意前端onclose在纯网络闪断场景下不一定立刻触发有时候要等很久所以我一般会挂一个setInterval定时检查readyState超过 N 秒没有任何消息到达就主动close()再重连。5.4 服务端广播范围的两种模型有一种常见误解WebSocket 天然就是“全房间广播”。其实不是。wss收到的每条消息只有一个来源往哪里发完全由业务代码说了算。当前的broadcast函数把所有客户端都发一遍这叫全局广播。后面如果要做群聊、私聊有两个扩展方向房间模型客户端发送{ type: join, room: 前端交流 }服务端维护Map房间名, Set连接广播时只遍历这个房间的连接集合私聊模型每条消息加to字段目标用户 ID服务端遍历连接集合找到目标用户那条连接单独send()其他连接不理会。从“全局广播”到“房间广播”代码层面的改动量非常小但设计上要提前留好type和载荷格式否则后面改协议时会牵一发动全身。5.5 JSON 解析的防御性处理前端onmessage里我写了一段try...catch包住JSON.parse看起来多余实则是防止服务端或其他中间层发送非 JSON 数据造成页面报错。WebSocket 的message事件不关心内容是不是合法 JSON只要是一个完整帧就会触发。如果服务端某个时刻手滑发了一段普通文本JSON.parse会抛异常而onmessage里的异常一旦不捕获整段脚本会中断。后面所有消息都渲染不了那种故障排查起来非常痛苦。只要是从网络上来的数据解析前一律防御性包裹。6. 实测一轮二十个连接同时在线服务端表现如何跑通基本链路之后我顺手做了个压力测试。也不太严格就是开了 20 个标签页同时连这个服务端然后每页每秒发一条消息持续五分钟看服务端进程的内存变化和消息延迟表现。6.1 测试数据与观察结果指标结果最大在线连接数20标签页多开每 5 秒消息总量约 100 条消息平均延迟小于 50ms本地回环网络服务端 Node 进程内存从约 35MB 涨到约 58MB趋于平稳断线标签页数3 个手动关闭在线人数正确递减这个结果说明在消息量不大的场景下一套原生 WebSocket 服务完全没有性能压力。瓶颈大概率会出现在两个地方一是单条消息广播时遍历的集合规模二是消息体 JSON 序列化和反序列化消耗的 CPU。对于日常小应用几千个连接以内这套结构都是够用的。6.2 断线重连把“用户自动掉线”变成“悄然恢复”测试里我故意关掉 Wi-Fi 再打开然后观察页面表现。很遗憾页面在断网期间没有任何反应——onclose没有触发readyState一直停留在OPEN。这就是前文说过的“死连接”问题。加一段自动重连逻辑才是更完整的方案let retryCount 0; function connect() { ws new WebSocket(ws://localhost:8080); ws.onopen () { retryCount 0; onlineCountEl.textContent 已连接; }; ws.onclose () { onlineCountEl.textContent 连接断开重试中...; const delay Math.min(1000 * Math.pow(2, retryCount), 10000); retryCount; setTimeout(connect, delay); }; }这里用了指数退避策略第一次断开 1 秒后重连第二次 2 秒第三次 4 秒封顶 10 秒。如果不封顶退避时间会越来越长用户等很久都回不来。这个策略看起来简单但它能避免服务端短暂重启时几十个客户端同时猛冲上来造成连接风暴——错峰重连要好得多。6.3 云服务器上跑的额外注意点如果这个 Demo 要丢到云服务器上跑要注意云平台的安全组/防火墙是否放行了 8080 端口。很多人本地跑得好好的一上服务器别人就连不上八成是安全组只开了 22 和 80没有开 WebSocket 用的端口。有些人图省事直接关防火墙这个做法我不太推荐——开最小权限端口就好。6.4 这个 Demo 后续可以怎么扩展代码写到这里已经具备了聊天的核心链路。再往前走有几个很自然的扩展方向用户身份聊天消息里加一个随机昵称这个我现在已经做了但更完整的是做一个简单的登录页把昵称和头像颜色存到localStorage历史消息补发服务端用一个数组暂存最近 100 条聊天记录新用户连接后先把历史记录发给他消息已读回执消息体里加msgId接受方收到后回一条 ACK发送方可以做“已送达”标记文件/图片发送二进制帧可以把图片 base64 编码后放进 JSON 编码传输或者把content扩展成一个包含 MIME 类型的结构。我在实际测试这套代码时最大的感触是WebSocket 本身不难难的是把“连接”当成一个需要全程呵护的对象来对待——什么时候建立什么时候销毁什么时候重试什么时候广播每一步都得有明确逻辑。把这些边界条件处理干净代码自然就稳了。下一篇文章如果继续写我打算顺着“房间私聊”这个方向做一版更接近真实产品的聊天室把现在的一些弱判断换成更严谨的身份系统。