WebSocket 实时通信
什么是 WebSocket?
WebSocket 是一种网络通信协议,它在单个 TCP 连接上实现全双工通信。
简单来说,普通的 HTTP 请求是"一问一答"——客户端问了,服务器才能答。而 WebSocket 建立连接后,客户端和服务器可以随时互发消息,就像打电话一样。
| 协议 | 通信模式 | 特点 |
|---|---|---|
| HTTP | 请求-响应(半双工) | 客户端主动,服务器被动 |
| WebSocket | 全双工 | 双方随时可发消息,实时性高 |
| 轮询 | 定时发 HTTP 请求 | 伪实时,浪费资源 |
为什么需要 WebSocket?
传统 HTTP 的局限
假设你要做一个在线聊天室,用 HTTP 怎么做?
- 客户端每隔 1 秒发一个请求问"有新消息吗?"(轮询)
- 99% 的请求回复都是"没有"
- 带宽浪费、服务器压力大、延迟高
WebSocket 的优势
- 实时性 — 消息到达延迟毫秒级
- 低开销 — 一次握手,持久连接,头部仅 2~6 字节
- 双向通信 — 服务器可主动推送
- 适用场景 — 聊天室、协作编辑、股票行情、游戏、通知推送
WebSocket 工作原理
WebSocket 的生命周期很简单,分四步走:
- 握手 — 客户端发一个特殊的 HTTP 请求,告诉服务器"我要升级成 WebSocket",服务器同意后连接建立
- 传数据 — 双方用紧凑的二进制帧互发消息,帧头只有 2~6 字节,远比 HTTP 头轻量
- 心跳保活 — 定期发 Ping/Pong 帧确认对方还活着,防止长时间没数据被路由器断开
- 关闭连接 — 任一方发关闭帧,附带状态码说明原因,优雅断开
客户端 服务器
│ │
│──── HTTP Upgrade 请求 ──────→│ 🔗 握手
│←─── 101 Switching Protocols ─│
│ │
│── 发消息(数据帧)──────────→│ 💬 聊天
│←─ 回消息(数据帧)───────────│
│ │
│←────── Ping 帧 ────────────│ 💓 心跳
│────── Pong 帧 ────────────→│
│ │
│────── Close 帧 ────────────→│ 🔌 关闭
│←───── Close 帧 ─────────────│客户端 WebSocket API
浏览器原生支持 WebSocket,无需安装任何库:
创建连接
// 创建 WebSocket 连接
const ws = new WebSocket('ws://localhost:3000/chat')
// 连接成功
ws.onopen = () => {
console.log('连接成功!')
ws.send('大家好,我来了!')
}
// 接收消息
ws.onmessage = (event) => {
console.log('收到消息:', event.data)
// 如果服务器发的是 JSON
const msg = JSON.parse(event.data)
}
// 连接关闭
ws.onclose = () => {
console.log('连接已断开')
}
// 连接错误
ws.onerror = (error) => {
console.error('连接出错:', error)
}发送消息
// 发送纯文本
ws.send('Hello!')
// 发送 JSON(需要先序列化)
ws.send(JSON.stringify({
type: 'chat',
username: '小明',
content: '大家好!'
}))关闭连接
// code: 状态码(1000 表示正常关闭)
// reason: 关闭原因说明
ws.close(1000, '用户离开')Socket.IO vs 原生 WebSocket
很多项目不直接用原生 WebSocket,而是用 Socket.IO。它们的关系就像 Vue 和 JavaScript:
| 对比 | 原生 WebSocket | Socket.IO |
|---|---|---|
| 浏览器支持 | 现代浏览器全部支持 | 需要引入 socket.io-client |
| 自动重连 | ❌ 需要手动实现 | ✅ 内置 |
| 房间/命名空间 | ❌ 需要自己封装 | ✅ 内置 room 概念 |
| 降级兼容 | 不支持旧浏览器 | 自动降级到 HTTP 长轮询 |
| 消息确认 | ❌ | ✅ 支持 ACK |
| 框架集成 | 需要自己封装 | NestJS 有 @nestjs/websockets 专用模块 |
"Socket.IO 不是 WebSocket 实现"到底什么意思?
Socket.IO 官网有一句反复强调的话:Socket.IO is NOT a WebSocket implementation。
很多人不理解——"Socket.IO 底层不就是 WebSocket 吗?怎么说不是呢?"
一句话说清
WebSocket 是协议,Socket.IO 是框架。 就像 HTTP 是协议,Express 是框架——你不能说 "Express 是 HTTP 的实现"。Go 的标准库 net/http 才是 HTTP 的实现,就像浏览器内置的 new WebSocket() 才是 WebSocket 的实现。
为什么不能混用?
Socket.IO 在 WebSocket 外面包了一层自己的协议(Engine.IO)。它把消息编码成特定格式的 JSON 包来传输:
Socket.IO 消息在 WebSocket 帧里长这样:
42["chat message",{"text":"你好"}]
而不是:
你好这就导致了一个很实际的后果:客户端和服务端必须都用 Socket.IO。
浏览器 new WebSocket() ──→ ❌ ──→ Socket.IO 服务器
Socket.IO 客户端 ──→ ❌ ──→ 原生 WebSocket 服务器
Socket.IO 客户端 ──→ ✅ ──→ Socket.IO 服务器你能用 curl 发 HTTP 请求调 Express 接口,但你没法用浏览器原生 WebSocket 连 Socket.IO 服务器——协议不互通。
另一个证据:降级
如果网络环境差到连 WebSocket 都建立不了,Socket.IO 会自动切到 HTTP 长轮询,此时跟 WebSocket 协议完全无关。一个"WebSocket 实现"做不到这一点。
一句话:Socket.IO 把 WebSocket 当传输工具用,但它自己是一套完整的实时通信框架,不是 WebSocket 协议本身。
Node.js 服务端(Socket.IO)
import { Server } from 'socket.io'
const io = new Server(3000, {
cors: { origin: '*' }
})
io.on('connection', (socket) => {
console.log('用户连接:', socket.id)
// 加入房间
socket.join('chat-room')
// 监听客户端消息
socket.on('sendMessage', (data) => {
// 广播给房间内所有人(包括自己)
io.to('chat-room').emit('newMessage', {
username: data.username,
content: data.content,
timestamp: Date.now()
})
})
// 断开连接
socket.on('disconnect', () => {
console.log('用户断开:', socket.id)
})
})客户端(Socket.IO)
import { io } from 'socket.io-client'
const socket = io('http://localhost:3000')
socket.on('connect', () => {
console.log('已连接')
})
// 接收新消息
socket.on('newMessage', (msg) => {
console.log(`${msg.username}: ${msg.content}`)
})
// 发送消息
socket.emit('sendMessage', {
username: '小明',
content: '你好!'
})常见问题
WebSocket 连接失败怎么办?
- 检查服务器是否启动了 WebSocket 服务
- 检查防火墙是否放行对应端口
- 检查是否使用正确的协议:
ws://(非加密)或wss://(加密,等同 HTTPS) - 如果是反向代理(Nginx),需要额外配置 WebSocket 支持
如何保证消息不丢失?
- WebSocket 基于 TCP,传输层保证数据有序到达
- 应用层可以加消息确认机制(ACK)
- 断线后需要补发历史消息
心跳机制
长时间不通信,连接可能被中间设备断开。需要定期发"心跳包":
// 客户端每 30 秒发一次 ping
setInterval(() => {
ws.send('ping')
}, 30000)提示
Socket.IO 内置了心跳机制,无需手动实现。
WebSocket 实战:在线聊天室
接下来我们基于当前项目(NestJS + Vue 3),用 Socket.IO 从零实现一个在线聊天室。
第一步,建立连接
服务端
cd server
npm i @nestjs/websockets @nestjs/platform-socket.io| 包 | 作用 |
|---|---|
@nestjs/websockets | NestJS 的 WebSocket 模块,提供 @WebSocketGateway() 等装饰器 |
@nestjs/platform-socket.io | Socket.IO 的 NestJS 适配器,让 NestJS 和 Socket.IO 对接 |
NestJS 中用 Gateway 来处理 WebSocket 连接,概念上类似 HTTP 的 Controller。
创建 server/src/modules/chat/chat.gateway.ts:
import {
WebSocketGateway,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection(client: Socket) {
console.log('有人连上来了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
handleDisconnect(client: Socket) {
console.log('有人断开了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
}NestJS 不是靠方法名来识别代码的,而是靠装饰器和接口。不加这些标记,方法写得再好也不会被调用。
@WebSocketGateway() — 类装饰器
告诉 NestJS:"这个类是一个 WebSocket 入口,请按 Socket.IO 的方式处理它"。
@WebSocketGateway({ cors: { origin: '*' } }) // ← 没有这行,NestJS 不认识这个类
export class ChatGateway { ... }不加它,NestJS 启动时直接跳过这个类,不会为它创建 WebSocket 服务。
implements OnGatewayConnection, OnGatewayDisconnect — 接口
告诉 NestJS:"这个类里有 handleConnection 和 handleDisconnect 方法,请在连接/断开时调用它们"。
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection(client: Socket) { ... } // ← 有人连上来,NestJS 自动调
handleDisconnect(client: Socket) { ... } // ← 有人断开,NestJS 自动调
}如果不 implements 这两个接口,NestJS 不知道你有这两个方法,即使方法名完全一样也不会调用。TypeScript 的 implements 在这里被 NestJS 用作运行时标识。
然后,创建 server/src/modules/chat/chat.module.ts:
import { Module } from '@nestjs/common'
import { ChatGateway } from './chat.gateway'
@Module({
providers: [ChatGateway],
})
export class ChatModule {}然后在 app.module.ts 中注册:
import { ChatModule } from './modules/chat/chat.module'
// ... 在 imports 数组中添加 ChatModule客户端
cd web
npm i socket.io-client用 Pinia 管理聊天状态。创建 web/src/stores/chat.ts:
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { io, type Socket } from 'socket.io-client'
export const useChatStore = defineStore('chat', () => {
const socket = ref<Socket | null>(null)
const isConnected = ref(false)
// 连上服务器
function connect(username: string) {
socket.value = io({ query: { username } })
socket.value.on('connect', () => {
console.log('连上了!socket id:', socket.value?.id)
isConnected.value = true
})
socket.value.on('disconnect', () => {
console.log('断开了')
isConnected.value = false
})
}
function disconnect() {
socket.value?.disconnect()
}
return { socket, isConnected, connect, disconnect }
})socket.value 是什么?
socket.value = io({ query: { username } }) // io() 返回一个 Socket 对象,塞进去io() 是工厂函数,调用它返回一个普通的 JavaScript 对象,这个对象就是你和服务器之间的那条"电话线":
// io() 返回的对象长这样:
{
id: 'shNdjAzA7wNlizgUAAAB', // 属性:连接唯一标识
on: function(事件名, 回调) {}, // 方法:注册监听(接听)
emit: function(事件名, 数据) {}, // 方法:发送消息(说话)
disconnect: function() {}, // 方法:断开连接(挂电话)
}
// 用法
socket.value.on('connect', () => { ... }) // 连上时执行
socket.value.on('disconnect', () => { ... }) // 断开时执行
socket.value.emit('sendMessage', { content }) // 发送消息
socket.value.disconnect() // 主动断开.on() 跟浏览器原生 addEventListener 完全一致,只是监听的是网络事件而非 DOM 事件。
接着创建 web/src/views/chat/ChatView.vue:
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const myName = ref<string>('')
</script>
<template>
<div>
<n-form-item label="请输入名字">
<n-input v-model:value="myName"></n-input>
</n-form-item>
<n-button @click="chatStore.connect(myName)">点我连接</n-button>
<n-button @click="chatStore.disconnect()">点我断开连接</n-button>
</div>
</template>输入名字,点击连接,
# 前端输出
连上了!socket id: K5BhZXelNrdk-J_wAAAx
# 后端输出
有人连上来了,id: K5BhZXelNrdk-J_wAAAx
用户名: 我是超级大帅哥!点击断开连接,
# 前端输出
断开了
# 后端输出
有人断开了,id: K5BhZXelNrdk-J_wAAAx
用户名: 我是超级大帅哥!事件流程总结
客户端 服务器
│──── connect (query: username) ──→│ handleConnection()
│ │
│──── disconnect ─────────────────→│ handleDisconnect()至此,一个最基本的websocket连接就完成了。
第二步,客户端向服务端发送消息
服务端
import {
WebSocketGateway,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection(client: Socket) {
console.log('有人连上来了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
handleDisconnect(client: Socket) {
console.log('有人断开了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
@SubscribeMessage('sendMessage')
handleMessage(
@ConnectedSocket() client: Socket, // 明确告诉 NestJS "这是 socket"
@MessageBody() data: { content: string }, // 明确告诉 NestJS "这是消息体"
): void {
const username = (client.handshake.query.username as string) || '匿名用户';
console.log(`收到 ${username} 的消息: ${data.content}`);
}
}这三个装饰器各自的作用:
@SubscribeMessage('事件名') — 方法装饰器
告诉 NestJS:前端发来 'sendMessage' 事件时,调用下面这个方法。
// ✅ 有 @SubscribeMessage
@SubscribeMessage('sendMessage')
handleMessage(...) { } // 前端 emit('sendMessage', ...) → 这个方法会被调用
// ❌ 没有 @SubscribeMessage
handleMessage(...) { } // 这就是个普通方法,NestJS 永远不会调它@ConnectedSocket() — 参数装饰器
告诉 NestJS:方法第一个参数是当前连接的 socket 对象(谁发的消息)。
// ✅ 有 @ConnectedSocket
handleMessage(@ConnectedSocket() client: Socket, data) {
console.log(client.handshake.query.username) // ✅ 能拿到发送者是谁
}
// ❌ 没有 @ConnectedSocket,但参数位置固定
handleMessage(client: Socket, data) {
console.log(client.handshake.query.username) // ✅ 一样能用,NestJS 默认第一个参数就是 socket
}@MessageBody() — 参数装饰器
告诉 NestJS:这个参数是前端发来的消息体数据。
// ✅ 有 @MessageBody
handleMessage(client, @MessageBody() data: { content: string }) {
console.log(data.content) // ✅ 拿到消息内容
}
// ❌ 没有 @MessageBody,参数位置固定
handleMessage(client, data: { content: string }) {
console.log(data.content) // ✅ 一样能用,NestJS 第二个参数就是消息体
}总结:NestJS WebSocket 的参数顺序是固定的——第 1 个是 socket,第 2 个是数据。不加
@ConnectedSocket和@MessageBody也能正常工作,加了只是更明确地告诉 NestJS(和你自己)每个参数是什么。但@SubscribeMessage必须加,不然 NestJS 不知道哪个方法处理哪个事件。
客户端
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { io, type Socket } from 'socket.io-client'
export const useChatStore = defineStore('chat', () => {
const socket = ref<Socket | null>(null)
const isConnected = ref(false)
// 连上服务器
function connect(username: string) {
socket.value = io({ query: { username } })
socket.value.on('connect', () => {
console.log('连上了!socket id:', socket.value?.id)
isConnected.value = true
})
socket.value.on('disconnect', () => {
console.log('断开了')
isConnected.value = false
})
}
// 发消息给服务器
function sendMessage(content: string) {
console.log('发送消息:', content)
socket.value?.emit('sendMessage', { content })
}
function disconnect() {
socket.value?.disconnect()
}
return { socket, isConnected, connect, sendMessage, disconnect }
})这就不用演示了吧
第三步,服务端把消息广播给所有人
刚才只是服务端"收到"了消息,但只有服务器自己知道。真正聊天需要把消息推给所有在线的人。
服务端
用到两个新东西:
@WebSocketServer()— 装饰器,注入 Socket.IO 的Server实例server.emit('事件名', 数据)— 给所有连接的客户端群发消息
import {
WebSocketGateway,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
ConnectedSocket,
MessageBody,
WebSocketServer, // ← 新增
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io'; // ← 新增 Server 类型
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server; // ← 新增:广播喇叭
handleConnection(client: Socket) {
console.log('有人连上来了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
handleDisconnect(client: Socket) {
console.log('有人断开了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
}
@SubscribeMessage('sendMessage')
handleMessage(
@ConnectedSocket() client: Socket, // 明确告诉 NestJS "这是 socket"
@MessageBody() data: { content: string }, // 明确告诉 NestJS "这是消息体"
): void {
const username = (client.handshake.query.username as string) || '匿名用户';
console.log(`收到 ${username} 的消息: ${data.content}`);
// 广播给所有人(包括发送者自己)
this.server.emit('newMessage', {
username,
content: data.content,
timestamp: Date.now(),
});
}
}@WebSocketServer() server 是什么?
服务器维护着所有客户端的连接,server 就是操作这些连接的"遥控器"。没用它之前,消息进来就停了;有了它,消息进来→广播出去→聊天室所有人看到。
! 是什么?
server!: Server;TypeScript 看到 server 没赋值就报错。但 NestJS 会在运行时通过 @WebSocketServer() 自动把值塞进去。加 ! 就是告诉 TypeScript:"我知道,别报错,运行时会有的"。
客户端
客户端需要监听服务器广播的 newMessage 事件,把消息存到列表里显示。
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { io, type Socket } from 'socket.io-client'
// 消息的类型
export interface ChatMessage {
username: string
content: string
timestamp: number
}
export const useChatStore = defineStore('chat', () => {
const socket = ref<Socket | null>(null)
const isConnected = ref(false)
const messages = ref<ChatMessage[]>([]) // ← 消息列表
// 连上服务器
function connect(username: string) {
socket.value = io({ query: { username } })
socket.value.on('connect', () => {
console.log('连上了!socket id:', socket.value?.id)
isConnected.value = true
})
socket.value.on('disconnect', () => {
console.log('断开了')
isConnected.value = false
})
// 收到服务器广播的消息 → 加到列表里
socket.value.on('newMessage', (msg: ChatMessage) => {
messages.value.push(msg)
})
}
// 发消息给服务器
function sendMessage(content: string) {
console.log('发送消息:', content)
socket.value?.emit('sendMessage', { content })
}
function disconnect() {
socket.value?.disconnect()
}
return { socket, isConnected, messages, connect, sendMessage, disconnect }
})页面加一个消息展示区域:
<!-- 消息列表 -->
<div style="height:200px; overflow-y:auto; border:1px solid #ccc; padding:8px;">
<div v-for="(msg, i) in chatStore.messages" :key="i">
<strong>{{ msg.username }}</strong>: {{ msg.content }}
</div>
</div>效果
张三打开页面 → 输入名字 → 点连接
李四打开页面 → 输入名字 → 点连接
张三输入"你好" → 点发送
→ 张三页面显示: 张三: 你好
→ 李四页面显示: 张三: 你好
→ 服务器打印: 收到 张三 的消息: 你好消息路线:
张三发消息 ──→ 服务器收到 ──→ server.emit() 群发 ──→ 所有人收到并显示至此,一个能真正聊天的聊天室就完成了。
第四步,完善聊天室体验
加上在线人数、上线/离线通知、气泡样式、时间显示、Enter 发送、自动滚动。
服务端
用 Map 记录在线用户,连接/断开时广播通知。
// ... 前面代码不变
@WebSocketServer()
server!: Server;
// 在线用户:id → 用户名
private onlineUsers: Map<string, string> = new Map();
handleConnection(client: Socket) {
console.log('有人连上来了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
const username = (client.handshake.query.username as string) || '匿名用户';
this.onlineUsers.set(client.id, username);
this.server.emit('userJoined', { username, onlineCount: this.onlineUsers.size });
}
handleDisconnect(client: Socket) {
console.log('有人断开了,id:', client.id);
console.log('用户名:', client.handshake.query.username);
const username = this.onlineUsers.get(client.id) || '匿名用户';
this.onlineUsers.delete(client.id);
this.server.emit('userLeft', { username, onlineCount: this.onlineUsers.size });
}客户端 store
加 onlineCount 和上下线监听。
const messages = ref<ChatMessage[]>([])
const onlineCount = ref(0)
// ... connect 函数里面加:
socket.value.on('userJoined', (data: { onlineCount: number }) => {
onlineCount.value = data.onlineCount
})
socket.value.on('userLeft', (data: { onlineCount: number }) => {
onlineCount.value = data.onlineCount
})客户端界面
- 已连接时显示「聊天室 | N 人在线」顶栏 + 离开按钮
- 消息气泡:自己的蓝色靠右,别人的灰色靠左
- 每条消息显示发送者和时间
- Enter 发送,新消息自动滚到底部
完整代码见 web/src/views/chat/ChatView.vue。
效果
张三进入 → 所有人看到 "张三 加入聊天室"
李四进入 → 所有人看到 "李四 加入聊天室"
在线人数: 2
张三发 "你好" →
张三(蓝色气泡,右侧): 张三 · 14:30 你好
李四(灰色气泡,左侧): 张三 · 14:30 你好