前置工作
先把demo搭好
注意前端我们需要依赖socket.io-client
后端我们需要依赖socket.io
来搭建信令服务器
等等
WebRTC (Web Real-Time Communication ) )是一种实时通信技术,它允许网络应用程序和站点在不使用中间服务器的情况下,在浏览器之间直接建立点对点(P2P)连接,提供了一些可以用在视频聊天,音频聊天或 P2P 文件分享等 Web App 中的 API
不是两个客户端之间可以直接建立点对点连接吗?我们还需要信令服务器干什么?
由于NAT(网络地址转换)和防火墙限制了设备之间的直接通信。我们需要信令服务器可以充当一个中继,帮助设备之间建立连接,使通信得以顺利进行
一旦两台设备的连接建立完成,在P2P网络中,设备就可以直接使用其他设备的IP地址和端口进行数据传输
所以信令服务器的作用在于协调P2P连接的建立,告知双方需要交换媒体类型和格式
那另一个问题,为什么要基于websocket
协议而不是前端常用的http
协议创建信令服务器?
试想客户端A和B试图建立连接,双方需要交换一些基本信息
这里涉及到服务端主动推送信息到B客户端,使用http
协议来实现的话非常恶心(长/短轮询)
所以可以考虑使用基于websocket
的 js
库socket.io
来实现
前端依赖socket.io-client
后端依赖socket.io
js
// React + ts
yarn create vite
yarn add koa
// socket.io
yarn add socket.io-client
yarn add socket.io
// 创建后端目录
cd ./src
mkdir ./backend
cd ./backend
cd . > app.js
js
// app.js
import Koa from 'koa'
const app = new Koa()
app.listen(3333, ()=>{
console.log('后端跑在3333~')
})
package.json
加上 script
js
"scripts": {
"dev": "vite",
"dev-back": "cd ./src/backend && node ./app.js",
...
}
开发环境启动
js
yarn dev
yarn dev-back
Step 1: 准备好客户端的id和房间id
回到正题,房间视频通话,房间视频通话
我们需要在哪几个地方下功夫?我,你和我们要加入的房间
写两个输入框用于表明我是谁,我要加入哪个房间
js
const [info, setInfo] = useState<IInfo>({
myID: "",
roomID: ""
})
const [hasRegister, setHasRegister] = useState<boolean>(false)
const handleInput = (key: string, value: string) => {
setInfo({ ...info, [key]: value })
}
const handleClick = () => {
setHasRegister(!!(info.myID && info.roomID))
}
return (
<>
我的id:
<input type="text" onInput={(e) => handleInput("myID", (e.target as HTMLInputElement).value)} />
房间id:
<input type='text' onInput={(e) => handleInput("roomID", (e.target as HTMLInputElement).value)} />
<button onClick={handleClick} >斯达头!</button>
</>
js
export interface IInfo {
myID: string,
roomID: string
}
清晰明了
Step 2: 获取本地视频流并挂载到video标签上
感谢全能的浏览器提供了完善的API
js
const myVideoRef = useRef<HTMLVideoElement>(null)
const myVideoStream = useRef<MediaStream | null>(null)
const init = async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
myVideoStream.current = stream
myVideoRef.current.srcObject = myVideoStream.current!
}
useEffect(() => {
init()
}, [])
return <>
<video ref={myVideoRef} width="200" height="200" autoPlay muted />
</>
这里用到的API:navigator.mediaDevices.getUserMedia
getUserMedia
- 为一个 RTC 连接获取设备的摄像头与 (或) 麦克风权限,并为此 RTC 连接接入设备的摄像头与 (或) 麦克风的信号。
欲了解更多可看 developer.mozilla.org/zh-CN/docs/...
哇哦,现在我们的脑袋出现在了屏幕上

Step 3: 在发出媒体流之前
在发出媒体流之前,需要找到发送方
并且发送方必须和我们在同一房间中
js
import { Socket, io } from 'socket.io-client'
// 维护一个和 websocket 服务器的连接
const socketRef = useRef<Socket | null>(io('http://localhost:3333'))
// 发起连接,告诉 websocket 服务器我们的id和所在房间的id
const pushStream = async (stream: MediaStream) => {
const socket = socketRef.current!
if (!hasConnect) {
socket.send({
type: 'connect',
data: {
myID: props.info.myID,
roomID: props.info.roomID
}
})
setHasConnect(true)
}
}
}
在后端 app.js
下
js
import Koa from 'koa'
import { Server } from 'socket.io'
// 创建服务器
const app = new Koa()
//创建 webSocket 服务
const io = new Server({
//设置跨域
cors: {
origin: ["http://localhost:5173", "http://localhost:5174"]
}
});
// 存储连接上ws服务的客户端信息
const clients = {};
// 在 connection 完成后,持续监听 message
io.on("connection", function (ws) {
ws.on("message", function (message) {
let clientID
let roomID
switch (message.type) {
// 如果 message 中携带的 type 是 connect
case "connect":
// 将连接的客户端存储起来
clientID = message.data.myID;
roomID = message.data.roomID
console.log(`${clientID} has connected in ${roomID}`)
// 存到 clients 对象里
clients[clientID] = {
clientID,
roomID,
ws,
};
ws.send(
JSON.stringify({
type: "connect",
})
);
break;
}
})
})
现在服务端能感知到客户端的存在,并存储在服务端中,如果能感知到多个客户端,自然就能引导多个客户端之间建立p2p连接
Step 4: 建立p2p连接
在客户端,创建一个p2p连接实例peer
js
const peerRef = useRef<RTCPeerConnection | null>(new RTCPeerConnection())
const pushStream = async (stream: MediaStream) => {
....
// 把本地的音视频流先塞到 peer里,虽然连接还没建立
stream.getTracks().forEach((track) => peer.addTrack(track))
// 连接的必要条件1:准备好客户端A的offer,发给服务端
const offer = await peer.createOffer()
await peer.setLocalDescription(offer)
socket.send({
type: 'offer',
data: {
sdp: offer.sdp,
myID: props.info.myID,
roomID: props.info.roomID
}
})
}
回到服务端, 如果我们接受到的是客户端的offer,我们需要把offer交给房间内的其他客户端
js
case "offer":
sdp = message.data.sdp;
clientID = message.data.myID;
roomID = message.data.roomID;
for (const client in clients) {
if (isNotClientSelf(clients, client, roomID, clientID)) {
// 触发客户端B的call事件
clients[client].ws.emit('call', {
data: sdp,
})
}
}
break;
再回到客户端, 我们需要监听call事件是否被服务端触发
js
useEffect(() => {
const socket = socketRef.current!
const peer = peerRef.current!
// 接受到其他 client 的 call
socket.on('call', async (res) => {
const sdp = res.data
// 建立p2p连接的必要条件2:B拿到客户端A的offer,并设置到自己的连接实例中
await peer.setRemoteDescription({
type: 'offer',
sdp,
})
// 建立p2p连接的必要条件3: B设置完sdp,"answer"A
peer.createAnswer().then(async answer => {
await peer.setLocalDescription(answer)
socket.send({
type: 'answer',
data: {
sdp: answer.sdp,
roomID: props.info.roomID,
myID: props.info.myID
}
})
})
})
return () => {
socket.off('call')
....
}
}, [props.info])
再回到服务端...把来自B的answer
发给A
js
case "answer":
sdp = message.data.sdp;
clientID = message.data.myID;
roomID = message.data.roomID;
for (const client in clients) {
if (isNotClientSelf(clients, client, roomID, clientID)) {
clients[client].ws.emit('answer', {
data: sdp
})
}
}
break;
当p2p连接建立后,客户端A和B最后要交换 candidate
js
//一旦自己的candidate准备好了,就发给客户端
peer.onicecandidate = (event) => {
const candidate = event.candidate;
if (candidate) {
socket.send({
type: 'candidate',
data: {
candidate,
roomID: props.info.roomID,
myID: props.info.myID
}
})
}
}
// 一旦自己收到的对方的candidate,就加入自己的p2p连接中
socket.on('candidate', async (res) => {
const candidate = res.data
await peer.addIceCandidate(new RTCIceCandidate(candidate))
})
服务端作为交换candidate
的中间桥梁
js
case "candidate":
candidate = message.data.candidate;
clientID = message.data.myID;
roomID = message.data.roomID;
for (const client in clients) {
if (isNotClientSelf(clients, client, roomID, clientID)) {
clients[client].ws.emit('candidate', {
data: candidate
})
}
}
break;
Step 5: 挂载对方视频到自己的Video标签上
js
// 一旦执行完上面的步骤,客户端A就可以拿到客户端B的视频流了
// ontrack监听到就挂到video标签上即可
peer.ontrack = (e) => {
yourVideoStream.current!.addTrack(e.track)
yourVideoRef.current.srcObject = yourVideoStream.current!
}
PS
还可以在服务端加上一个定时器,
周期性同步一些数据给房间内其他客户端(如房间人数,房间内客户端有哪些...)
js
setInterval(() => {
for (const client in clients) {
clients[client].ws.emit('updateAllClients', {
data: .....
})
}
}, 2000)
我们也可以使用socket.io的api来实现房间的效果,这里就不再赘述了,