做前端这几年,只要一碰“实时推送”需求,我的第一反应永远不是轮询,而是 WebSocket。在线客服、股票行情看板、后台任务执行进度,我都是用这套方案把服务端数据实时推到浏览器端。很多同学第一次接触时,往往是打开控制台刷到一整片报错:stream disconnected before completion、WebSocket closed by server before response,页面偶尔还直接卡死,最后不得不怀疑人生。这篇文章我想把 JavaScript 里 WebSocket 从原理到实践、从浏览器到服务端、从开发环境到 Nginx 反代上线的完整链路整理一遍。无论你是刚准备入门实时通信,还是已经踩过几个坑,都能在这里找到可以马上抄走的方案。
1. 为什么实时通信场景绕不开 WebSocket
1.1 HTTP 轮询差在哪
先说最传统的方式:轮询。前端写一个 setInterval,每隔两秒发一次 GET 请求,服务端返回最新数据。这个方案实现成本极低,但代价很明显:大多数请求返回的数据根本没有任何变化,白白浪费带宽;请求频率越高延迟越低,服务器压力却越大;请求频率越低服务器越轻松,延迟又上去了。我见过一个项目,在线人数三百,轮询间隔 1 秒,QPS 直接跑到了三百,实际上真正需要推送的消息每分钟也就几十条。
后来有人发明了长轮询:客户端发一个请求过去,服务端先挂住,等有了新数据再返回,客户端收到后再立刻发下一个请求。这确实把无效请求的数量降下来了,但连接在服务端长时间挂着,每个连接都要占用一个 socket、一个线程或者协程资源,连接数一多依然顶不住。而且长轮询本质上还是一条“单向管道”,服务端不能主动往客户端推数据,消息要是恰好在这两次请求之间产生,客户端还是要等到下一次请求才能拿到。所以长轮询只是缓解了轮询的部分痛点,并没有从根本上改变“请求-响应”这个模型。
1.2 一次搞定握手:从 HTTP 升级为 WebSocket 的完整过程
WebSocket 的思路就完全不同了。它先通过 HTTP 协议完成一次握手,然后升级为 WebSocket 协议,之后连接双方随时都可以往对方发数据,再也不需要一次次建立连接。这个设计很巧妙:它复用了 HTTP 的 80/443 端口,所以防火墙不用额外开端口;同时 Nginx、CDN 这些基础设施也天然认识这个握手过程。
握手过程大概是这样的。客户端发一个 GET 请求,带上 Upgrade: websocket 和 Connection: Upgrade 两个头,同时生成一个 16 字节的随机数做 Base64,放到 Sec-WebSocket-Key 头里。服务端收到后,把这个 Key 拼上一个协议固定的 GUID 字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1 哈希,再 Base64 编码,放到响应头的 Sec-WebSocket-Accept 里返回。如果客户端算出来的结果和服务端一致,握手成功,返回 101 Switching Protocols,之后这条连接就从 HTTP 切换成了 WebSocket。
为什么协议非要给你搞一个校验 Key?最核心的用途是防止普通 HTTP 缓存代理把这个握手请求当成普通 GET 请求缓存下来。有了这个随机 Key 和固定 GUID 的校验,中间层没法伪造响应,也能保证这次 Upgrage 是“对的人在对的地方”。这个设计本身也是一种安全保障,所以千万不要觉得它麻烦就去掉。
1.3 什么场景才值得换这套方案
WebSocket 并不是银弹。如果业务是标准的请求-响应模式,比如查询订单、提交表单、拉取列表,老老实实用 HTTP 就行。HTTP 有无状态、可缓存、可压缩、有成熟的重试语义这些优势,强行上 WebSocket 反而把简单问题复杂化。
真正适合 WebSocket 的场景有三类:
- 服务端主动推送:通知、行情、进度条、日志流转
- 高频双向交互:在线协作编辑、实时游戏、远程控制
- 需要长时间保持在线状态的连接:客服坐席、直播弹幕、物联网设备控制
另外要说一下 SSE(Server-Sent Events)。SSE 走 HTTP,服务端可以单向往客户端推数据,实现简单、自动重连,但它是单向的,客户端想往服务端发消息还是得发 HTTP 请求。WebSocket 是双向的,所以只要明确要双向通信,基本就直接跳过 SSE 考虑 WebSocket 了。
| 方案 | 方向 | 延迟 | 连接数压力 | 实现成本 |
|---|---|---|---|---|
| HTTP 轮询 | 单向(请求-响应) | 高 | 高 | 低 |
| 长轮询 | 单向(请求-响应) | 中 | 中 | 中 |
| SSE | 单向(服务端到客户端) | 低 | 低 | 低 |
| WebSocket | 双向 | 最低 | 低 | 中高 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 浏览器端从零开始:建立连接、收发消息、心跳保活
2.1 五分钟跑通一个最小 Demo
浏览器端使用 WebSocket 自带原生 API,不用装任何依赖。先给一个最小完整示例:
javascript复制const socket = new WebSocket('ws://localhost:8080/ws');
socket.addEventListener('open', () => {
console.log('连接已建立');
socket.send(JSON.stringify({ type: 'join', room: 'demo' }));
});
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
});
socket.addEventListener('close', (event) => {
console.log('连接已关闭,code=', event.code, 'reason=', event.reason);
});
socket.addEventListener('error', (error) => {
console.error('发生错误:', error);
});
这里有几个值得注意的细节。socket.onopen、socket.onmessage 这种赋值式写法虽然能用,但我建议用 addEventListener,因为同一个事件可以挂多个监听函数,后续扩展不会互相覆盖。
WebSocket 创建之后会自动开始连接,没有 connect() 方法可以调用。readyState 属性代表当前连接状态,0 是 CONNECTING,1 是 OPEN,2 是 CLOSING,3 是 CLOSED。发消息之前最好判断一下:socket.readyState === WebSocket.OPEN,否则在连接没建立时调用 send() 会直接抛异常。
send() 方法能发送的数据类型包括字符串、Blob、ArrayBuffer、TypedArray、DataView。如果服务端推的是二进制数据,建议把 socket.binaryType 设置成 'arraybuffer',默认是 'blob',Blob 在处理二进制协议时没有 ArrayBuffer 那么顺手。
2.2 心跳保活:没有它,连接会被默默掐掉
这是最容易踩坑的地方。你以为连接还活着,其实中间某个代理已经把它关了。
很多网络设备、云负载均衡器、Nginx 都有一个“空闲超时”机制。如果一条连接在指定时间内没有任何数据传输,中间设备就会主动把它断开。Nginx 默认的 proxy_read_timeout 是 60 秒,也就是说,如果你页面挂着什么都不做,超过 60 秒,Nginx 就可能把这条 WebSocket 连接掐掉。重点是:浏览器感知不到服务端或者中间层已经断开,readyState 还会停留在 1(OPEN),直到你下一次真正发消息出去才会触发报错。这在“只是挂着看数据”的场景里,问题会被藏得很深。
解决方式就是在应用层做心跳。浏览器端的 WebSocket API 没有直接暴露协议层的 ping/pong 帧控制能力,所以常规做法是发业务心跳消息:定时发送 { "type": "ping" },服务端收到后返回 { "type": "pong" }。下面是一个基础
