WebSocket
WebSocket 更适合放在请求与数据层里看,因为它处理的不是“某个请求怎么发出去”,而是“连接建立之后,客户端和服务端怎么持续交换消息”。
这类能力通常会落在这些场景:
- 聊天
- 协同编辑
- 实时看板
- 在线游戏
- 交易或行情推送
- 需要客户端主动发消息、服务端也要主动回推的系统
如果只有服务端单向推送需求,SSE 往往已经够用;一旦进入双向通信、高频交互、房间状态同步,WebSocket 通常会成为主线。
WebSocket 在解决什么
HTTP 请求的默认模式更像:
- 客户端发起
- 服务端响应
- 一次请求结束
这种模型很适合:
- 列表查询
- 详情查询
- 提交表单
- 上传下载
但一旦进入实时场景,就会暴露几个限制:
- 轮询频率低,更新不及时
- 轮询频率高,服务端压力大
- 客户端和服务端都需要随时发消息
- 多个用 户同时在线时,状态同步会越来越麻烦
WebSocket 的核心价值,就是在一次握手之后,把连接保持住,让双方都能在同一条连接上持续发消息。
先记住它的几个特征
- 基于 TCP
- 浏览器里通过
WebSocketAPI 使用 - 连接建立后是全双工通信
- 客户端和服务端都能主动发消息
- 更像“消息通道”,不是普通 REST 接口替代品
- 很适合实时交互,但也会带来连接管理、鉴权、扩缩容、协议设计这些新问题
MDN 也明确提到,WebSocket API 可以创建并管理连接,同时在连接上收发数据。
握手和连接怎么理解
WebSocket 一开始并不是凭空新建一种浏览器连接,而是先借用一次 HTTP 握手,再升级成持久连接。
可以先记住这条线:
- 浏览器发起
ws://或wss://连接 - 服务端同意升级协议
- 握手完成后,双方进入持续消息通道
- 后面发送的就是 WebSocket 消息,不再是普通 HTTP 请求-响应模型
这一层意味着:
- 网关、反向代理、负载均衡需要明确支持升级连接
- 部署链路里,超时和连接数限制要单独看
- 问题排查时,不能只按 HTTP 接口思路想
一个最小例子
const socket = new WebSocket('wss://example.com/ws')
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'ping' }))
})
socket.addEventListener('message', (event) => {
console.log('message:', event.data)
})
socket.addEventListener('close', () => {
console.log('closed')
})
最核心的事情其实只有五件:
- 建立连接
- 监听打开事件
- 发送消息
- 接收消息
- 处理关闭
浏览器端最常见的 API
构造连接
const socket = new WebSocket('wss://example.com/ws')
ws://:非加密wss://:加密
线上环境通常更应该使用 wss://。
子协议
如果前后端需要约定特定消息协议,也可以在握手时声明子协议:
const socket = new WebSocket('wss://example.com/ws', ['json.v1'])
后面可以通过 socket.protocol 看最终协商结果。
常见事件
openmessageerrorclose
socket.onopen = () => {}
socket.onmessage = (event) => {}
socket.onerror = (event) => {}
socket.onclose = (event) => {}
常见属性
readyStatebufferedAmountprotocolbinaryTypeurl
readyState 最常用来判断连接状态:
0:CONNECTING1:OPEN2:CLOSING3:CLOSED
bufferedAmount 很值得盯一下。它表示已经调用 send() 但还没真正发出去的数据量。如果这个值持续变大,往往说明:
- 网络慢
- 服务端消费慢
- 客户端发得太快
文本和二进制
WebSocket 不只支持字符串,也能处理二进制。
socket.binaryType = 'arraybuffer'
socket.addEventListener('message', (event) => {
if (typeof event.data === 'string') {
console.log('text', event.data)
} else {
console.log('binary', event.data)
}
})
二进制更常见于:
- 音视频片段
- 文件块
- 高性能实时数据
- 自定义协议
一个更接近真实项目的连接封装
export type WsMessage = {
type: string
payload?: unknown
requestId?: string
ts?: number
}
export class WsClient {
private socket: WebSocket | null = null
private url = ''
connect(url: string) {
this.url = url
this.socket = new WebSocket(url)
this.socket.addEventListener('open', () => {
console.log('ws connected')
})
this.socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data) as WsMessage
console.log('ws message', message)
})
this.socket.addEventListener('close', (event) => {
console.log('ws closed', event.code, event.reason)
})
this.socket.addEventListener('error', (error) => {
console.error('ws error', error)
})
}
send(message: WsMessage) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
this.socket.send(JSON.stringify({ ...message, ts: Date.now() }))
return true
}
close(code = 1000, reason = 'normal closure') {
this.socket?.close(code, reason)
this.socket = null
}
reconnect() {
if (!this.url) return
this.close()
this.connect(this.url)
}
}
这类封装至少能先把:
- 连接
- 发送
- 关闭
- 重连入口
- 基础事件
收成一层。
消息协议怎么设计
WebSocket 真正容易乱的地方,往往不是 API,而是消息协议。
最小建议
至少尽早统一这些字段:
typepayloadrequestIdts
{
type: 'chat.message',
requestId: 'msg_123',
ts: 1746780000000,
payload: {
roomId: 'room_a',
text: 'hello'
}
}
为什么 type 很关键
只要消息一多,没有 type 分类,后面处理逻辑会迅速失控。
比较稳的命名习惯一般像这样:
chat.messagechat.typingroom.joinroom.leaveboard.patchsystem.pingsystem.pong
为什么 requestId 也值得早点加
如果一条消息需要:
- 服务端应答
- 客户端匹配回包
- 失败重试
- 幂等处理
那 requestId 会非常重要。
request-response 模式怎么做
虽然 WebSocket 更像消息通道,但很多业务仍然会希望保留“请求-响应”的心智。
可以在消息层做一层约定:
{
type: 'room.members.fetch',
requestId: 'req_001',
payload: { roomId: 'room_a' }
}
服务端回:
{
type: 'room.members.fetch.result',
requestId: 'req_001',
payload: {
members: []
}
}
这样前端就能在一条持久连接里,模拟出相对稳定的异步调用体验。
React 里怎么接更稳
最常见的写法通常是用 useEffect 管生命周期,用 useRef 持有连接实例。
import { useEffect, useRef, useState } from 'react'
export function ChatPanel() {
const socketRef = useRef<WebSocket | null>(null)
const [messages, setMessages] = useState<string[]>([])
useEffect(() => {
const socket = new WebSocket('wss://example.com/chat')
socketRef.current = socket
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data) as { type: string; payload?: { text?: string } }
if (data.type === 'chat.message') {
setMessages((prev) => [...prev, data.payload?.text ?? ''])
}
})
return () => {
socket.close(1000, 'component unmount')
socketRef.current = null
}
}, [])
function sendMessage() {
socketRef.current?.send(JSON.stringify({ type: 'chat.message', payload: { text: 'hello' } }))
}
return (
<div>
<button onClick={sendMessage}>发送</button>
<ul>
{messages.map((msg, index) => (
<li key={index}>{msg}</li>
))}
</ul>
</div>
)
}
这里比较关键的是:
- 组件卸载时关闭连接
- 不要每次渲染都重新
new WebSocket - 把消息协议解析尽早抽出去
- 不要让 UI 组件直接承担太多重连、心跳、鉴权逻辑
Vue 里怎么接
Vue 里的主线也差不多,重点还是生命周期和清理。
import { onMounted, onBeforeUnmount, ref } from 'vue'
export function useSocket(url: string) {
const socket = ref<WebSocket | null>(null)
onMounted(() => {
socket.value = new WebSocket(url)
})
onBeforeUnmount(() => {
socket.value?.close(1000, 'component unmount')
socket.value = null
})
return { socket }
}
如果是 Vue 项目,比较稳的方式通常也是把连接、重连、协议解析单独放到 composable 或 service 层。
鉴权怎么做
WebSocket 的鉴权通常不会只有一种写法,具体要看网关、后端和部署链路约束。
常见方式一般有:
1. URL 参数带 token
const socket = new WebSocket(`wss://example.com/ws?token=${token}`)
优点是接入简单;缺点是 token 出现在 URL 里时,需要更注意日志和中间层暴露风险。
2. Cookie 鉴权
如果站点本身就是 Cookie 登录态,WebSocket 也可以沿用这层上下文。
这种方式通常更适合同域系统。
3. 首条消息认证
先建立连接,再发一条认证消息:
socket.send(JSON.stringify({
type: 'auth.login',
payload: { token }
}))
这种方式在多协议场景里更灵活,但服务端要明确处理“未认证连接”的生命周期。
4. 子协议或额外协商
少数系统会把身份信息或协议版本协商放到子协议层,但这通常不是默认方案。
心跳为什么重要
很多实时连接并不是连上就完了,还需要心跳机制确认连接还活着。
最常见的原因有:
- 中间代理可能会断开空闲连接
- 前端需要尽早发现连接失效
- 服务端也需要清理长时间无响应的客户端
一个最小心跳例子
const timer = window.setInterval(() => {
socket.send(JSON.stringify({ type: 'system.ping' }))
}, 30000)
服务端一般会回:
{ "type": "system.pong" }
重连怎么做更稳
真实项目里,断线重连几乎是必做项。
但“断了就立刻重连”通常不够稳,比较常见的方式是:
- 指数退避
- 最大重试次数
- 抖动(jitter)
- 页面不可见时降低频率
- 网络恢复后再主动尝试
一个更稳的重连节奏
function getRetryDelay(retryCount: number) {
const base = Math.min(1000 * 2 ** retryCount, 15000)
const jitter = Math.floor(Math.random() * 500)
return base + jitter
}
什么情况下不应该继续重连
- 用户已经主动退出登录
- 页面逻辑明确要求关闭
- 服务端返回了明确的鉴权失败或协议错误
close code 怎么看
close 事件里最值得看的通常是:
codereasonwasClean
项目里至少应该能分清:
- 正常关闭
- 服务端主动踢下线
- 鉴权失效
- 网络中断
- 协议错误
如果这里完全没有约定,后面排查实时故障会很慢。
房间、频道和订阅怎么设计
聊天、协同编辑、看板场景里,通常会有:
- room
- channel
- topic
- subscription
这层不是浏览器 API 提供的,而是业务协议层自己定义。
最常见的几类消息:
{ type: 'room.join', payload: { roomId: 'room_a' } }
{ type: 'room.leave', payload: { roomId: 'room_a' } }
{ type: 'topic.subscribe', payload: { topic: 'market.btc' } }
顺序、重复和幂等
WebSocket 是实时通道,不代表业务层就自动没有重复、乱序和重试问题。
如果场景比较关键,通常要补这些约定:
- 消息 ID
- 服务端时间戳
- 客户端去重
- 幂等更新
- ack / nack
例如聊天消息、协同编辑 patch、交易指令回执,都会碰到这些问题。
backpressure 和高频消息怎么处理
MDN 提到,浏览器标准 WebSocket API 本身没有 backpressure 机制。
这意味着:
- 消息来得太快时,前端可能处理不过来
- 内存可能被缓冲撑大
- UI 线程可能被持续消息压住
如果消息频率很高,前端通常还要自己补:
- 节流
- 批处理
- 限流
- 丢弃策略
- 队列控制
- 视口不可见时降频
如果对流量控制要求特别高,也可以留意 MDN 提到的 WebSocketStream,但它的可用范围和生态成熟度不能直接按传统 WebSocket 去看。
页面可见性和网络状态也要接进来
很多实时问题其实发生在:
- 标签页切后台
- 浏览器休眠
- 网络切换
- 设备从离线恢复
比较稳的做法一般会结合:
document.visibilityStatevisibilitychangewindow.onlinewindow.offline
让连接策略随着页面状态变化,而不是一直用同一个重连节奏硬顶。
服务端怎么配合更稳
前端很多 WebSocket 问题,根子其实不在前端,而在服务端和基础设施层。
前后端最好尽早对齐这些问题:
- 心跳间隔
- 最大空闲时间
- 房间和频道模型
- 消息协议版本
- ack 策略
- 重连后是否支持状态恢复
- 是否支持从某个 cursor / sequence 继续补消息
扩缩容和多实例问题
一旦 WebSocket 服务进入多实例部署,几个问题几乎一定会出现:
- 同一个用户连到了不同实例
- 房间内广播如何跨实例同步
- 是否需要 sticky session
- 服务端状态是放本地内存,还是放 Redis / pubsub / broker
这层虽然不全是前端代码问题,但前端在设计重连、房间恢复、消息补偿时必须知道服务端有没有这些能力。
和 Socket.IO 的关系
很多项目说“用 WebSocket”,实际接的是 Socket.IO。
需要分清:
- WebSocket:浏览器标准协议 / API
- Socket.IO:一个更高层的实时通信库,通常会额外提供房间、重连、fallback、ack 等能力
两者不是完全等价关系。
如果项目接的是 Socket.IO,前端文档和封装也应该按 Socket.IO 的 API 心智写,不要混着叫。