# 基于 Socket.IO 和 WebRTC 的实时语音聊天室 (Demo 版本)
# 功能亮点
- 🎙️ 实时语音通话(P2P 直连)
- 💬 文字消息群聊(房间隔离)
- 🚪 动态房间管理
- 🌐 自动 NAT 穿透(STUN 服务)
# 技术栈
技术 | 用途 |
---|
Socket.IO | 信令服务器 / 消息中转 |
WebRTC | 音视频 P2P 通信 |
React | 前端界面框架 |
Node.js | 后端服务 |
# 快速开始
# 环境准备
| |
| npm install socket.io-client react react-dom |
| |
| |
| npm install express socket.io cors |
# 代码解析
# 系统架构图
+----------------+ 信令交互 +----------------+
| 浏览器客户端 | ←—— Socket.IO ——→ | 信令服务器 |
+----------------+ +----------------+
▲ |
| WebRTC媒体流(P2P直连) |
▼ ▼
+----------------+ +----------------+
| 其他客户端 | ←—— WebRTC ————→ | 其他客户端 |
+----------------+ +----------------+
# 服务端核心逻辑 (server.js)
| |
| io.on('connection', (socket) => { |
| socket.on('join-room', (roomId) => { |
| socket.join(roomId); |
| socket.to(roomId).emit('user-connected', socket.id); |
| }); |
| |
| |
| socket.on('signal', ({ targetId, signal }) => { |
| socket.to(targetId).emit('signal', { senderId: socket.id, signal }); |
| }); |
| }); |
# 服务器初始化
| const express = require('express'); |
| const http = require('http'); |
| const cors = require('cors'); |
| const app = express(); |
| |
| const server = http.createServer(app); |
| app.use(cors()); |
| const io = require("socket.io")(server, { |
| cors: { |
| origin: "*", |
| methods: ["GET", "POST"], |
| } |
| }); |
功能:创建基础 HTTP 服务并配置 Socket.IO
关键配置:
cors
中间件用于解决跨域问题,允许任何来源的请求访问服务器origin: "*"
允许任何来源的请求访问服务器- 指定使用 WebSocket 传输 (默认包含轮询备用)
# 连接管理
| io.on('connection', (socket) => { |
| console.log(`用户 ${socket.id} 已连接`); |
| |
| |
| }); |
功能:处理客户端连接事件
生命周期:
- 客户端连接时自动触发
- 每个
socket
对象代表一个独立客户端连接 socket.id
是自动生成的唯一标识符,用于标识每个客户端连接
# 房间管理
| socket.on('join-room', (roomId) => { |
| socket.join(roomId); |
| socket.roomId = roomId; |
| socket.emit('myId', socket.id); |
| socket.to(roomId).emit('user-connected', socket.id); |
| }); |
核心方法:
socket.join(roomId)
:Socket.IO 内置房间管理。socket.to(roomId).emit()
:向指定房间广播消息
数据流:
客户端A --join-room--> 服务端
服务端 --> 客户端A:发送myId
服务端 --> 房间其他客户端:发送user-connected
# 信令转发
| socket.on('signal', ({ targetId, signal }) => { |
| socket.to(targetId).emit('signal', { |
| senderId: socket.id, |
| signal |
| }); |
| }); |
功能:中转 WebRTC 连接信令
场景:
- 转发 Offer/Answer 信令
- 转发 ICE 候选信令
路由逻辑:
客户端A --signal--> 服务端 --> 客户端B
# 聊天消息管理
| socket.on('chat message', (msg) => { |
| io.to(socket.roomId).emit('chat message', msg); |
| }); |
广播机制:
io.to(socket.roomId).emit('chat message', msg);
:向指定房间广播消息- 保证消息仅在房间内传播
# 断开连接处理
| socket.on('disconnect', () => { |
| if (socket.roomId) { |
| socket.to(socket.roomId).emit('user-disconnected', socket.id); |
| } |
| }); |
触发场景:
# 客户端核心逻辑 (App.js)
| |
| const createPeerConnection = (targetId) => { |
| const pc = new RTCPeerConnection({ |
| iceServers: [{ |
| urls: 'stun:stun.l.google.com:19302' |
| }] |
| }); |
| |
| |
| localStream.getTracks().forEach(track => |
| pc.addTrack(track, localStream) |
| ); |
| |
| |
| pc.ontrack = e => { |
| remoteAudioRef.current.srcObject = e.streams[0]; |
| remoteAudioRef.current.play(); |
| }; |
| }; |
# 状态管理
| const [messages, setMessages] = useState([]); |
| const [message, setMessage] = useState(''); |
| const [myId, setMyId] = useState(''); |
| const [roomId, setRoomId] = useState(''); |
# 音视频初始化
| useEffect(() => { |
| navigator.mediaDevices.getUserMedia({ audio: true }) |
| .then(stream => { |
| localAudioRef.current.srcObject = stream; |
| }); |
| |
| }, []); |
关键 API:
getUserMedia
:获取用户媒体(音频 / 视频)srcObject
:设置媒体流到 <audio>
元素
注意:- 需要 HTTPS 环境(localhost 除外)
- 处理用户拒绝权限情况
# WebRTC 连接管理
| const createPeerConnection = (targetId, isInitiator = false) => { |
| const pc = new RTCPeerConnection({ |
| iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] |
| }); |
| |
| |
| localAudioRef.current.srcObject.getTracks().forEach(track => { |
| pc.addTrack(track, localAudioRef.current.srcObject); |
| }); |
| |
| |
| pc.ontrack = (e) => { |
| remoteAudioRef.current.srcObject = e.streams[0]; |
| }; |
| |
| |
| pc.onicecandidate = (e) => { |
| if (e.candidate) { |
| socket.emit('signal', { targetId, signal: e.candidate.toJSON() }); |
| } |
| }; |
| |
| |
| if (isInitiator) { |
| pc.createOffer() |
| .then(offer => pc.setLocalDescription(offer)) |
| .then(() => { |
| socket.emit('signal', { targetId, signal: pc.localDescription }); |
| }); |
| } |
| }; |
流程解析:
<pre class="mermaid">sequenceDiagram
参与者 A->> 参与者 A: 创建 PeerConnection
参与者 A->> 参与者 A: 添加本地音轨
参与者 A->> 参与者 A: 设置 ontrack 回调
参与者 A->>STUN: 获取 ICE 候选
参与者 A->> 服务端:发送候选
服务端 ->> 参与者 B: 转发候选
参与者 B->> 参与者 B: 处理候选 </pre>
# 信令处理
| const handleSignal = async ({ senderId, signal }) => { |
| if (!peersRef.current[senderId]) { |
| createPeerConnection(senderId); |
| } |
| const pc = peersRef.current[senderId]; |
| |
| try { |
| if (signal.type === 'offer') { |
| await pc.setRemoteDescription(signal); |
| const answer = await pc.createAnswer(); |
| await pc.setLocalDescription(answer); |
| socket.emit('signal', { targetId: senderId, signal: answer }); |
| } |
| |
| } catch (err) { |
| console.error('信令处理失败:', err); |
| } |
| }; |
状态转换:
stable -> have-local-offer -> have-remote-offer -> stable
# 用户界面注意
| {/* 音频元素 */} |
| <audio ref={localAudioRef} autoPlay muted /> |
| <audio ref={remoteAudioRef} autoPlay /> |
元素说明:
localAudioRef
:用于显示本地音频流remoteAudioRef
:用于显示远程音频流autoPlay
:自动播放音频流muted
:静音本地音频流
# 代码细节总结
- 修改状态的扩展运算符和函数表达式
在 useEffect
中使用扩展运算符会产生闭包陷阱,因为首次加载时, messages
数组为空,后续的 setMessages
操作会直接修改一个空的数组,而不会更新状态。因此,使用函数表达式来定义 handleChatMessage
函数,可以避免闭包陷阱。
| |
| const handleChatMessage = (msg) => { |
| console.log(msg) |
| setMessages((prev) => [...prev, msg]); |
| }; |
| |
| |
| |
| const handleChatMessage = (msg) => { |
| console.log(msg) |
| setMessages([...messages, msg]); |
| }; |
- 每次挂载结束都要断开 Socket.IO 连接
如果不断开会导致重复连接,所以每次挂载结束都要断开 Socket.IO 连接。
| return () => { |
| socket.off('signal', handleSignal); |
| socket.off('user-connected', handleUserConnected); |
| socket.off('user-disconnected', handleUserDisconnected); |
| socket.off('myId', setMyId); |
| socket.off('chat message', handleChatMessage); |
| }; |
# 系统流程图
<pre class="mermaid">sequenceDiagram
participant A as 用户 A
participant S as 服务端
participant B as 用户 B
A->>S: join-room(101)
S->>A: myId(A)
S->>B: user-connected(A)
B->>B: 创建 PeerConnection
B->>S: 发送 Offer
S->>A: 转发 Offer
A->>S: 发送 Answer
S->>B: 转发 Answer
A->>B: ICE 候选交换
B->>A: ICE 候选交换
A->>B: 语音流传输
A->>S: 发送文字消息
S->>B: 转发消息 </pre>
# 使用步骤
- 启动后端服务:
- 启动前端应用:
- 打开多个浏览器窗口
访问 http://localhost:5173
,在浏览器中输入房间 ID,点击 “加入房间” 按钮,即可进行实时语音聊天。 - 功能验证
说话:能够听到自己的声音
输入文字发送:双方窗口同步显示消息
关闭窗口:对方收到断开通知
# 关键注意事项
- 麦克风权限问题
需要在浏览器中允许麦克风权限,否则无法 发送语音。 - 本地开发限制
确保 Socket.IO 服务端配置 CORS,允许跨域请求,否则无法正常通信。 - NAT 穿透问题
公共 STUN 服务器可能不稳定
# 常见问题排查
现象 | 可能原因 | 解决方案 |
---|
无法听到对方声音 | 未添加音频轨道 | 检查 pc.addTrack 调用 |
连接状态卡在 checking | ICE 候选未交换成功 | 检查 STUN 服务器可用性 |
文字消息未广播 | 未加入相同房间 | 验证 join-room 事件处理 |
# 扩展优化建议
- 增加房间状态显示:当前房间人数、房间列表等。
- 优化 UI 体验:如加载动画、错误提示等。
- 添加视频聊天功能:使用 WebRTC 的
createOffer
和 createAnswer
方法实现视频聊天。
# 技术补充 (2025-4-3 更新)
- remoteAudioStream 的覆盖问题
在当前代码中,pc.ontrack 事件会将接收到的远程音频流直接设置到 remoteAudioStream 状态中
| pc.ontrack = (e) => { |
| set({ remoteAudioStream: e.streams[0] }); |
| }; |
这会导致一个问题:当多个用户加入房间时,每次接收到新的远程音频流时,都会覆盖之前的 remoteAudioStream
。因此,最终只会有一个用户的音频流被播放,其他用户的音频流会被丢弃。
- 修改前端代码以支持多音频流
将 remoteAudioStream
替换为 remoteAudioStreams
,并将其设计为一个对象,键为 userId
,值为对应的音频流。
| remoteAudioStreams: {}, |
| pc.ontrack = (e) => { |
| set((state) => ({ |
| remoteAudioStreams: { |
| ...state.remoteAudioStreams, |
| [targetId]: e.streams[0], |
| }, |
| })); |
| }; |
- 修改 user-disconnected 处理:
当用户断开连接时,从 remoteAudioStreams
中移除对应的音频流:
| socket.on('user-disconnected', (userId) => { |
| const { peersRef } = get(); |
| if (peersRef[userId]) { |
| peersRef[userId].close(); |
| delete peersRef[userId]; |
| set((state) => ({ |
| peersRef: { ...state.peersRef }, |
| users: state.users.filter((user) => user.id !== userId), |
| remoteAudioStreams: Object.fromEntries( |
| Object.entries(state.remoteAudioStreams).filter(([id]) => id !== userId) |
| ), |
| })); |
| } |
| }); |
- 动态渲染多个 audio 元素
当前的代码中只使用了一个 remoteAudioStream
,因此只会播放一个远程用户的音频流。如果要支持多个远程用户的音频流,需要将 remoteAudioStream
替换为一个数组或对象(例如 remoteAudioStreams
),并动态渲染多个 audio
元素来播放每个远程用户的音频流。
| import React from 'react'; |
| import useSocketStore from './store'; |
| |
| const AudioPlayers = () => { |
| const { localAudioStream, remoteAudioStreams } = useSocketStore(); |
| |
| return ( |
| <div> |
| {} |
| <audio |
| ref={(ref) => ref && (ref.srcObject = localAudioStream)} |
| autoPlay |
| muted |
| /> |
| |
| {} |
| {Object.entries(remoteAudioStreams).map(([userId, stream]) => { |
| if (!stream) return null; |
| return ( |
| <audio |
| key={userId} |
| ref={(ref) => ref && (ref.srcObject = stream)} |
| autoPlay |
| /> |
| ); |
| })} |
| </div> |
| ); |
| }; |
| |
| export default AudioPlayers; |
# 补充总结
- 🚀转远程音频流为远程音频流对象
- 🚀动态渲染多个 audio 元素
# 完整代码获取
访问 GitHub 仓库,获取完整代码。