跳到主要内容

WebSocket

WebSocket 更适合放在请求与数据层里看,因为它处理的不是“某个请求怎么发出去”,而是“连接建立之后,客户端和服务端怎么持续交换消息”。

这类能力通常会落在这些场景:

  • 聊天
  • 协同编辑
  • 实时看板
  • 在线游戏
  • 交易或行情推送
  • 需要客户端主动发消息、服务端也要主动回推的系统

如果只有服务端单向推送需求,SSE 往往已经够用;一旦进入双向通信、高频交互、房间状态同步,WebSocket 通常会成为主线。

WebSocket 在解决什么

HTTP 请求的默认模式更像:

  • 客户端发起
  • 服务端响应
  • 一次请求结束

这种模型很适合:

  • 列表查询
  • 详情查询
  • 提交表单
  • 上传下载

但一旦进入实时场景,就会暴露几个限制:

  • 轮询频率低,更新不及时
  • 轮询频率高,服务端压力大
  • 客户端和服务端都需要随时发消息
  • 多个用户同时在线时,状态同步会越来越麻烦

WebSocket 的核心价值,就是在一次握手之后,把连接保持住,让双方都能在同一条连接上持续发消息。

先记住它的几个特征

  • 基于 TCP
  • 浏览器里通过 WebSocket API 使用
  • 连接建立后是全双工通信
  • 客户端和服务端都能主动发消息
  • 更像“消息通道”,不是普通 REST 接口替代品
  • 很适合实时交互,但也会带来连接管理、鉴权、扩缩容、协议设计这些新问题

MDN 也明确提到,WebSocket API 可以创建并管理连接,同时在连接上收发数据。

握手和连接怎么理解

WebSocket 一开始并不是凭空新建一种浏览器连接,而是先借用一次 HTTP 握手,再升级成持久连接。

可以先记住这条线:

  1. 浏览器发起 ws://wss:// 连接
  2. 服务端同意升级协议
  3. 握手完成后,双方进入持续消息通道
  4. 后面发送的就是 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 看最终协商结果。

常见事件

  • open
  • message
  • error
  • close
socket.onopen = () => {}
socket.onmessage = (event) => {}
socket.onerror = (event) => {}
socket.onclose = (event) => {}

常见属性

  • readyState
  • bufferedAmount
  • protocol
  • binaryType
  • url

readyState 最常用来判断连接状态:

  • 0:CONNECTING
  • 1:OPEN
  • 2:CLOSING
  • 3: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,而是消息协议。

最小建议

至少尽早统一这些字段:

  • type
  • payload
  • requestId
  • ts
{
type: 'chat.message',
requestId: 'msg_123',
ts: 1746780000000,
payload: {
roomId: 'room_a',
text: 'hello'
}
}

为什么 type 很关键

只要消息一多,没有 type 分类,后面处理逻辑会迅速失控。

比较稳的命名习惯一般像这样:

  • chat.message
  • chat.typing
  • room.join
  • room.leave
  • board.patch
  • system.ping
  • system.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 里时,需要更注意日志和中间层暴露风险。

如果站点本身就是 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 事件里最值得看的通常是:

  • code
  • reason
  • wasClean

项目里至少应该能分清:

  • 正常关闭
  • 服务端主动踢下线
  • 鉴权失效
  • 网络中断
  • 协议错误

如果这里完全没有约定,后面排查实时故障会很慢。

房间、频道和订阅怎么设计

聊天、协同编辑、看板场景里,通常会有:

  • 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.visibilityState
  • visibilitychange
  • window.online
  • window.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 心智写,不要混着叫。

和 SSE 怎么选

这是最常见的判断题。

SSE 更适合

  • 服务端单向推送
  • 流式输出
  • 日志流
  • AI 输出流
  • 通知流

WebSocket 更适合

  • 聊天
  • 协同编辑
  • 双向实时操作
  • 在线游戏
  • 高频互动场景

一句话记:

  • 只需要服务端往客户端推:优先看 SSE
  • 两边都要频繁发消息:优先看 WebSocket

和轮询、长轮询的关系

轮询、长轮询、SSE、WebSocket 其实是一条逐步增强的实时通信路线。

  • 轮询:实现简单,但请求开销高
  • 长轮询:比普通轮询更省请求,但延迟和连接管理还是受 HTTP 模型影响
  • SSE:服务端单向持续推送
  • WebSocket:双向持续通信

这部分已经单独拆成 轮询 / 长轮询 / 实时通信选型 专题,更适合对照着一起看。

常见误区

1. 把 WebSocket 当成所有接口的替代品

普通查询、列表获取、详情加载,很多时候还是 HTTP 更稳。

2. 只写连接,不写重连和关闭

这会让实时链路在弱网、页面切换、标签页切回时很脆。

3. 消息格式没有约定

只要消息一多,没有统一 typepayload,后面一定会越来越乱。

4. 忽略鉴权和权限边界

实时链路同样要做登录态和频道权限控制。

5. 只盯浏览器 API,不看部署链路

很多线上问题最后都和:

  • Nginx / 网关超时
  • 代理升级配置
  • 连接数限制
  • 多实例广播

有关。

在前端项目里怎么放更合适

如果项目已经有请求层,WebSocket 通常也应该收进这一层,而不是散在页面组件里。

比较稳的结构一般是:

src/
services/
websocket/
client.ts
protocol.ts
reconnect.ts
heartbeat.ts
channels.ts

这样后面要补:

  • 心跳
  • 重连
  • 房间管理
  • 统一事件分发
  • 消息协议版本

都会更顺。

一个更实际的落地顺序

  1. 先把消息协议定下来
  2. 再补最小连接封装
  3. 再补重连和心跳
  4. 再补房间 / 频道 / 订阅模型
  5. 最后再补状态恢复、ack、消息补偿

这样推进,比一开始就把整套实时系统全堆进去更稳。

参考来源