WebSocket 这东西,我用 JavaScript 写了好几年,从最早为了做一个在线客服页面被轮询折腾到怀疑人生,到后来被 WebSocket 的实时推送能力彻底解放。这中间踩过的坑、熬夜查过的文档、写完后想给自己两巴掌的蠢代码,一抓一大把。很多朋友跑来问我 WebSocket 怎么用,我翻了一圈市面上的教程,要么上来就贴一堆代码看不懂在干嘛,要么讲原理讲得云里雾里,真正能把“为什么这么做”和“实际怎么落地”讲透的太少了。
这篇文章我就按照自己实际做项目的经验,把 JavaScript 里 WebSocket 的使用从原理到实践完整拆一遍。你会知道 WebSocket 到底解决什么问题、和 HTTP 的关系是什么、怎么建立一个稳定的连接、心跳检测和断线重连怎么做、遇到浏览器崩溃和连接断开的报错怎么排查,以及部署到 nginx 后面的时候要改哪些配置。适合刚接触 WebSocket 的前端新手,也适合那些已经能跑通 demo 但一上生产就翻车的同学。
1. 为什么需要 WebSocket:从一个反人性的轮询需求说起
1.1 HTTP 短轮询和长轮询到底哪里不对劲
在我真正用上 WebSocket 之前,做过一个项目,需求是页面上的订单状态要在 3 秒内更新。那时候我第一反应就是轮询,setInterval 每隔 3 秒发一次请求,接口返回最新的状态,然后局部更新页面。代码写起来确实简单,能在本地跑通,上线之后问题就来了。
首先是请求量的问题。页面开着的人越多,服务器收到的无效请求就越多。假设用户停留在页面上 10 分钟,那就是 200 个请求,其中 199 个都是在问“有没有变化”,而绝大多数时候答案都是“没有”。这是把有限的服务器带宽和数据库连接浪费在反复确认上。
其次是延迟问题。3 秒的间隔意味着用户看到状态变化最快也要等 3 秒,最慢可能要快 6 秒。你想把间隔缩短到 1 秒,服务器又扛不住。这就是 HTTP 短轮询的死结。
后来我试过长轮询,也就是客户端发一个请求过去,服务器先挂住这个请求,等有数据了才返回,客户端收到后再立刻发下一个。这个方案比短轮询好一些,消息可以做到几乎实时,但服务器端挂着一大堆等待中的请求,每个都占用一个连接资源,在高并发下照样吃不消。而且还要处理超时重发、连接中断等等情况,代码复杂度直线上升。
1.2 WebSocket 的设计取舍和适用边界
WebSocket 的设计思路和 HTTP 是完全不同的。HTTP 是“一问一答”,客户端发请求,服务器返回响应,然后这个连接的生命周期基本就结束了。WebSocket 建立的是一条长连接,客户端和服务器都能主动往对方那边发送数据,没有“请求-响应”这种模式,而是一条管道,两头都能随时往里扔数据。
这就解决了上面说的问题。连接建立之后,服务器有数据直接推给客户端,没有数据时连接是空闲的,不产生额外请求。订单状态变化的那一刻,服务器推一条消息过来,客户端收到就更新,真正做到实时。
那我是不是所有场景都应该用 WebSocket?不是。这也是我给很多人的忠告。如果你的场景是“隔几分钟拉一次数据”“用户主动点击才刷新”“数据变化不频繁”,用普通的 HTTP 请求完全够,甚至更简单更稳定。WebSocket 的适用场景有这些特征:需要服务器主动推送、对实时性要求很高、消息频率较高、客户端需要保持状态。典型的比如聊天室、在线协作编辑、股票行情、游戏对战、客服系统、监控面板。如果你的需求不符合这些特征,不要为了用而用。
1.3 WebSocket 连接的工作流程:建立、通信、关闭
WebSocket 连接的完整生命周期可以分成三个阶段:握手、双向通信、关闭。
握手阶段实际上是走 HTTP 的。客户端发一个带有特殊请求头的 HTTP 请求,服务器确认之后返回 101 状态码,通信协议才会从 HTTP 升级为 WebSocket。这个过程看起来是“一次请求”,但那个请求不是普通的业务请求,它只是完成协议的切换。
双向通信阶段就是数据往来。数据以帧的形式传输,可以传文本,也可以传二进制数据,比如图片、音频、ArrayBuffer。这个阶段没有请求和响应的概念,两端地位完全平等。
关闭阶段可以由任何一端发起,也可以因为网络异常或服务器崩溃而断开。需要注意的是,WebSocket 的连接不会永远活着,网络波动、代理超时、服务器重启都会导致连接断开,所以生产环境里必须考虑断线重连。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 协议机制拆解:握手细节、数据帧和心跳保活
2.1 从 HTTP 到 WebSocket:一次握手到底做了什么
很多教程直接让你 new WebSocket(url) 就完事了,根本不讲握手过程。但如果你不懂握手,后面遇到“连接一直失败”“wss 握手不过”“代理服务器报 400”这类问题的时候,你会一头雾水。
客户端发起握手时,请求头大概是这样的:
http复制GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
关键的几个头:Upgrade 和 Connection 告诉服务器要把协议升级成 WebSocket,Sec-WebSocket-Key 是一个经过 base64 编码的随机字符串,相当于客户端给服务器出的“考题”,Sec-WebSocket-Version 一般固定是 13,这是目前的标准版本。
服务器收到之后,会拿 Sec-WebSocket-Key 拼上一个固定的 GUID,做一次 SHA-1 哈希,再 base64 编码,放到 Sec-WebSocket-Accept 响应头里返回。这个过程是为了证明“服务器这边确实支持 WebSocket 协议”,避免客户端连到一个不支持 WebSocket 的普通 HTTP 服务器上。
如果握手成功,状态码是 101,也就是“切换协议”。从这一刻开始,连接不再是 HTTP,而是 WebSocket。如果服务器不支持或者配置不对,会返回 400 或者其他错误码。
我在实战中遇到过一个很典型的坑:本地开发用 http 访问页面,然后连的是 ws:// 开头的 WebSocket 地址,没问题。但一旦上线到了 https 环境,页面本身是 https,你必须用 wss:// 去连 WebSocket,不能再用 ws://。浏览器出于安全策略会直接阻止这种“https 页面请求 ws 连接”的行为。wss 和 ws 的关系,就相当于 https 和 http,加密和不加密的区别。
2.2 数据帧与消息分片:文本、二进制、Ping/Pong
握手完成后,数据不是以普通 HTTP 报文传输的,而是以帧为单位。每个帧有固定的格式,包含操作码、长度、掩码等信息。这部分你不需要深究到二进制层面,但有几个概念要清楚。
WebSocket 的操作码区分了不同类型的帧。文本帧是 0x01,二进制帧是 0x02,关闭帧是 0x08,Ping 帧是 0x09,Pong 帧是 0x0A。浏览器原生 API 里,你不需要手动构造帧,但当你收到数据时,需要看 event.data 的类型,是字符串还是 Blob 还是 ArrayBuffer,这其实对应了服务端发送的是文本帧还是二进制帧。
这里有个容易被忽略的细节:如果服务端发来的是一个大消息,WebSocket 协议允许它被拆分成多个帧传输,这就是分片。浏览器会自动把分片重组好,你拿到的还是一个完整的消息。但是如果网络不稳定,分片传输中出现了丢包或者中断,你可能会收到不完整的数据,甚至连接直接断开。
我一般在前端代码里会加一个判断,根据数据格式做不同处理:
javascript复制ws.onmessage = function (event) {
if (typeof event.data === 'string') {
// 处理文本消息
console.log('收到文本:', event.data);
} else if (event.data instanceof ArrayBuffer) {
// 处理二进制数据
console.log('收到二进制数据');
} else if (event.data instanceof Blob) {
// 处理 Blob,比如图片、文件
console.log('收到 Blob');
}
};
在某些游戏或者音视频场景中,前后端可能直接约定二进制协议,一堆字节按顺序排列代表不同字段,这种时候直接用 ArrayBuffer。如果是普通的聊天消息,JSON 字符串就够了。
2.3 心跳保活:连接看似活着,其实已经死了
这是我见过最多新手忽略的问题,也是生产环境里 WebSocket 连接“莫名其妙”断开的最大原因之一。
WebSocket 连接在建立之后,默认情况下连接是“沉默”的。连接建立后,如果长时间没有任何数据流动,中间的网络设备比如路由器、防火墙、负载均衡器,会把空闲的连接默默掐掉。这不是 WebSocket 独有的问题,TCP 连接本身就会遇到,只不过 HTTP 短连接用完了就断开,长连接才会被空闲超时影响到。
你可能会想:断了就断了,反正我做了重连。但问题是,连接断开时服务器或者客户端并不会立刻感知到,尤其是当你只是默默打开页面不操作的时候。你看着页面,以为连接还在,实际上底层早就断了。等你下一次主动发消息的时候,才发现发送失败,或者你期待服务器推消息,结果什么都没有。
解决方案就是心跳机制。客户端每隔一段时间发一个 Ping 帧,服务器收到后自动回一个 Pong 帧,如果客户端超过一定时间没收到 Pong,就认为连接已经死了,主动关闭并重新连接。用浏览器原生 WebSocket API 的话,你不能手动发 Ping 帧,因为浏览器不给你这个接口。常见的做法是直接发一个自定义的文本消息,比如 {"type": "ping"},服务端收到后回 {"type": "pong"}。这在协议实现上不是标准 Ping/Pong,但效果一样,核心是保证有数据在流动,让中间设备认为连接是活跃的。
3. 原生 JavaScript API 使用实操:从连接到收发消息
3.1 创建连接:url 写不对,全都白搭
创建 WebSocket 连接的方式很简单:
javascript复制const ws = new WebSocket('ws://localhost:8080/ws');
但这里有几个非常关键的细节,我见过无数人栽在上面。
第一是协议必须是 ws:// 或者 wss://。如果你写成 http:// 或者 https://,浏览器直接报错。https 页面必须用 wss://,刚才说过就不重复了。
第二是 url 路径。很多人以为 WebSocket 的路径可以随便写,反正握手请求发过去就行。实际上服务端会根据路径做路由,你连接 /ws 还是 /socket 取决于服务端的配置,写错了会连不上,服务器返回 404 或者直接拒绝。
第三是连接刚创建的时候是异步进行的,WebSocket 对象构造完成不代表连接已经建立。你必须监听 open 事件才知道什么时候可以发送数据。
javascript复制const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = function () {
console.log('连接已建立');
ws.send('Hello Server');
};
ws.onmessage = function (event) {
console.log('收到消息:', event.data);
};
ws.onerror = function (error) {
console.error('连接出错:', error);
};
ws.onclose = function (event) {
console.log('连接关闭:', event.code, event.reason);
};
这里有个新手非常容易犯的错误:new WebSocket 之后立刻调用 ws.send(),然后发现消息发不出去或者报错 “WebSocket is not open”。因为连接还在握手阶段,readyState 还不是 OPEN。要么放到 onopen 里面发,要么在 send 之前检查 readyState 是不是等于 WebSocket.OPEN。
3.2 发送数据和关闭连接:注意 readyState 判断
send 方法的调用时机,我一般会封装一个小工具函数来处理:
javascript复制function sendWebSocketMessage(ws, message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
return true;
}
console.warn('WebSocket 连接未就绪,消息未发送');
return false;
}
这个函数看起来简单,但在实战中非常有用。因为用户可能在任何时间点点击按钮、提交表单,你不能保证此刻连接就一定处于 OPEN 状态。封装一层判断,可以避免大量因为连接状态问题报的错。
关闭连接用 close 方法:
javascript复制ws.close();
close 方法可以带两个可选参数,一个是关闭码,一个是关闭原因字符串。比如主动关闭一般用 1000,表示正常关闭。如果因为业务原因要断开,比如用户被踢下线,可以自定义关闭码,但要符合协议规范,1000 到 1014 之外的保留码不能乱用。
还要注意 onclose 回调里有一个 code 属性,这个是判断连接异常与否的关键。1000 表示正常关闭,1006 表示异常关闭(比如连接直接断了,没有发关闭帧),1001 表示服务器主动关闭或者页面跳转导致的关闭。后面排查问题时会用到这个 code。
3.3 完整示例:一个简单的实时消息页面
下面我写一个完整的例子,把上面这些基础 API 集合起来。这个例子模拟一个实时通知面板,服务器每隔几秒推送一条消息,页面实时展示,用户也可以手动发送消息。
html复制<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket 实时消息demo</title>
</head>
<body>
<h3>实时消息面板</h3>
<div id="messages"></div>
<input type="text" id="msgInput" placeholder="输入要发送的消息" />
<button id="sendBtn">发送</button>
<script>
const messagesDiv = document.getElementById('messages');
const msgInput = document.getElementById('msgInput');
const sendBtn = document.getElementById('sendBtn');
const ws = new WebSocket('ws://localhost:8080/chat');
ws.onopen = function () {
addMessage('系统', '连接已建立');
};
ws.onmessage = function (event) {
addMessage('服务器', event.data);
};
ws.onerror = function () {
addMessage('系统', '连接出错');
};
ws.onclose = function (event) {
addMessage('系统', '连接关闭,code=' + event.code);
};
sendBtn.addEventListener('click', function () {
const value = msgInput.value;
if (!value) return;
if (ws.readyState === WebSocket.OPEN) {
ws.send(value);
addMessage('我', value);
msgInput.value = '';
} else {
addMessage('系统', '连接未就绪,消息未发送');
}
});
function addMessage(from, content) {
const item = document.createElement('div');
item.textContent = '[' + from + '] ' + content;
messagesDiv.appendChild(item);
}
</script>
</body>
</html>
这个例子看起来简单,但它把 WebSocket 最基本的使用流程完整走了一遍。你在浏览器里打开这个页面,控制台能看到连接日志,输入框发消息,服务端能收到。服务端返回的消息,页面能实时显示。一个最简单的 WebSocket 通信闭环,就在这几十行代码里跑通了。
4. 生产级实战:自动重连、心跳保活、消息队列与多端配合
4.1 自动重连与指数退避:连接断开后不能傻等
基础 demo 跑通了,真正上生产时最重要的问题就是连接的稳定性。因为网络是脆弱的,服务器会重启,代理会超时,手机的 WiFi 和 4G 切换也会导致连接的 TCP 层直接断开。如果你的页面只在连接建立时做一次 WebSocket 连接,断了就再也不连,那这个实时功能基本废了。所以轮询代码可以不要,但自动重连必须要有。
我在项目里实践下来效果好用的重连策略,是给重连加一个退避机制。
核心思想很简单:每次重连失败,就把等待时间拉长,避免在服务端还没恢复时,一堆客户端疯狂重连把服务器打爆,这叫做重连风暴。等连接成功后,再把重连等待时间重置回初始值。
下面是我在项目里使用的一个简化版本:
javascript复制function createWebSocket(url, options) {
let ws = null;
let reconnectAttempts = 0;
let reconnectTimer = null;
let userClosed = false;
const maxReconnectAttempts = options.maxReconnectAttempts || 10;
const baseDelay = options.baseDelay || 1000;
const maxDelay = options.maxDelay || 30000;
function connect() {
ws = new WebSocket(url);
ws.onopen = function () {
reconnectAttempts = 0;
if (options.onOpen) options.onOpen(ws);
};
ws.onmessage = function (event) {
if (options.onMessage) options.onMessage(event.data);
};
ws.onerror = function (error) {
if (options.onError) options.onError(error);
};
ws.onclose = function (event) {
if (options.onClose) options.onClose(event);
if (userClosed) return;
if (reconnectAttempts >= maxReconnectAttempts) {
if (options.onReconnectFailed) options.onReconnectFailed();
return;
}
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), maxDelay);
console.log('连接断开,' + delay + 'ms 后尝试重连,第 ' + (reconnectAttempts + 1) + ' 次');
reconnectTimer = setTimeout(function () {
reconnectAttempts++;
connect();
}, delay);
};
}
function close() {
userClosed = true;
clearTimeout(reconnectTimer);
if (ws) {
ws.close();
}
}
function send(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
return true;
}
return false;
}
connect();
return {
close: close,
send: send
};
}
这里有一个关键点:指数退避的重连延迟,第 1 次是 1 秒,第 2 次是 2 秒,第 3 次是 4 秒,第 4 次是 8 秒,这样依次翻倍,直到达到最大值 30 秒。应用场景是企业微信、钉钉那些服务端崩溃了 5 分钟才恢复的场景,如果你所有客户端都固定 1 秒重连一次,5 分钟就是 300 次请求,几千个客户端就能把服务器彻底搞死。退避机制会让大部分客户端错开重连时间,给服务器恢复留出空间。
还有一点,我代码里维护了一个 userClosed 标记。用户主动关闭页面或主动退出登录时,要调用 close 方法,这时候不应该再重连。如果用户都退出登录了,你还无限重连,这既浪费资源,也会在服务端留下错误日志,排查问题时制造干扰。
4.2 心跳保活的完整实现:真正让连接“活”着
前面讲了心跳原理,这里直接给出我可以复用的实现。核心思路:在连接成功的瞬间启动一个定时器,每隔 30 秒发送一个心跳包,同时记录是否收到服务端的响应。如果连续 N 次没收到响应,就主动 close,触发重连逻辑。
这里要注意的是,因为浏览器原生 WebSocket 的 onmessage 只接收服务端的数据帧,我们可以把心跳做成业务消息,也就是服务端收到 {type:'ping'} 就回 {type:'pong'}。这是目前前端生产环境里最常见的做法,因为很多后端消息框架对原生 WebSocket 的 Ping/Pong 支持并不友好,业务层反而更好处理。
javascript复制function createHeartBeatWebSocket(url, options) {
const heartbeatInterval = options.heartbeatInterval || 30000;
const heartbeatTimeout = options.heartbeatTimeout || 5000;
let pingTimer = null;
let pongWaitTimer = null;
let isAlive = true;
function startHeartbeat(ws) {
stopHeartbeat();
pingTimer = setInterval(function () {
if (ws.readyState === WebSocket.OPEN) {
isAlive = false;
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
pongWaitTimer = setTimeout(function () {
if (!isAlive) {
console.warn('心跳超时,主动断开连接');
ws.close();
}
}, heartbeatTimeout);
}
}, heartbeatInterval);
}
function stopHeartbeat() {
if (pingTimer) clearInterval(pingTimer);
if (pongWaitTimer) clearTimeout(pongWaitTimer);
}
function markAlive() {
isAlive = true;
if (pongWaitTimer) {
clearTimeout(pongWaitTimer);
pongWaitTimer = null;
}
}
// 这个函数在 onopen 中调用启动,在 onmessage 中检查消息类型
return {
startHeartbeat: startHeartbeat,
stopHeartbeat: stopHeartbeat,
markAlive: markAlive
};
}
实际使用的时候,onmessage 里要判断收到的消息是不是 pong:
javascript复制ws.onmessage = function (event) {
const data = event.data;
if (typeof data === 'string') {
try {
const obj = JSON.parse(data);
if (obj.type === 'pong') {
heartbeat.markAlive();
return;
}
} catch (e) {
// 非 JSON 数据,按普通消息处理
}
}
// 处理普通消息
};
心跳间隔的取值也很讲究。太短了浪费流量,太长了起不到保活作用。一般我刚才会用 30 秒到 60 秒之间的间隔。这个数值要考虑中间网络设备或代理服务器的空闲连接超时时间。比如 nginx 默认的 proxy_read_timeout 是 60 秒,你心跳间隔 45 秒,就是安全的。如果服务端或者代理设置的超时时间是 30 秒,你心跳间隔 25 秒就比较稳。具体要根据你的部署环境来调。
对了,还有一个容易踩的坑:你每次发心跳包,服务端都会收到一条消息,有些后端的消息统计或者日志系统会把心跳包当作业务消息处理,导致日志刷屏或者统计异常。所以前后端要约定一个特殊的前缀或者消息类型,比如 {"type": "ping","cid": "xxx"},后端能够识别并过滤掉。
4.3 与 Node.js 服务端配合:用 ws 库搭一个最小可用后端
前端说完,服务端也得展开。最常用的 Node.js WebSocket 库是 ws,Express 或 Koa 的那套 HTTP 中间件体系不太适用于 WebSocket。下面是一个最小可用的服务端示例:
javascript复制const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function (ws, req) {
console.log('客户端已连接,url:', req.url);
// 收到客户端消息
ws.on('message', function (data, isBinary) {
const message = isBinary ? data : data.toString();
console.log('收到消息:', message.toString());
// 处理心跳包
if (!isBinary) {
try {
const obj = JSON.parse(message.toString());
if (obj.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
} catch (e) {
// 不是 JSON,忽略
}
}
// 广播给所有客户端
wss.clients.forEach(function (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(message.toString());
}
});
});
ws.on('close', function (code, reason) {
console.log('客户端断开,code:', code, 'reason:', reason.toString());
});
ws.on('error', function (error) {
console.error('WebSocket 错误:', error);
});
});
console.log('WebSocket 服务器已启动: ws://localhost:8080');
ws 库底层很稳定,API 也比较简洁。服务端监听 message 事件,判断消息是二进制还是文本,然后做相应处理。这里的广播逻辑是把消息发给所有连接着的客户端,典型场景就是聊天室。如果你的业务是点对点,那就要维护一个用户 ID 到 WebSocket 连接的映射表,消息来了之后根据目标用户 ID 找到对应的连接,精确推送。
ws 库对一个连接的心跳检测也有内置的机制,你可以通过 ping 方法主动 ping 客户端:
javascript复制const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
不过这个对前端的影响不大,只要前端有心跳机制,服务端通常不用担心闲连接被掐掉。真正要注意的是服务端的连接数上限,因为每个 WebSocket 连接都是常驻的,不释放,如果客户端不断重连但服务端没有及时清理死连接,内存会一直涨。
4.4 与 Spring Boot 后端的配合:前端要重点注意的几个点
搜索引擎相关热词里大量出现了“springboot中websocket方法详解”“springboot项目调用第三方websocket”,做 Java 后端的朋友非常多。在这里我就补充一下,当前端对接 Spring Boot 的 WebSocket 接口时,需要注意哪些前端侧的东西。原生 JavaScript 的 WebSocket API 是浏览器统一的标准,不区分后端语言。你只要注意后端暴露的路径和端口,以及消息格式能不能对得上。
Spring Boot 整合 WebSocket 通常用 @ServerEndpoint 注解或者 Spring WebSocket 的 TextWebSocketHandler。后端暴露的路径比如 /ws/chat,这里要注意如果项目配置了 Servlet context-path,比如 /api,那么 WebSocket 连接地址也可能会被加上这个前缀。我当时遇到过前端怎么连都连不上,最后发现是后端的 context-path 被拼到了 WebSocket 路径前面。
还有一个很关键的点:后端如果加了拦截器,对 WebSocket 握手请求做鉴权,前端就需要在握手阶段带上 token。但浏览器的 WebSocket API 是不允许你自定义请求头的,handshake 阶段浏览器只允许带上有限的请求头。那么常见的做法是把 token 放在 url 的查询参数里,比如 ws://localhost:8080/ws?token=xxx,后端从查询参数里取出来做校验。或者更安全一点,把 token 放在子协议(Sec-WebSocket-Protocol)里面,但这个兼容性和实现都比较麻烦,我一般就用查询参数。
5. 部署与代理:nginx 配置 wss、负载均衡和跨域问题
5.1 nginx 代理 WebSocket:普通配置会直接失败
前端 WebSocket 开发时不时遇到的生产环境配置里,nginx 配置是最容易出问题的环节之一。很多项目用的是前后端分离部署,前端静态资源由 nginx 托管,后端接口也由 nginx 转发。这种情况下,WebSocket 连接必然也会经过 nginx。
如果你直接用普通的 HTTP 代理配置转发 WebSocket 请求,会发现在浏览器控制台里 WebSocket 连接一直报错,常见的现象是响应状态码是 200 而不是 101,或者直接的 400 Bad Request。
这里解释一下原因:WebSocket 握手请求里有两个特殊的请求头,Upgrade: websocket 和 Connection: Upgrade。HTTP 代理默认会忽略或者重写这两个头,导致后端收到请求时不知道这是一个 WebSocket 升级请求,最终还是按普通 HTTP 处理,返回 200 而不是 101,自然升级失败。
nginx 里正确的配置是显式地把这两个头透传过去:
nginx复制location /ws/ {
proxy_pass http://backend_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
proxy_read_timeout 和 proxy_send_timeout 这两个配置也很关键。WebSocket 连接建立后可能需要长时间保持空闲状态,如果这里的超时时间比较短,比如默认的 60 秒,那么一旦超过 60 秒连心跳包都没发,nginx 就会把连接断开。前端虽然有心跳机制,但为了防止某些特殊情况下心跳失效,我一般会把超时时间设置成一个比较大的值,比如 3600 秒。但这个还是要结合你的心跳间隔来配置,如果心跳每 30 秒一次,那 60 秒的超时就没问题,如果心跳间隔是 120 秒,那 60 秒的超时就把连接切断了。
再有就是负载均衡的场景。如果后端有多台服务器,负载均衡模式里如果默认按请求轮流分发(比如轮询),那么第一次握手请求可能分发到 A 服务器,连接建立后,后续的数据帧依赖的是同一条 TCP 连接,不会再经过新的 HTTP 请求,所以数据还是走 A。这一点在 nginx 的 http 负载均衡下是天然成立的,因为一条 TCP 连接的生命周期内只会被路由到一台后端。但如果你的架构里有其他层面的负载均衡或网关,比如某些云负载均衡产品在空闲超时时间较短的情况下会回收空闲连接,那就需要留意了。前端发心跳,本质上就是让连接保持活跃,防止这种设备回收连接。
5.2 wss 配置:https 页面必须走加密通道
这个问题在实践里尤其多发。很多团队上线前把页面切到了 https,然后用浏览器打开页面,发现 WebSocket 连接一直失败,报错信息是 “The page at 'https://xxx' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://xxx'”。
这个错很清楚:https 页面禁止连接不加密的 ws 地址。解决的办法就是让 WebSocket 也走加密,也就是 wss://。
如果你的 WebSocket 也是通过 nginx 代理的,那么 wss 的配置本质上是在 nginx 上监听一个 https 端口,然后按上面的方式把流量转发到后端的 ws 端口。HTTPS 证书的处理由 nginx 完成,后端其实不感知。
nginx复制server {
listen 443 ssl;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/cert.key;
location /ws/ {
proxy_pass http://backend_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
前端连接地址就变成 wss://your.domain.com/ws/xxx。在用 wss 的时候,https 页面的浏览器地址栏小锁依旧正常,连接也稳定。
还有跨域问题。WebSocket 的连接默认受同源策略影响,但握手阶段的 Origin 头会留给服务器做判断。你在 nginx 层转发的时候不需要做 CORS 处理,因为 WebSocket 不使用 CORS 标准,但服务端可以检查 Origin 是否符合白名单,这是安全层面的事。开发环境如果遇到 Origin 不匹配的问题,多半是服务端有校验,不是浏览器拦截。
6. 常见问题与排查技巧实录:遇到别慌,按顺序查
6.1 从一张报错速查表开始
我把这些年实际遇到过的问题整理成了一个表格。这比任何长篇大论都更有参考价值,每次排查的时候照着查一遍,命中率非常高。
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 连接一直 pending,最终 Failed | 地址写错、服务端未启动、端口错误 | 先用 curl 或 Postman 测试服务端是否可访问 |
| 浏览器报 400 Bad Request | nginx 没配置 Upgrade 头,或者路径不对 | 检查 nginx location 和 proxy_set_header |
| 浏览器报 403 Forbidden | 服务端校验 Origin 拒绝,或者鉴权失败 | 检查服务端日志,确认是否要求 token |
| 连接建立后几十秒自动断开 | nginx/代理空闲超时,或者服务端没心跳 | 抓包看断开前的最后一个包,确认是否空闲超时 |
| 页面加载后连接建立又立刻断开,code=1006 | 服务端异常退出、网络层断开 | 1006 是异常关闭,查后端日志与宕机时间 |
| 连接断开后不断重连,服务被打挂 | 没有退避或退避太激进 | 检查重连逻辑和最大重连次数 |
| wss 连接失败,报证书相关错误 | 证书无效或域名不匹配 | 检查证书配置,用浏览器直接访问同域 https 看看证书状态 |
| 服务端收到消息但前端收不到 | 服务端没有正确广播、消息类型是二进制或压缩格式 | 在后端加日志,确认 send 是否执行以及发送的目标连接 |
| 浏览器内存持续暴涨 | 消息体过大、Blob 未释放、DOM 节点无限追加 | 用 DevTools Performance 抓内存快照,定位是数据缓存还是渲染问题 |
| 多开页面都连接,服务器连接数很快飙满 | 没有做连接复用、或者连接泄漏 | 统计每页面一条连接是正常的,但需要设置连接数上限和 maxReconnectAttempts |
这条表格里排在前两位的问题,几乎覆盖了我实际收到求助的 70% 以上。第一个是 nginx 配置问题,第二个是地址或者路径问题。在深入排查其他东西之前,一定要先确认这两件事。
6.2 浏览器崩溃:WebSocket 导致页面直接挂掉的排查实录
搜索热词里“websocket导致浏览器崩溃”这个关键词让我想起一个非常典型的排查经历。当时我们的监控大屏页面,开着一天后会越来越卡,最后整个标签页直接崩了。第一反应是内存泄漏,用 Chrome 的任务管理器看,页面内存从几百 MB 一路涨到几 GB,随后崩溃。
到底是谁吃掉了内存?我们用 Performance 面板抓了几次 profile,发现了几个问题。
一是收到消息后创建了新的 DOM 节点,但旧节点没有清理。比如大屏上的实时日志列表,每收到一条消息就 append 一个 div,看着最多只显示 100 条,其他早该清掉,但如果代码里只做了 append 没做 remove,DOM 节点就会无限增长,浏览器内存迟早被打爆。这是所有实时页面最常见的坑,解决方式很简单,超过上限就移除最早的节点,或者用固定长度的数组维护数据再整体渲染。
二是 WebSocket 收到二进制数据时,如果转换成 Blob URL 使用了 URL.createObjectURL(),但是用完没有调用 URL.revokeObjectURL(),每次创建的对象 URL 不会被回收,也会造成泄漏。这个非常隐蔽,因为你在页面上看不到任何提示,但内存会随着消息数量线性增长。
三是前端代码里不经意地把收到的所有消息都存到了一个数组里,用来做历史记录回放,但没有设置上限。示例的聊天室如果聊一天,消息可能有上万条,这些对象每个都带着完整的信息,加起来内存就大了。如果你确实需要保存历史消息,请至少加一个上限,超出后丢弃最老的消息,或者存到 IndexedDB 而不是内存。
经过这几处修复后,页面的内存占用稳定了很多,开一整天也不怎么会涨。
6.3 连接频繁断开和自动重连的完整排查链路
“stream disconnected before completion: websocket closed by server before res” 这个报错,字面意思就是 “流在完成前断开:服务器在返回响应之前关闭了 WebSocket”。这是我在对抗一些网关或代理时见过的高频报错之一,核心逻辑就是连接建立后,服务器(或中间的代理)在预期时间内没有收到完整的请求数据,就非常干脆地把连接关闭了。
我自己排查这个问题的思路是先用浏览器开发者工具 Network 面板找到 WebSocket 连接,点击查看 Messages 和 Frames,看看连接建立后的时序。如果握手成功后几十秒就断了,优先怀疑空闲超时;如果刚连接就断开,优先怀疑服务端拒绝或异常。
要排查服务器到底怎么想的,最直接的办法是看后端日志。WebSocket 连接关闭时,服务端通常会打日志,包含 close code 和 reason,这些信息能帮你判断是服务端主动关闭还是网络层断掉。如果是服务端主动关闭,日志中一般会带上业务含义。
还有一个细节值得注意:前端某些应用在页面进入后台(标签页被切走)时,浏览器会限制定时器的执行频率。比如你用 setInterval 做心跳,标签页在后台时,浏览器可能把定时器最小间隔限制到 1 秒甚至更久,这可能导致心跳间隔变得不规律。在极端情况下,如果后台时间过长,心跳可能滞留,连接被服务端认为超时断开。这时就要靠 onclose 触发重连逻辑来恢复,而不是在后台继续维护这条连接。
6.4 关于 javascript:void(0) 和 javascript 函数报错:先定位再动手
这次的搜索热词里还有不少看起来跟 WebSocket 不是直接相关的词,比如 javascript:void(0)、javascript:void(o) 报错。我顺手点开看了看发现,这些其实是前端页面里常见的两个问题,在 WebSocket 调试时也可能一起出现。
javascript:void(0) 通常出现在 a 标签的 href 属性里,意图是点击链接不跳转:
html复制<a href="javascript:void(0)" onclick="handleClick()">点击</a>
这是一个经典但不算太优雅的写法。代码本身没问题,但如果 handleClick 函数抛了异常,页面上会弹出错误提示,背景里的 WebSocket 连接也可能因为这个异常没有继续执行而显得像断了。调试的时候要把“JavaScript 脚本报错”和“WebSocket 连接断开”两个问题分开定位,先修 JS 异常,再检查连接状态。我在早期就因为一个 onclick 里抛异常,导致后续发送消息的逻辑没有执行,看起来就像 WebSocket 挂了,实际是前端自己的锅。
javascript:void(o) 这种报错里那个 o 是什么?通常它是在压缩后的代码里出现的变量名,压缩工具把函数参数名压成了单个字母,于是函数内部某处抛错时,报错信息里就变成了 javascript:void(o)。这类报错的关键不是纠结在哪个变量上,而是用浏览器 DevTools 的 Source 面板里开启 Source Map,找到压缩前对应的原始代码,才能定位真正的错误原因。如果你的项目没开 source map,报错指向的就是一整块混淆代码,排查起来会非常痛苦。所以我的建议很直接:开发环境一定要开启 source map,上线后也要保留 source map 文件但不要公开部署,只在错误监控平台里保留一份。
7. 一些值得留意的实战经验与性能建议
到这里,WebSocket 的使用、原理、实战、调试都讲完了。最后再分享几个我在团队里反复强调的经验,不算高深,但每一条都是从真实事故里换来的。
第一,连接数一定要设上限。不管是服务端还是客户端,都要有边界意识。客户端设置 maxReconnectAttempts,不要无限重连。服务端设置最大连接数,超过后对新连接直接拒绝或者踢掉最老的空闲连接。我见过一个聊天室功能在上线第二天把公司测试服务器连接数打满的,就是因为某个前端版本的重连逻辑没有上限,所有客户端都在疯狂重连。
第二,WebSocket 消息体大小要限制。如果你不做限制,一个超大的消息会占用大量内存,还可能让别人构造一个超高负载的请求把服务拖垮。后端一般在框架层面可以配置最大帧大小,ws 库在构建服务的时候也可以传这个参数,一定不要用默认的无限大。
第三,生产环境的 WebSocket 一定要有监控和告警。至少要知道当前有多少个连接、连接断开的频率是多少、连接建立的成功率是多少。可以用简单的计数器埋点上报,也可以接成熟的监控系统。没有监控,你永远不知道线上已经有一批用户的实时功能已经失效了,因为他们看到的现象只是“页面没自动更新”,多数人不会主动告诉你。
第四,多端共享一条连接的问题。如果同一个用户在不同标签页都打开了系统,每个标签页都会建立一条 WebSocket 连接。这样不仅浪费资源,还可能导致消息重复处理。比较常见的方案是使用 SharedWorker 让多个标签页共享一条连接,或者用 localStorage 的 storage 事件做跨标签页广播,其中一个标签页持有连接,收到消息后转发给其他标签页。SharedWorker 兼容性稍微差一些,但现代浏览器基本都支持了,可以根据目标用户群来选择。
第五,WebSocket 和 HTTP 的配合使用。不要为了追求实时,把所有数据都改成 WebSocket 推。页面的静态数据、用户信息、普通提交表单,这些走 HTTP 就好。WebSocket 专注在真正需要实时性的数据流上。我见过有些团队把登录接口也用 WebSocket 做,结果登录失败、token 过期、网络重连这些逻辑全搅在一起,复杂度爆炸,后来花了整整一个迭代才拆出来。合适的工具用在合适的场景。
以后遇到任何 WebSocket 连接问题,先确认地址对不对、证书对不对、代理配置对不对,再考虑业务逻辑。这几样排完,80% 的问题都能解决,剩下的 20% 再耐心抓包查日志就好。
