1. 为什么前端要用 WebSocket:先从 HTTP 轮询的痛点说起
做前端这几年,真正逼着我去研究 JavaScript 连接 WebSocket 的,不是网上那些花哨的 Demo,而是业务方的两句话:“页面上的状态要实时变”“消息不能等刷新才出现”。在线客服、协作白板、股票行情、扫码登录、打印机任务状态……这些场景一旦背上“实时”两个字,用普通 HTTP 请求去做数据更新,痛点会非常明显:你不知道订单什么时候被接单,不知道对方什么时候发来消息,只能让页面每隔几秒问一次服务器“有变化了吗”,也就是轮询。
轮询不是不能做,但并发一旦上来,你会看到一堆 502、请求超时、服务器 CPU 飙升,前端还要处理定时器错乱、清理不及时引发的内存泄漏。更离谱的是,哪怕服务器那边没有任何新消息,每一轮空请求照样会把 HTTP 头、Cookie、鉴权逻辑全部重新走一遍。后来我又尝试过长轮询,虽然比普通轮询强一些,让服务器先“挂住”请求,等有数据再返回,但每次连接到期后还是得重新发请求,本质依然是一条单向管道,服务端没法主动往客户端推数据。
WebSocket 的出现,正是把“服务端主动推送”这件事做成标准。它和 HTTP 一样基于 TCP,但只需要一次握手,就能在客户端和服务端之间维持一条全双工通道。所谓全双工,就是两端都能随时发数据,不用再分轮流。JavaScript 这门语言在浏览器里天生内置了 WebSocket 构造函数,Node.js 环境里也有官方实现和 ws 这样的第三方库。你不需要装任何插件,只需要 new WebSocket(url),就能把一个实时通道握在手里。
这篇文章适合的读者,不只是已经熟练使用框架的前端工程师。哪怕你刚学 JavaScript 没多久,只要能看得懂函数和对象,跟着文章里的代码一步步敲,也能把连接跑起来。我会把连接时涉及的 URL 格式、状态码、握手过程、事件回调、重连策略、消息格式设计、部署时常见的坑全部讲清楚,而且会尽量解释每一步“为什么这么写”,而不是丢给你一段能跑但不理解的黑盒代码。
1.1 WebSocket 相比 HTTP 轮询,到底赢在哪里
先说一个容易被忽略的点:WebSocket 的首轮握手,其实就是一个带特殊请求头的 HTTP Upgrade 请求。浏览器发一个 GET 请求,服务端看到里面有 Upgrade: websocket,如果同意切换协议,就会返回 101 Switching Protocols,之后这条 TCP 连接就从 HTTP 协议切换成了 WebSocket 协议。
所以它不是完全脱离 HTTP 的另一种东西,而是借助 HTTP 完成渠道协商。这样做有一个很明显的好处:连接建立之前,你依旧可以通过 HTTP 的 Header 传递 Cookie、Token 等鉴权信息,网关、负载均衡器也能先走同一套规则。
但连接建立之后,WebSocket 的传输效率和 HTTP 就完全不是一个量级了。用 HTTP 轮询,每次请求至少有几十个字节甚至几百字节的 Header 开销,这些 Header 大多数是重复的;而 WebSocket 数据帧的最小开销只有 2 个字节左右,如果消息内容很短,省下来的网络消耗非常可观。更重要的是,HTTP 协议是“一问一答”,客户端不发请求,服务器就不能回消息;WebSocket 打破了这层限制。
拿扫码登录举例。以前我实现扫码登录,可能需要前端每 3 秒轮询一次“二维码状态”,直到用户扫码并确认。换成 WebSocket 后,用户手机确认的瞬间,服务器直接推送一条 { action: 'loginSuccess', token: 'xxx' },网页马上跳转,中间没有任何等待。对于消息推送场景,这个模型直观得多:客户端不用反复问,服务端有事件就推过来。
1.2 WebSocket 连接的生命周期和核心机制
JavaScript 里的 WebSocket 对象,其实内部维护了一个状态机。你通过 readyState 属性可以看到当前连接处于哪个阶段:
| readyState | 常量名 | 含义 |
|---|---|---|
| 0 | CONNECTING | 正在握手,还没建立连接 |
| 1 | OPEN | 连接已建立,可以收发数据 |
| 2 | CLOSING | 正在关闭,客户端或服务端发起了关闭流程 |
| 3 | CLOSED | 连接已经关闭或根本没能建立 |
我第一次看这个状态机的时候,觉得前端写连接无非是四件事:连接成功、收到消息、连接出错、连接关闭。真正用起来才发现,很多线上问题恰恰出在对状态的不重视。比如根本没有判断 readyState 是不是 1 就直接调用 send(),浏览器会直接抛异常,而你会花很长时间去查为什么代码逻辑没进来。
关于消息格式,WebSocket 协议支持文本帧和二进制帧。文本帧的内容是 UTF-8 字符串,在 JavaScript 里表现为普通的 string 类型;二进制帧则可以是 Blob 或 ArrayBuffer,具体拿到哪一种,由 binaryType 属性决定。做浏览器端开发时,如果你要传输 JSON 格式的数据,最简单的方式就是 JSON.stringify() 后以文本帧发送,收到消息后再 JSON.parse() 解析。这样写业务比较直观,做多人协作、聊天这类应用时错误也容易排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JavaScript 连接 WebSocket 的入门写法与状态处理
很多教程一上来就让你填完整代码,但我觉得先拆开看一个连接对象的生命周期,后面理解封装模块会轻松很多。JavaScript 里发起连接只需要一行:
javascript复制const ws = new WebSocket('ws://localhost:8080/chat');
这一行代码执行的同时,浏览器会立刻去发起握手。注意:WebSocket 的 URL 是以 ws:// 或 wss:// 开头的,不能用 http://。如果你要加密传输,就必须使用 wss://,它的作用类似于 HTTPS,可以防止中间人窃听或篡改内容。
2.1 URL、子协议和二进制类型这些参数怎么定
new WebSocket(url) 还有第二个可选参数 protocols。这个参数不是给自己看的“标签”,而是一个字符串或字符串数组,用来告诉服务器“客户端期望使用哪个子协议”。比如你后端同时支持 JSON 协议和 MessagePack 协议,客户端可以传一个 'json' 或 'msgpack',服务器在握手的响应里必须显式选一个与客户端匹配的子协议,否则连接会报错。
我先用一个无需鉴权的本地调试地址来演示最基础设置:
javascript复制const socket = new WebSocket('ws://127.0.0.1:8080/ws', 'json');
socket.binaryType = 'arraybuffer';
这里把 binaryType 设置成 arraybuffer,意味着二进制消息到达时,会以 ArrayBuffer 的形式进入回调函数,而不是 Blob。如果你要处理音频、视频帧或二进制协议数据,通常选择 ArrayBuffer 更方便,因为你可以直接通过 DataView 去解析字节;如果只是下载文件然后交给浏览器处理,用默认的 Blob 更省内存。这个参数需要在实际使用场景里想清楚,不要因为默认值看起来能用就一直不主动设置。
2.2 四个核心事件回调,顺序和触发时机都要弄清
在原生 JavaScript 里,WebSocket 通过事件机制对外通信。最常用的是下面四个:
javascript复制socket.onopen = function (event) {
console.log('连接已建立,readyState =', socket.readyState);
socket.send(JSON.stringify({ type: 'hello', data: '我从浏览器来了' }));
};
socket.onmessage = function (event) {
// event.data 可能是 string / Blob / ArrayBuffer,取决于帧类型和 binaryType
console.log('收到消息:', event.data);
};
socket.onerror = function (event) {
console.error('连接出错:', event);
};
socket.onclose = function (event) {
console.log('连接关闭, code =', event.code, 'reason =', event.reason);
};
很多人会把 onerror 当成连接失败的主要原因,但实际经验告诉我,onerror 只是告诉你“出错了”,真正的错误细节常常不在这里,而是在随后的 onclose 事件里。比如服务器端口没开、握手被拒、网络断开,浏览器通常会先触发 error,紧接着触发 close。所以线上日志里,我习惯把两者一起记录。
onclose 回调里的两个字段特别重要:event.code 是关闭码,event.reason 是服务端或浏览器给出的关闭原因。关闭码 1000 代表正常关闭;1006 很特殊,它表示连接非正常关闭,而且这个状态码不会真的通过帧发送给你,你只在 close 事件里能看到;1009 表示消息太大被拒绝;1011 表示服务器遇到内部错误而关闭。写日志的时候把这些 code 原样打出来,对排查问题很有价值。
另外还要注意,send() 方法只能在连接状态为 OPEN 时调用,也就是 readyState === 1。如果你的页面刚刷新,服务端立刻推送消息,而你的监听回调还没注册完成,理论上是有可能漏消息的。所以更稳妥的做法是,在页面初始化时优先完成连接建立,再渲染业务区。
2.3 快速起一个 Node 服务端配合本地调试
只看浏览器端代码,很难理解连接细节。我建议你本地装一个 Node.js 环境,用 ws 库起一个简单服务端来搭配调试。先用 npm 初始化项目并安装依赖:
bash复制mkdir ws-demo
cd ws-demo
npm init -y
npm install ws
然后新建 server.js:
javascript复制const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws, req) {
console.log('新连接来自:', req.socket.remoteAddress);
ws.on('message', function message(data, isBinary) {
console.log('收到:', data.toString());
// 原样回给客户端,方便你确认双向通道是否通畅
ws.send(data, { binary: isBinary });
});
ws.on('close', function close(code, reason) {
console.log('连接关闭:', code, reason.toString());
});
ws.send(JSON.stringify({ type: 'welcome', msg: '连接成功' }));
});
启动这个服务端后,你在浏览器的 Console 里直接执行前面的 JavaScript 代码,就能看到连接建立、消息回显、连接关闭的完整过程。你不需要急着写业务,先把这个跑通,后面遇到奇怪问题时会有一个非常干净的环境去复现,比在一个大型项目里猜来猜去高效得多。
3. 把连接封装成可复用模块:重连、心跳和生命周期管理
入门代码能用,但离生产还有很大距离。实际项目中你不可能每次进页面都手写一套 onopen/onmessage,更不可能放任断线之后再也不连。网络环境是脆弱的:Wi-Fi 切换、手机网络漂移、服务器重启、代理超时,都会导致 WebSocket 连接被切断。如果前端不做重连策略,用户只能刷新页面,体验会非常差。
3.1 一个标准 RealtimeClient 应该怎么设计
我希望这个模块解决几件事:连接建立自动执行、收到消息分发回调、断线自动重连、心跳保活、手动主动关闭、避免重复创建实例。这里给出一版我常用在中小项目里的简洁实现,不依赖任何框架,复制到浏览器里就能用:
javascript复制class RealtimeClient {
constructor({ url, protocols, heartbeatInterval = 15000, reconnectDelay = 1000 }) {
this.url = url;
this.protocols = protocols;
this.heartbeatInterval = heartbeatInterval;
this.reconnectDelay = reconnectDelay;
this._ws = null;
this._heartbeatTimer = null;
this._manualClosed = false;
this._handlerMap = {};
this._reconnectCount = 0;
this.connect();
}
connect() {
if (this._ws) {
this._ws.close();
}
const ws = new WebSocket(this.url, this.protocols);
this._ws = ws;
this._manualClosed = false;
ws.onopen = () => {
this._reconnectCount = 0;
this._startHeartbeat();
this._emit('open');
};
ws.onmessage = (event) => {
// 这里把文本帧自动反序列化为对象,方便上层使用
let payload = event.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch (e) {
// 如果服务端推送的是非 JSON 文本,保留原始字符串
}
}
this._emit('message', payload);
};
ws.onerror = (err) => {
this._emit('error', err);
};
ws.onclose = (event) => {
this._stopHeartbeat();
this._emit('close', event);
if (!this._manualClosed) {
this._scheduleReconnect();
}
};
}
_startHeartbeat() {
this._stopHeartbeat();
// 每个固定周期发一次探测消息,服务端可以根据消息决定是否断开
this._heartbeatTimer = setInterval(() => {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this.send({ type: 'ping' });
}
}, this.heartbeatInterval);
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
_scheduleReconnect() {
// 简单的指数退避,避免服务端未恢复时前端以极高频率疯狂重连
const delay = Math.min(this.reconnectDelay * Math.pow(2, this._reconnectCount), 30000);
this._reconnectCount += 1;
setTimeout(() => {
if (!this._manualClosed) {
this.connect();
}
}, delay);
}
send(data) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(data));
} else {
console.warn('当前连接状态不是 OPEN,消息未发送', data);
}
}
on(type, handler) {
if (!this._handlerMap[type]) {
this._handlerMap[type] = [];
}
this._handlerMap[type].push(handler);
}
_emit(type, ...args) {
const list = this._handlerMap[type] || [];
for (const handler of list) {
try {
handler.apply(this, args);
} catch (err) {
// 回调内部异常不能影响后续监听器执行,也方便定位业务代码问题
console.error('事件回调执行出错:', type, err);
}
}
}
close() {
this._manualClosed = true;
this._stopHeartbeat();
if (this._ws) {
this._ws.close();
}
}
}
核心思路是,把事件回调集中注册到 _handlerMap 里,业务代码可以用 client.on('message', fn) 这样的方式订阅,而不是直接修改 client 内部的事件属性。这样既方便多个模块同时监听同一个连接,也能在最外层统一捕获异常。
3.2 和 Vue 3 / React 配合时,生命周期不能漏
很多前端朋友踩过同一个坑:在 Vue 组件里 created 或 mounted 阶段创建了连接对象,但组件销毁时忘了关闭连接。结果用户从聊天页跳到首页再跳回来,页面开了十几个 WebSocket,每个都在收消息。这不仅是资源浪费,还会造成消息重复、页面卡顿,严重时甚至触发浏览器崩溃。
在 Vue 3 组合式 API 里,正确的做法是在 onMounted 或者初始化函数里连接,在 onUnmounted 里关闭:
javascript复制let client = null;
onMounted(() => {
client = new RealtimeClient({ url: 'ws://localhost:8080/ws' });
client.on('message', (data) => {
if (data.type === 'notice') {
// 这里再根据业务决定是否弹出 ElMessage 或更新页面状态
updateNoticeList(data);
}
});
});
onBeforeUnmount(() => {
if (client) {
client.close();
client = null;
}
});
React 里也是类似的思路,通常放在 useEffect 中创建连接,并在清理函数中关闭:
javascript复制useEffect(() => {
const client = new RealtimeClient({ url: 'ws://localhost:8080/ws' });
client.on('message', handleMessage);
return () => {
client.close();
};
}, []);
如果担心组件卸载时后端进程还在运行,你可以在 close() 时主动向服务端发送一条 { type: 'leave' },这样服务端也能及时清理房间成员状态,而不是等待 TCP 超时才把用户踢下线。
3.3 重连和心跳的参数到底该怎么定
重连不是越快越好。如果你用 500 毫秒固定频率去重连,服务端故障恢复前,你的前端可能会造成“重连风暴”,反而让后端雪上加霜。指数退避是一个比较稳妥的方案:第一次断线后等 1 秒,第二次等 2 秒,第三次等 4 秒,最多封顶 30 秒。这样服务端刚挂掉的几分钟里,你的客户端不会傻乎乎地每秒撞一次门。
心跳机制也常常被忽略。Nginx、云负载均衡器、运营商防火墙,都可能会把一段时间内没有数据传输的 TCP 连接视为空闲而掐断。浏览器不会因为连接被底层掐断就立刻知道,它可能要等很久才能发现异常。因此前端需要定时给服务端发一个很小的消息,比如每 15 秒发一次 { type: 'ping' },服务端只要收到,就知道连接还活着。服务端也可以在下一次真正要发业务消息时发现发不出去,从而主动断开这条连接。
心跳间隔要小于网关空闲超时时间。如果你们的代理层配置了 60 秒回收空闲连接,你的心跳至少得每 30 秒发一次。15 秒是我常用的值,因为它在“及时发现问题”和“减少无意义流量”之间比较平衡。
4. 真遇到连接失败时,我从哪几个地方排查
不管是刚入门还是做了几年,前端 WebSocket 连接失败都是让人头疼的问题。因为它在浏览器里的报错往往很简单,就一行红色提示,不告诉你到底是网络不通、服务端拒绝、还是代理层搞的鬼。下面我把自己用过的排查顺序整理成一张速查表,你遇到问题时可以按图索骥。
4.1 浏览器控制台常见报错和根因对照
| 报错信息特征 | 常见原因 | 处理方向 |
|---|---|---|
WebSocket connection to 'ws://...' failed |
服务端没启动、地址端口错、网络不通 | 先确认服务端进程是否在运行,再确认端口是否能访问 |
Unexpected response code: 400 |
握手请求被拒绝,常见于鉴权失败或子协议不匹配 | 检查服务端鉴权中间件返回的错误,检查握手时传递的 token |
Unexpected response code: 404 |
WebSocket 路径不对,服务端没有监听这个路径 | 核对 URL 中的 path,例如 /ws 和 /socket 不是同一个 |
浏览器报 ERR_SSL_PROTOCOL_ERROR |
页面是 HTTPS,但 WebSocket 地址使用了 ws:// |
改成 wss:// 才能穿过 HTTPS 页面 |
WebSocket is closed before the connection is established |
客户端在握手完成前调用了 close,或者连接被很快断开 | 检查服务端握手阶段有没有抛异常导致提前关闭 |
| 连接能打开,但过一段时间自动断开,关闭码是 1006 | 通常是被网络代理或网关掐断,也可能是服务端内部异常退出 | 加上心跳保活,检查服务端日志和代理超时配置 |
我个人最常遇到的其实是第二种:服务端要求从 Header 里取 token,但 WebSocket API 在浏览器里没法自定义请求头,导致握手直接被拒绝。遇到这种情况,通常的解决办法是改走“查询参数带 token”的方式,比如 ws://localhost:8080/ws?token=abc,或者把 token 放到子协议里,再由服务端做一次兼容校验。
4.2 用 DevTools 的帧面板查看实时通信
连接建立后,排查业务数据问题首选不是 Console,而是 Chrome DevTools 的 Network 面板。你刷新页面,在请求列表里找到类型为 WebSocket 的那一项,点进去会看到三个页签:Headers、Messages、Timing。
Messages 页签能看到这条连接上每一条消息的发送和接收方向、时间、内容。如果你发现消息内容是一大串乱码,多半是后端发送了二进制帧,而你前端按文本帧去解析了。如果发现消息没有按预期顺序到达,也能在时间线上看到是否由网络延迟导致。
Headers 页签里的重点是状态码和握手响应头。你必须确认服务端返回的是 101,而不是 200 或 404。有一些后端框架如果不支持 WebSocket,可能会把握手请求当成普通 HTTP 请求处理,返回 200,浏览器端也会直接报错。这个细节配合后端同事排查时尤其有用,不用争“我这边没问题”,打开这个面板看状态码就能定位到问题归属。
4.3 代理、跨域和部署层面对连接的影响
WebSocket 也会受到跨域限制,但它的处理方式和普通 AJAX 不一样。浏览器会通过握手请求里的 Origin 头告诉服务器网页来自哪个域,服务器可以校验这个 Origin 决定是否允许连接。如果你的页面是 https://a.com,而 WebSocket 地址是 wss://b.com/ws,这就属于跨域连接。服务端必须允许来自 a.com 的 Origin,否则握手会失败。开发环境下,你需要确认后端 WebSocket.Server 的配置里有相应的跨域校验逻辑。
生产环境还有一个高频问题:WebSocket 连接要经过 Nginx 等反向代理。如果代理只配置了普通 HTTP 转发,没有处理 Upgrade 头,连接会在代理层被截断。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_read_timeout 3600s;
}
如果你发现线上环境连接不稳定,而本地调试一切正常,优先检查代理层这一段配置。proxy_read_timeout 3600s 的意思是允许这条连接长时间没有数据而不被 Nginx 掐断,如果你不设置默认的 60 秒或 75 秒,空闲连接会在没有业务消息时被代理回收,导致你就算加了心跳,只要心跳间隔超过这个值,依然会被断开。
4.4 消息频率过高引发浏览器崩溃的缓解办法
热搜词里有一条是“WebSocket 导致浏览器崩溃”,这确实不是谣言。当服务端以极高频率推送大量消息,而前端每一条消息都立刻触发 Vue 或 React 的响应式更新,页面会陷入无休止的渲染循环,内存占用快速上升,最终标签页崩溃。
我之前做过一个实时行情页面,后端每秒推送 20 条报价数据,前端每一条都直接更新价格文本和 K 线图,结果用户开着页面 10 分钟,内存占用从 80MB 涨到 800MB。后来处理办法是加一层节流缓冲:把一秒钟之内的多条数据合并,只保留最新一条用于绘图;或者先用 requestAnimationFrame 把渲染频率限制在每帧一次,避免数据更新频率超过屏幕刷新率。WebSocket 本身很稳定,崩溃往往不是连接的问题,而是你处理消息的方式没考虑频率。
5. 从项目里沉淀的几个工程化经验
最后这部分,我不想讲太多理论,而是把几个比较实用的点和踩过的坑做一个分享。你会发现 WebSocket 真正让人头疼的地方,往往不是建立连接本身,而是连接建立之后怎么让它在复杂的现实环境里稳定运行。
5.1 消息格式必须要做结构化和版本管理
很多刚上手的人会直接 send('hello')、send('123'),服务端也返回裸字符串。这种模式放在玩具项目里没问题,但项目一大就会失控。我建议从第一天起就定义一个统一的消息信封,比如:
json复制{
"type": "chat.message",
"id": "uuid-xxx",
"ts": 1710000000000,
"data": {
"from": "u_1024",
"content": "你好"
}
}
type 字段用来区分业务动作,id 用来做消息去重和请求响应匹配,ts 是时间戳,data 是真正的业务负载。只要大家都按这个结构发,服务端路由和前端分发都会简单很多。消息格式如果变化,也要在 type 上带上版本,比如 chat.message.v2,避免老客户端和新服务端之间的解析错乱。
5.2 服务端主动关闭前,最好告诉客户端原因
WebSocket 关闭事件里有一个 code 和 reason 可以用,但很多后端同事不知道。当服务端因为用户被踢下线、权限变更、系统维护等原因要断开连接时,正确的做法是使用 close(code, reason) 主动发送一个关闭帧:
javascript复制ws.close(4001, 'user offline');
ws.close(4401, 'token expired');
前端收到 onclose 事件后,看到 code 是 4001,就会知道是正常业务关闭,不需要触发重连;如果看到 4401,则应该跳转登录页重新鉴权。如果后端不做区分,直接断开,前端拿到 1006,只会傻傻地不断重连,然后被服务端一次次拒绝,形成没有意义的循环。
5.3 连接管理和业务解耦,能少写很多重复代码
我见过一些项目把 WebSocket 连接直接写在页面组件里,导致一个 H5 项目里存在五六条连接。正确思路是把它做成一个全局单例模块,由它统一管理连接状态、心跳、重连和消息分发。业务组件只关心自己感兴趣的消息类型,不需要关心底层连接现在是 CONNECTING 还是 CLOSED。这样,将来你要在连接建立后统一上报日志、统一做 token 刷新重连,只需要改一个模块,而不是全局搜索替换。
如果你做的是大型项目,还可以单独抽象一层“消息总线”,让 WebSocket 只负责把原始数据从服务端收进来,再按消息类型分发到不同页面。用户从列表页进入详情页时,连接不会因为组件卸载而反复开关,页面切换的响应速度也会明显提升。
最后分享一个我习惯保留的小技巧:在本地开发时,我会在 WebSocket 模块里加一个 debugLog 开关,打开后会把每一条收到和发出的消息格式化打印到控制台。这个开关在生产环境可以关掉,但开发阶段能帮你节省大量“这个数据到底发出去没有”的猜测时间。WebSocket 本身并不复杂,真正决定项目成败的,往往是这些你看不见的细节。如果你能把连接状态、消息协议、重连策略都想清楚,这套实时通信方案会非常稳定,比任何“看起来能用”的 Demo 都扛得住线上流量。
