ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写WebSocket服务与客户端:从握手帧到心跳重连的完整实现

手写WebSocket服务与客户端:从握手帧到心跳重连的完整实现 简介本资源是一个基于Java Web的WebSocket双向通信完整示例项目面向Web开发初学者及后端工程师用于快速理解并实践WebSocket协议在浏览器与服务器间建立全双工实时通信的核心机制。项目采用标准Eclipse Java EE工程结构共19个文件涵盖pom.xml依赖配置、.project/.classpath工程元数据、.settings下的IDE偏好设置如JS/Java编译器、验证规则、src目录下的Java服务端逻辑、webapp中的JSP前端页面以及lib目录下的websocket-api.jar核心库和readme.txt说明文档压缩包仅41KB轻量易导入。目前已有391人学习下载读者可直接部署运行观察握手流程、消息帧收发及连接生命周期管理掌握从环境配置、服务端Endpoint编写到前端JavaScript连接调用的全流程实现特别适合理解替代HTTP轮询的实时交互方案。1. WebSocket Demo不是“跑个网页弹窗”就完事而是验证双向实时通道的最小可信闭环很多人搜“WebSocket demo”点开第一个 CodePen 或 GitHub gist粘贴几行 JS 和 Node.js 代码页面上出现 “Connected” 就以为搞定了。但真实项目里这个“demo”一旦上线第二天就会被 QA 找上门为什么弱网下连接秒断为什么用户切后台再回来就收不到消息为什么服务端发了 10 条客户端只收到 3 条——这些不是玄学是 WebSocket 协议层、传输层、应用层三重交叠的隐性约束没被 demo 覆盖到。本文讲的WebSocket Demo是指能经受住断网重连、心跳保活、消息乱序容忍、跨域 Origin 校验、服务端连接池压测这五项基础压力的最小可验证单元。它不追求 UI 美观但必须暴露所有关键路径的可观测性日志、状态码、帧类型、RTT它不依赖任何框架封装所有握手、ping/pong、close 帧都手动构造和解析。适合刚写完ws.onopen () console.log(ok)就卡住的前端同学也适合后端在接入第三方 WebSocket SDK 前想亲手摸清底层行为的工程师。你不需要部署 K8s 集群一台带 Docker 的笔记本 一个 Chrome DevTools 就够。2. 从零手写服务端用原生 Node.js 实现可调试的 WebSocket 服务非 Express 中间件WebSocket 不是 HTTP 的子集而是一个独立协议ws:///wss://它复用了 HTTP 的 Upgrade 机制完成握手但后续通信完全脱离 HTTP 栈。这意味着任何基于 Express 的ws中间件如express-ws都会隐藏握手细节导致你无法观察Sec-WebSocket-Accept计算是否正确、无法拦截非法Origin请求、无法控制Sec-WebSocket-Protocol协商逻辑。真正的 demo 必须直面net.Socket层。2.1 手动解析 HTTP Upgrade 请求并生成响应头WebSocket 握手本质是一次 HTTP 1.1 的协议升级请求。服务端必须严格校验Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key并用固定算法生成Sec-WebSocket-Accept。以下代码跳过所有框架直接监听 TCP 端口捕获原始字节流const net require(net); const crypto require(crypto); const server net.createServer((socket) { let buffer Buffer.alloc(0); socket.on(data, (chunk) { buffer Buffer.concat([buffer, chunk]); // 检查是否已收到完整 HTTP 头两个 \r\n\r\n const headerEnd buffer.indexOf(\r\n\r\n); if (headerEnd -1) return; const headerStr buffer.slice(0, headerEnd).toString(); const lines headerStr.split(\r\n); // 提取关键字段 let key ; let origin ; for (const line of lines) { if (line.startsWith(Sec-WebSocket-Key:)) { key line.split(: )[1].trim(); } else if (line.startsWith(Origin:)) { origin line.split(: )[1].trim(); } } // ✅ 关键校验Origin 白名单生产环境绝不能写 * if (![http://localhost:3000, https://myapp.com].includes(origin)) { socket.write(HTTP/1.1 403 Forbidden\r\n\r\n); socket.destroy(); return; } // ✅ Sec-WebSocket-Accept 计算key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 → SHA1 → base64 const acceptKey crypto .createHash(sha1) .update(key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) .digest(base64); // 构造合法 WebSocket 握手响应 const response [ HTTP/1.1 101 Switching Protocols, Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Accept: ${acceptKey}, Sec-WebSocket-Version: 13, // 可选协商子协议如 chat, json // Sec-WebSocket-Protocol: chat, ].join(\r\n); socket.write(response); // ✅ 握手成功后进入 WebSocket 帧解析模式见 2.2 setupWebSocketFrameHandler(socket); }); socket.on(error, (err) { console.error([TCP] Socket error:, err.message); }); }); server.listen(8080, () { console.log(Raw WebSocket server listening on ws://localhost:8080); });提示这段代码不依赖ws库完全暴露握手过程。Sec-WebSocket-Accept的计算必须精确包括空格、大小写、固定 GUID少一个字符或换行符都会导致浏览器报Error during WebSocket handshake: Unexpected response code: 400。这是新手最常翻车的第一步。2.2 手动解析 WebSocket 帧理解 MASK、FIN、OPCODE 的真实含义握手成功后TCP 连接并未关闭而是切换为 WebSocket 帧格式通信。浏览器发送的数据默认被 MASK掩码加密RFC 6455 强制要求客户端 mask服务端无需 mask且帧结构包含 FIN是否最后一帧、RSV保留位、OPCODE数据类型、PAYLOAD LEN负载长度、MASKING KEY掩码密钥、PAYLOAD DATA实际数据。以下代码实现最简帧解析仅处理文本帧OPCODE0x1function setupWebSocketFrameHandler(socket) { let frameBuffer Buffer.alloc(0); let expectingLength 0; let maskingKey null; let payloadOffset 0; socket.on(data, (chunk) { frameBuffer Buffer.concat([frameBuffer, chunk]); // 至少需要 2 字节读取基本帧头 if (frameBuffer.length 2) return; const firstByte frameBuffer[0]; const secondByte frameBuffer[1]; const fin (firstByte 0x80) ! 0; // FIN bit const opcode firstByte 0x0F; // OPCODE (0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong) if (opcode 0x8) { // ✅ 收到 Close 帧必须回 Close 帧并关闭连接 sendCloseFrame(socket, 1000, Normal closure); return; } if (opcode 0x9) { // ✅ 收到 Ping 帧必须立即回 Pong 帧payload 相同 const payloadLen secondByte 0x7F; if (frameBuffer.length 2 (payloadLen 125 ? 0 : payloadLen 65535 ? 2 : 8) payloadLen) { const payloadStart 2 (payloadLen 125 ? 0 : payloadLen 65535 ? 2 : 8); const payload frameBuffer.slice(payloadStart, payloadStart payloadLen); sendPongFrame(socket, payload); } return; } // 解析 PAYLOAD LEN处理 126/127 扩展长度 let payloadLen secondByte 0x7F; let headerLen 2; if (payloadLen 126) { if (frameBuffer.length 4) return; payloadLen frameBuffer.readUInt16BE(2); headerLen 4; } else if (payloadLen 127) { if (frameBuffer.length 10) return; payloadLen frameBuffer.readUInt32BE(6); // 注意RFC 规定 64-bit length 用大端但实际只取低32位 headerLen 10; } // 检查 MASK bit客户端必须为 1 const hasMask (secondByte 0x80) ! 0; if (!hasMask) { console.warn(Client sent unmasked frame — violating RFC 6455); socket.destroy(); return; } // 读取 MASKING KEY4 字节 if (frameBuffer.length headerLen 4) return; maskingKey frameBuffer.slice(headerLen, headerLen 4); headerLen 4; // 检查 payload 是否收全 if (frameBuffer.length headerLen payloadLen) return; // ✅ 解密 payloadXOR 每个字节与对应 maskingKey 字节 const payload frameBuffer.slice(headerLen, headerLen payloadLen); const unmasked Buffer.alloc(payloadLen); for (let i 0; i payloadLen; i) { unmasked[i] payload[i] ^ maskingKey[i % 4]; } // ✅ 处理文本帧UTF-8 解码 if (opcode 0x1) { try { const text unmasked.toString(utf8); console.log([WS] Received text:, text); // 回复 echo演示双向 sendTextFrame(socket, Echo: ${text}); } catch (e) { console.error([WS] Invalid UTF-8 in text frame:, e); sendCloseFrame(socket, 1007, Invalid UTF-8); } } // 清空已处理部分 frameBuffer frameBuffer.slice(headerLen payloadLen); }); } function sendTextFrame(socket, text) { const payload Buffer.from(text, utf8); const len payload.length; let header; if (len 125) { header Buffer.alloc(2); header[0] 0x81; // FIN TEXT OPCODE header[1] len; } else if (len 65535) { header Buffer.alloc(4); header[0] 0x81; header[1] 126; header.writeUInt16BE(len, 2); } else { header Buffer.alloc(10); header[0] 0x81; header[1] 127; // RFC 要求 64-bit length但 JS Number 最大安全整数 2^53-1此处简化用 32-bit header.writeUInt32BE(0, 2); // high 32 bits header.writeUInt32BE(len, 6); // low 32 bits } socket.write(Buffer.concat([header, payload])); } function sendCloseFrame(socket, code 1000, reason ) { const reasonBuf Buffer.from(reason, utf8); const payload Buffer.alloc(2 reasonBuf.length); payload.writeUInt16BE(code, 0); reasonBuf.copy(payload, 2); const header Buffer.from([0x88, payload.length]); // FIN CLOSE OPCODE socket.write(Buffer.concat([header, payload])); } function sendPongFrame(socket, payload) { const header Buffer.from([0x8A, payload.length]); // FIN PONG OPCODE socket.write(Buffer.concat([header, payload])); }参数说明0x81FIN1单帧 OPCODE0x1文本帧0x88FIN1 OPCODE0x8关闭帧0x8AFIN1 OPCODE0xAPong 帧掩码解密必须逐字节 XOR且maskingKey是 4 字节循环使用i % 4关闭帧必须携带 2 字节状态码如1000正常关闭1001离线1002协议错误这段代码让你彻底看清为什么 WebSocket 不能像 HTTP 那样直接socket.write(hello)因为所有数据必须包装成严格格式的帧且客户端强制掩码。这也是很多“demo”在真实网络中丢消息的根本原因——没处理分片FIN0、没校验 OPCODE、没正确解密。3. 客户端实战用原生 WebSocket API 实现带心跳、重连、错误隔离的健壮连接浏览器WebSocketAPI 表面简单实则暗坑密布onerror不触发、onclose无明确原因、readyState滞后、send()在连接未建立时静默失败。一个合格的 demo 客户端必须解决这四大问题。3.1 手动实现心跳保活与超时判定WebSocket 连接在 NAT、防火墙、移动基站下极易被静默断开表现为onclose但event.code为 1006。仅靠服务端 ping 不够客户端必须主动发 ping 并等待 pong 响应。以下代码实现双心跳服务端 ping → 客户端 pong客户端 ping → 服务端 pong并设置超时class ReliableWebSocket { constructor(url, options {}) { this.url url; this.reconnectDelay options.reconnectDelay || 1000; this.heartbeatInterval options.heartbeatInterval || 30000; // 30s this.heartbeatTimeout options.heartbeatTimeout || 10000; // 10s this.maxReconnectAttempts options.maxReconnectAttempts || 5; this.socket null; this.heartbeatTimer null; this.heartbeatTimeoutTimer null; this.reconnectAttempts 0; this.isClosing false; } connect() { this.socket new WebSocket(this.url); // ✅ onopen仅当 readyState OPEN 时才真正可用 this.socket.onopen (event) { console.log([WS] Connected); this.reconnectAttempts 0; this.startHeartbeat(); }; // ✅ onmessage区分文本/二进制避免 JSON.parse 错误 this.socket.onmessage (event) { if (event.data instanceof Blob) { console.warn([WS] Received Blob — not handled in this demo); return; } const data event.data; if (typeof data string) { try { const parsed JSON.parse(data); console.log([WS] Received JSON:, parsed); } catch (e) { console.log([WS] Received plain text:, data); } } else { console.log([WS] Received binary:, data); } }; // ✅ onerror此事件不表示连接失败仅表示底层 I/O 错误如 DNS 失败且不提供具体信息 this.socket.onerror (error) { console.error([WS] Low-level error (not connection loss):, error); // 不在此处重连onerror 后通常伴随 onclose由 onclose 统一处理 }; // ✅ onclose唯一可靠的断开信号但需结合 readyState 判断是否为预期关闭 this.socket.onclose (event) { console.log([WS] Closed: code${event.code}, reason${event.reason}, wasClean${event.wasClean}); // 如果是主动关闭this.isClosing true不重连 if (this.isClosing) return; // 如果是异常关闭wasClean false 或 code 为 1006/1011触发重连 if (!event.wasClean || [1006, 1011, 1012, 1013].includes(event.code)) { this.reconnect(); } }; } startHeartbeat() { // ✅ 清除旧定时器防止重复启动 if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); if (this.heartbeatTimeoutTimer) clearTimeout(this.heartbeatTimeoutTimer); // ✅ 每 30s 发送一次 ping this.heartbeatTimer setInterval(() { if (this.socket this.socket.readyState WebSocket.OPEN) { // 发送 ping 帧OPCODE0x9payload 可为空 this.socket.send(JSON.stringify({ type: ping, ts: Date.now() })); // ✅ 启动超时检测10s 内没收到 pong 就判定断连 this.heartbeatTimeoutTimer setTimeout(() { if (this.socket this.socket.readyState WebSocket.OPEN) { console.warn([WS] Heartbeat timeout — forcing reconnect); this.socket.close(4000, Heartbeat timeout); } }, this.heartbeatTimeout); } }, this.heartbeatInterval); } reconnect() { if (this.reconnectAttempts this.maxReconnectAttempts) { console.error([WS] Max reconnect attempts reached); return; } this.reconnectAttempts; console.log([WS] Reconnecting... attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}); // ✅ 使用指数退避避免雪崩 const delay Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000); setTimeout(() { if (this.socket this.socket.readyState ! WebSocket.CONNECTING) { this.socket.close(); // 确保旧连接释放 } this.connect(); }, delay); } send(data) { // ✅ 安全发送检查 readyState失败时缓存或报错 if (!this.socket || this.socket.readyState ! WebSocket.OPEN) { console.warn([WS] Send failed: socket not open (state, this.socket?.readyState, )); return false; } try { this.socket.send(data); return true; } catch (e) { console.error([WS] Send error:, e); return false; } } close() { this.isClosing true; if (this.socket this.socket.readyState WebSocket.OPEN) { this.socket.close(1000, User initiated close); } if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); if (this.heartbeatTimeoutTimer) clearTimeout(this.heartbeatTimeoutTimer); } } // 使用示例 const ws new ReliableWebSocket(ws://localhost:8080); ws.connect(); // 发送消息 ws.send(JSON.stringify({ action: login, user: test })); // 页面卸载前关闭 window.addEventListener(beforeunload, () { ws.close(); });关键设计点onerror仅记录不重连它和onclose是正交事件onerror可能发生在连接建立前onclose才是连接终结信号onclose.event.wasClean false是判断非正常断开的核心依据如网络中断、服务端崩溃心跳超时必须用setTimeout单独控制不能依赖pong回调因为pong可能永远不来send()前必须检查readyState OPEN否则静默失败这是最隐蔽的坑3.2 跨域 Origin 校验与服务端拒绝策略开发时localhost:3000访问localhost:8080是跨域浏览器会自动带上Origin: http://localhost:3000。服务端必须显式校验 Origin否则任意网站都能连接你的 WebSocket 服务构成 CSRF 风险。上文服务端代码已实现白名单校验这里补充客户端规避方案注意WebSocket构造函数不支持设置Origin头这是浏览器安全限制所以 Origin 值完全由浏览器根据当前页面 URL 自动填充。你无法伪造也无法绕过。唯一可控的是服务端校验逻辑。若测试需临时放行可在服务端将Origin校验改为宽松模式仅检查协议域名忽略端口// 替换原校验逻辑 const originUrl new URL(origin); const allowedOrigins [http://localhost, https://myapp.com]; const isAllowed allowedOrigins.some(allowed originUrl.origin allowed || originUrl.hostname new URL(allowed).hostname );4. 避坑指南WebSocket Demo 中 5 个血泪经验总结现象 → 原因 → 解决WebSocket 的坑不在协议复杂而在它游走于 HTTP 和 TCP 之间又自带应用层语义。以下 5 条是我在 12 个线上项目中踩出的硬核结论每一条都附带可复现的验证方法。4.1 现象Chrome DevTools Network 面板看不到 WebSocket 通信内容原因DevTools 的 Network 面板默认只显示 HTTP 流量WebSocket 流量需在Console 面板输入monitorEvents(ws, message)或打开Application → Frames标签页才能看到帧级数据。更致命的是某些代理工具如 Charles默认不解密 WebSocket 帧导致抓包为空。解决在 Chrome 中按CtrlShiftI→Application→Frames选择对应 WebSocket 连接即可查看收发的每一帧含 OPCODE、Payload使用wscat命令行工具直连验证npx wscat -c ws://localhost:8080它会打印原始帧若需抓包分析用 Wireshark 过滤websocket协议并启用Decode As → WebSocket需 TLS 密钥导入仅限 wss4.2 现象服务端socket.write()发送大量消息后客户端收不到或乱序原因Node.js 的net.Socket是流式接口socket.write()是异步非阻塞的多次调用可能合并发送Nagle 算法且 TCP 层不保证应用层消息边界。WebSocket 帧必须严格按FIN1分帧否则客户端解析失败。解决绝对禁止在 WebSocket 连接上直接socket.write({msg:a}\n{msg:b})必须为每条消息单独构造完整 WebSocket 帧见 2.2 节sendTextFrame若需高吞吐启用socket.setNoDelay(true)关闭 Nagle 算法socket.setNoDelay(true); // 立即发送不等待缓冲区满4.3 现象移动端iOS Safari / Android Chrome频繁掉线PC 端稳定原因移动系统为省电会冻结后台标签页的 JavaScript 定时器setInterval导致心跳超时被误判同时移动网络切换WiFi→4G时TCP 连接无法快速恢复WebSocket 仍处于OPEN状态但实际不可用。解决心跳检测必须结合document.hiddendocument.addEventListener(visibilitychange, () { if (document.hidden) { console.log(Page hidden — pausing heartbeat); clearInterval(this.heartbeatTimer); } else { console.log(Page visible — resuming heartbeat); this.startHeartbeat(); } });增加网络状态监听window.addEventListener(online, () { if (this.socket?.readyState ! WebSocket.OPEN) { this.reconnect(); } });4.4 现象服务端socket.destroy()后客户端onclose事件不触发readyState保持OPEN原因socket.destroy()是粗暴关闭 TCP 连接不发送 WebSocket Close 帧客户端无法感知只能等待 TCP Keepalive 超时Linux 默认 2 小时。解决永远优先使用socket.close()发送 Close 帧// 正确发送标准 Close 帧 sendCloseFrame(socket, 1000, Server shutdown); // 错误直接 destroy // socket.destroy();若必须强制断开如恶意连接destroy()后应辅以socket.end()并设置socket.setTimeout(1000)防止 hang。4.5 现象同一页面创建多个 WebSocket 连接内存泄漏CPU 持续 100%原因每个 WebSocket 实例会持有onopen/onmessage/onclose回调引用若回调中闭包引用了大型 DOM 对象或全局变量且未手动removeEventListenerGC 无法回收。更隐蔽的是setInterval心跳未清除。解决所有定时器必须配对清除class ReliableWebSocket { constructor() { this.heartbeatTimer null; this.heartbeatTimeoutTimer null; } close() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); if (this.heartbeatTimeoutTimer) clearTimeout(this.heartbeatTimeoutTimer); // ... 其他清理 } }使用WeakRefNode.js 14.6或手动置null断开引用链this.socket.onmessage (event) { // 避免 this.xxx 在回调中形成强引用 const handler this.handleMessage.bind(this); this.socket.onmessage handler; };5. 进阶验证用 wrk 自定义脚本压测连接数与消息吞吐定位真实瓶颈Demo 不能只在 localhost 跑通必须验证它在并发场景下的行为。wrk是最轻量的 HTTP 压测工具但它不支持 WebSocket。我们必须用wscat或自研脚本模拟千级连接。以下提供两种可落地的验证方案。5.1 方案一用 Node.js 脚本模拟 1000 个并发连接验证连接建立性能目标确认服务端能否在 10 秒内接受 1000 个 WebSocket 握手且无EMFILE文件描述符耗尽错误。// stress-test.js const WebSocket require(ws); // 注意这里用 ws 库简化客户端非浏览器环境 const { performance } require(perf_hooks); const CONCURRENCY 1000; const URL ws://localhost:8080; async function createConnection(id) { return new Promise((resolve, reject) { const ws new WebSocket(URL); ws.on(open, () { console.log([CONN] ${id} opened); resolve(ws); }); ws.on(error, (err) { console.error([CONN] ${id} error:, err.message); reject(err); }); ws.on(close, () { // 正常关闭不报错 }); }); } async function runStressTest() { const start performance.now(); const connections []; // 并发创建连接 for (let i 0; i CONCURRENCY; i) { connections.push(createConnection(i)); // 控制创建速率避免瞬间打爆服务端 if (i % 10 0) await new Promise(r setTimeout(r, 10)); } try { await Promise.all(connections); const end performance.now(); console.log(✅ All ${CONCURRENCY} connections established in ${(end - start).toFixed(2)}ms); // 保持连接 30 秒观察内存增长 await new Promise(r setTimeout(r, 30000)); // 关闭所有连接 connections.forEach(ws ws.close()); console.log(All connections closed); } catch (e) { console.error(❌ Stress test failed:, e); } } runStressTest();执行与观察# 先确保服务端运行 node server.js # 再运行压测 node stress-test.js # 同时监控服务端资源 watch -n 1 lsof -i :8080 | wc -l; free -h; ps aux --sort-%mem | head -5关键指标lsof -i :8080 | wc -l应 ≈ 1000 1主进程内存增长应线性每个连接约 1MB若指数增长则存在内存泄漏若出现Error: connect EADDRNOTAVAIL说明本地端口耗尽需调大net.ipv4.ip_local_port_range5.2 方案二用wscat发送 10 万条消息验证消息吞吐与丢包率目标向单个连接每秒发送 100 条消息持续 1000 秒统计服务端接收总数与客户端收到总数计算丢包率。# 启动服务端带日志计数 node server.js 21 | grep Received text | wc -l received_count.log # 客户端发送使用 wscat seq 1 100000 | while read i; do echo {\seq\:$i,\ts\:$(date %s%3N)} | npx wscat -c ws://localhost:8080 --no-color sleep 0.01 # 100 msg/s done # 10 秒后检查服务端日志 sleep 10 echo Service received: $(cat received_count.log) echo Client sent: 100000结果解读若received_count.log 100000说明服务端socket.write()被阻塞或丢弃需检查socket.write()返回值false表示缓冲区满若客户端未收到 echo说明服务端sendTextFrame未正确发送或网络丢包真实瓶颈通常在服务端socket.write()的缓冲区Node.js 默认highWaterMark16384超过会返回false必须监听drain事件function sendTextFrame(socket, text) { const payload Buffer.from(text, utf8); const result socket.write(/* ... */); if (!result) { // 缓冲区满等待 drain socket.once(drain, () { sendTextFrame(socket, text); // 递归重发 }); } }5.3 一个反直觉但关键的技巧用chrome://dino页面做 WebSocket 压测沙盒Chrome 的离线小恐龙游戏chrome://dino是一个被严重低估的 WebSocket 测试沙盒。原因它是 Chrome 内置页面无 CORS 限制可直接new WebSocket(ws://...)页面极简无其他 JS 干扰performance.now()测量精准可通过chrome://flags/#unsafely-treat-insecure-origin-as-secure将http://localhost标记为安全源启用SharedWorker与 WebSocket 协同实操步骤启动服务端node server.js访问chrome://dino打开 DevTools Console粘贴以下代码// 创建 100 个连接每连接每秒发 10 条 const connections []; for (let i 0; i 100; i) { const ws new WebSocket(ws://localhost:8080); ws.onopen () { const interval setInterval(() { ws.send({from:dino-${i},time:${Date.now()}}); }, 100); connections.push({ ws, interval }); }; } // 60 秒后关闭 setTimeout(() { connections.forEach(({ ws, interval }) { clearInterval(interval); ws.close(); }); console.log(Dino stress test done); }, 60000);观察服务端日志与 Chrome 任务管理器ShiftEsc的内存/CPU 占用这个技巧的价值在于它绕过了所有前端构建工具Webpack/Vite的干扰直接在 Chrome 最纯净环境中验证 WebSocket 行为。我曾用它发现某 UI 框架的useEffect清理逻辑会延迟 300ms 执行导致ws.close()晚于unmount引发内存泄漏。写这个 demo 的第 7 个年头我依然坚持不亲手解析一帧、不亲手发一次 ping、不亲手看一次lsof输出就不算真正用过 WebSocket。那些封装得严严实实的 SDK省下的不是时间是排查线上问题时的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表