1. 为什么我选择用原生 Socket 手搓一个聊天室
前端圈子里有个挺有意思的现象:大家每天用 WebSocket 写业务,调new WebSocket(url)然后onmessage收消息,封装得漂漂亮亮,但真要问一句“这条消息在网络上到底是怎么跑的”,很多人就卡壳了。我当初动手做这个简易网络聊天室,说白了就是被一次线上问题逼的——某个长连接场景下消息偶尔丢失,排查半天发现是对底层帧结构和连接生命周期理解不到位。从那以后我就养成了一个习惯:凡是天天用的东西,一定要自己从零撸一遍。
这个项目标题里的“Socket”其实是个泛指。严格来说,浏览器端能直接用的只有WebSocket,它是建立在 TCP 之上的应用层协议;而“Socket”这个词在服务端语境里通常指 TCP Socket。所以这个 Demo 的完整形态是:浏览器用 WebSocket 连接,服务端用原生 TCP/WebSocket 双模式处理。我最终选了 Node.js 的net模块手写 WebSocket 握手和帧解析,而不是直接上ws库,目的很纯粹——把那些被库藏起来的细节全挖出来看一遍。
它能做什么?一个能跑在局域网里的多人聊天室,支持昵称、广播、私聊、在线列表、断线重连。适合谁看?适合已经会写前端、但对网络底层一知半解的同学,也适合想自己搭个内网小工具练手的开发者。代码量不大,但每一行都有讲究,我会把踩过的坑和背后的原理都摊开讲。
2. 整体架构设计与技术选型拆解
2.1 为什么不用现成的 ws 库,非要手写协议
先说结论:学习价值远大于工程价值。生产环境我绝对推荐用成熟的库,但自己实现一遍能让你对“为什么 WebSocket 能全双工”“为什么需要掩码”“为什么握手要带 Sec-WebSocket-Key”这些问题有肌肉记忆。
WebSocket 的握手本质上是一次 HTTP 升级请求。客户端发过来的头里有个Sec-WebSocket-Key,服务端要把它拼上一个固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1 哈希再 Base64 编码,回填到Sec-WebSocket-Accept里。这个设计不是为了加密,而是为了防止缓存代理误把升级请求当成普通 HTTP 请求处理。我第一次看到这个 GUID 的时候觉得莫名其妙,后来查了 RFC 6455 才明白,这就是个“魔法字符串”,专门用来让握手结果不可预测。
选 Node.js 的net模块而不是http模块,是因为我想同时兼容裸 TCP 客户端(比如用 telnet 或自己写的测试脚本)和 WebSocket 客户端。net拿到的是最原始的字节流,所有解析都得自己来,这恰恰是我想练的部分。
2.2 服务端与客户端职责划分
整个系统分三层:
- 传输层:TCP 连接管理,负责字节流的收发和粘包处理。
- 协议层:WebSocket 帧的编解码,包括握手、掩码处理、分片重组。
- 业务层:聊天室逻辑,昵称管理、消息广播、在线列表维护。
很多教程把这三层揉在一起写,结果代码一长就乱。我坚持分层的原因是:协议层必须和业务层解耦。这样我才能单独测试帧解析是否正确,而不用每次都启动整个聊天室。实际写下来,协议层大概 200 行,业务层 150 行,传输层 80 行,边界非常清晰。
2.3 数据格式约定:为什么用 JSON 而不是纯文本
消息体我选了 JSON,结构长这样:
{ "type": "chat", "from": "张三", "content": "大家好", "timestamp": 1700000000000 }用 JSON 的理由很实际:可扩展。纯文本广播虽然简单,但一旦要加私聊、加系统通知、加在线列表,就得靠各种分隔符硬凑,解析起来全是坑。JSON 多花的那点字节数,在局域网场景下完全可以忽略。不过要注意,WebSocket 帧本身不关心你传的是 JSON 还是二进制,它只负责把字节搬过去,序列化是你自己的事。
3. WebSocket 帧结构深度解析与手写实现
3.1 帧头到底长什么样
这是整个项目最硬核的部分。WebSocket 的一帧数据,前两个字节是固定的帧头,后面可能跟扩展长度和掩码。
第一个字节:
- 第 1 位
FIN:表示这是不是消息的最后一帧。分片传输时用。 - 第 2-4 位
RSV1/2/3:保留位,一般全是 0,除非用了扩展。 - 第 5-8 位
opcode:操作码。0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是 ping,0xA是 pong。
第二个字节:
- 第 1 位
MASK:客户端发给服务端的帧必须置 1,服务端发给客户端的必须置 0。这是 RFC 强制规定的。 - 第 2-8 位
payload length:负载长度。0-125 直接表示长度;126 表示后面跟 2 字节的无符号整数;127 表示后面跟 8 字节。
我当初写解析的时候,最容易错的就是长度字段的读取顺序。126 和 127 这两种情况,长度值是网络字节序(大端),必须用readUInt16BE和readUInt64BE来读,用readUInt16LE读出来全是错的。这个坑我踩了整整一个下午,抓包对比才发现。
3.2 掩码处理:为什么客户端必须掩码
RFC 6455 规定客户端发往服务端的帧必须用 4 字节掩码异或处理负载。原因是为了防止恶意脚本通过 WebSocket 发送看起来像 HTTP 请求的数据,欺骗中间代理。掩码算法很简单:
function unmask(payload, maskKey) { const result = Buffer.alloc(payload.length); for (let i = 0; i < payload.length; i++) { result[i] = payload[i] ^ maskKey[i % 4]; } return result; }注意掩码是逐字节循环异或,不是整体异或。我第一次写的时候想当然地用了payload[i] ^ maskKey,结果只有前 4 个字节对,后面全乱。这个细节文档里写得很清楚,但很容易看漏。
3.3 粘包与半包:TCP 字节流的经典难题
TCP 是字节流协议,不保证你一次data事件收到的就是一个完整帧。可能一次收到两个半帧,也可能半个帧分两次到。我的处理方式是维护一个缓冲区:
let buffer = Buffer.alloc(0); socket.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); while (true) { const frame = tryParseFrame(buffer); if (!frame) break; buffer = buffer.slice(frame.totalLength); handleFrame(frame); } });tryParseFrame返回null表示数据还不够,等下一批。这个循环是必须的,因为一次data里可能包含多个完整帧。我见过有人只解析一次就完事,结果消息一密集就丢帧,排查起来极其痛苦。
提示:缓冲区一定要用
Buffer.concat而不是字符串拼接。二进制数据用字符串处理会因编码问题损坏,尤其是遇到非 UTF-8 字节时。
3.4 握手响应的完整构造
服务端收到 HTTP 升级请求后,要回一个 101 响应:
const key = headers['sec-websocket-key']; const accept = crypto .createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64'); socket.write( 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + `Sec-WebSocket-Accept: ${accept}\r\n` + '\r\n' );这里有个细节:响应头必须以空行结束,也就是最后那个\r\n\r\n。少了这个空行,浏览器会一直等,握手永远完不成。另外Sec-WebSocket-Accept的计算必须用原始 key 字符串,不能 trim 掉两边的空格,否则哈希对不上。
4. 聊天室业务逻辑的完整落地
4.1 连接生命周期管理
每个连接进来,我先给它分配一个临时 ID,等客户端发来昵称后再绑定。用一个 Map 存所有活跃连接:
const clients = new Map(); // id -> { socket, nickname, buffer } function addClient(socket) { const id = generateId(); clients.set(id, { socket, nickname: null, buffer: Buffer.alloc(0) }); return id; } function removeClient(id) { const client = clients.get(id); if (client) { clients.delete(id); broadcastSystem(`${client.nickname || '匿名用户'} 离开了聊天室`); broadcastUserList(); } }连接关闭时要同时处理close、error、end三个事件,任何一个触发都要清理。我一开始只监听了close,结果遇到异常断开时连接一直挂在 Map 里,在线列表越积越多。后来改成三个事件都指向同一个清理函数,并且加了幂等判断,问题才解决。
4.2 消息广播与私聊实现
广播就是遍历 Map 给每个连接发帧:
function broadcast(message, excludeId = null) { const data = JSON.stringify(message); for (const [id, client] of clients) { if (id === excludeId) continue; if (client.nickname) { sendFrame(client.socket, data); } } }私聊则是在消息体里带一个to字段,服务端找到对应连接单独发。这里要注意:私聊消息不能广播给所有人,但发送方自己也要收到一份回显,否则他看不到自己发了什么。我的做法是发送方本地先渲染,服务端只负责转发给接收方,这样体验更流畅。
4.3 在线列表的实时同步
在线列表我用一个单独的type: "userlist"消息推送。触发时机有三个:有人加入、有人离开、有人改昵称。每次变化都全量推送,而不是增量更新。为什么?因为全量推送逻辑简单,不容易出错。在线人数在局域网场景下撑死几十个,全量推送的开销可以忽略。增量更新虽然省流量,但状态同步一旦出错就很难排查,得不偿失。
4.4 心跳保活机制
长连接最怕的就是中间设备悄悄断开。我加了一个简单的 ping/pong 机制:服务端每 30 秒给所有连接发一个 ping 帧(opcode0x9),客户端收到后自动回 pong(opcode0xA)。如果服务端连续两次没收到某个连接的 pong,就主动关闭它。
setInterval(() => { for (const [id, client] of clients) { if (client.socket.destroyed) { removeClient(id); continue; } sendPing(client.socket); } }, 30000);浏览器端的 WebSocket API 会自动处理 ping/pong,你不需要写任何代码。但如果你用裸 TCP 客户端测试,就得自己实现 pong 响应,否则会被服务端踢掉。
5. 实操全流程与关键代码复现
5.1 环境准备与项目结构
我用的环境很朴素:Node.js 18+,不需要任何第三方依赖,因为net和crypto都是内置的。项目结构如下:
chat-demo/ ├── server.js # 服务端入口 ├── protocol.js # WebSocket 帧编解码 ├── room.js # 聊天室业务逻辑 └── public/ └── index.html # 前端页面前端页面就是一个 HTML 文件,内嵌 CSS 和 JS,不需要构建工具。这种“零依赖”的结构特别适合练手,clone 下来node server.js就能跑。
5.2 服务端启动与握手处理
服务端监听 8080 端口:
const net = require('net'); const server = net.createServer((socket) => { let handshaked = false; let clientId = null; socket.on('data', (chunk) => { if (!handshaked) { const req = chunk.toString(); if (req.includes('Upgrade: websocket')) { doHandshake(socket, req); handshaked = true; clientId = addClient(socket); } return; } // 后续走帧解析 handleData(clientId, chunk); }); }); server.listen(8080, () => { console.log('聊天室服务已启动,端口 8080'); });这里有个判断顺序的问题:必须先判断是否已完成握手,再决定走 HTTP 解析还是帧解析。因为握手请求和后续帧数据可能在同一批data里到达(虽然少见),如果不加状态标记就会解析错乱。
5.3 前端连接与消息渲染
前端代码很直接:
const ws = new WebSocket('ws://localhost:8080'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', nickname: '张三' })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); renderMessage(msg); }; ws.onclose = () => { // 断线重连 setTimeout(connect, 3000); };渲染部分我用了一个简单的div列表,新消息appendChild后自动滚到底部。这里有个小技巧:滚动到底部要用scrollTop = scrollHeight,并且放在requestAnimationFrame里,否则新元素还没布局完成,滚不到真正底部。
5.4 参数计算:帧长度字段的边界处理
负载长度小于 126 时,第二个字节的低 7 位直接就是长度。等于 126 时,后面跟 2 字节;等于 127 时,后面跟 8 字节。我写了个函数专门处理:
function encodeLength(length) { if (length < 126) { return Buffer.from([length]); } else if (length < 65536) { const buf = Buffer.alloc(3); buf[0] = 126; buf.writeUInt16BE(length, 1); return buf; } else { const buf = Buffer.alloc(9); buf[0] = 127; buf.writeBigUInt64BE(BigInt(length), 1); return buf; } }注意 65536 这个边界:2 字节最大表示 65535,所以长度等于 65536 时必须走 8 字节分支。我测试的时候专门构造了一个 65536 字节的消息,验证边界是否正确。这种边界测试在协议实现里非常重要,因为日常小消息根本触发不到这些分支。
6. 常见问题排查与避坑经验实录
6.1 握手失败排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 浏览器报 1006 | 响应头缺少空行 | 抓包看响应是否以\r\n\r\n结尾 |
| 连接立即关闭 | Sec-WebSocket-Accept 计算错误 | 打印 key 和 accept 对比 |
| 握手成功但收不到消息 | 帧解析长度字段读错 | 用readUInt16BE而非 LE |
| 消息乱码 | 掩码未处理或处理错误 | 检查异或是否按 4 字节循环 |
| 消息偶尔丢失 | 粘包未循环解析 | 确认 while 循环存在 |
6.2 我踩过的三个真实坑
第一个坑:Buffer 切片共享内存。buffer.slice()返回的是原 Buffer 的视图,不是拷贝。我一开始用 slice 切完直接丢给解析函数,结果后续Buffer.concat修改了原数据,导致已解析的帧内容被覆盖。后来改成Buffer.from(buffer.slice(...))做一次拷贝才稳定。这个坑非常隐蔽,因为小数据量下几乎不会触发。
第二个坑:JSON.parse 异常导致连接崩溃。客户端如果发来非 JSON 数据,JSON.parse会抛异常,如果不 catch,整个data回调就中断了,后续消息全丢。我的处理是每个消息处理都包一层 try-catch,解析失败就回一条错误提示,而不是让连接挂掉。
第三个坑:关闭帧没有正确响应。客户端发来 opcode0x8的关闭帧时,服务端必须回一个关闭帧再关闭 TCP 连接。我一开始直接socket.end(),结果浏览器端报异常关闭。正确做法是先发关闭帧,再等一小段时间关闭。
6.3 性能与稳定性优化建议
局域网聊天室不需要考虑高并发,但有几个优化点值得做:一是消息合并,如果短时间内有多条消息,可以攒 50ms 一起发,减少帧数量;二是缓冲区上限,给每个连接的 buffer 设一个最大值(比如 1MB),超过就断开,防止恶意客户端撑爆内存;三是错误隔离,单个连接出错不能影响其他连接,所有操作都要包在 try-catch 里。
注意:不要在生产环境直接用这套代码。它缺少鉴权、限流、消息持久化等关键能力,定位就是学习 Demo。真要上线,协议层用
ws库,业务层再自己写。
7. 从 Demo 到可用工具的扩展思路
这个 Demo 跑通之后,我顺手做了几个扩展,都挺有意思。第一个是加历史消息,用一个数组存最近 100 条,新用户加入时先推历史再推在线列表,体验立刻不一样。第二个是加表情和图片,图片走二进制帧(opcode0x2),前端用Blob接收再转URL.createObjectURL,比 Base64 省三分之一体积。第三个是加房间概念,把clients从单层 Map 改成rooms -> clients的嵌套结构,广播时只遍历当前房间。
如果你想把服务端换成其他语言,协议层是通用的,只要按 RFC 6455 实现握手和帧编解码就行。我用 Python 的asyncio重写过一版,核心逻辑几乎一模一样,只是 Buffer 操作换成了bytes切片。这也印证了一件事:网络协议是跨语言的,理解了字节层面的规则,换什么语言都是翻译工作。
最后分享一个调试技巧:Chrome DevTools 的 Network 面板里,WebSocket 连接点进去能看到每一帧的详细内容,包括 opcode、长度、负载。排查帧解析问题时,把浏览器看到的帧和你服务端解析出来的帧对比,差异一目了然。这个工具帮我省了至少一半的调试时间。