综合实战:构建一个完整的WebRTC视频通话应用
终于到了这一步。前面我们聊了STUN、TURN、ICE、SDP,全是理论。说实话,光看这些协议栈,你可能会觉得WebRTC很复杂。但真正动手搭一个视频通话应用,你会发现——嗯,核心流程其实就那么几条线。
今天我们就来走一遍完整的流程。从信令服务器搭建,到媒体流传输,最后让两个浏览器能互相看到对方的脸。我会用Node.js做信令层,前端用原生JS + WebRTC API。你不用复制粘贴所有代码,重点是理解每个环节在干什么。
整体架构:信令 + 媒体,两条线
先看一张图,把整个流程串起来。我习惯把WebRTC通话拆成两个平面:信令平面和媒体平面。
看到没?信令走WebSocket,媒体流走P2P。信令服务器只干一件事:帮两个浏览器交换SDP和ICE候选。媒体数据一旦建立连接,就不经过服务器了。这也是WebRTC节省带宽的核心思路。
第一步:搭建信令服务器
信令服务器说白了就是一个消息中转站。我用Node.js + ws库,几十行代码就能搞定。
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws) => {
const id = generateId();
clients.set(id, ws);
ws.send(JSON.stringify({ type: 'id', id }));
ws.on('message', (data) => {
const msg = JSON.parse(data);
const target = clients.get(msg.targetId);
if (target) {
target.send(JSON.stringify({
type: msg.type,
senderId: id,
data: msg.data
}));
}
});
ws.on('close', () => {
clients.delete(id);
// 通知对方断开
});
});
这段代码我简化了,但核心逻辑就这些。每个客户端连接后分配一个ID,收到消息后根据targetId转发。你想想看,这不就是一个带路由的广播器吗?
第二步:浏览器端——获取本地媒体流
在浏览器里,第一步永远是拿摄像头和麦克风。用getUserMedia,这个API我用了不下百次了。
async function startLocalStream() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
document.getElementById('localVideo').srcObject = stream;
return stream;
} catch (err) {
console.error('获取媒体流失败:', err);
// 我曾经遇到用户没有摄像头,结果页面直接崩溃...
// 所以一定要加错误处理
}
}
这里有个坑:getUserMedia必须在用户交互事件(比如点击按钮)中调用。否则浏览器会拒绝。我记得第一次做Demo时,直接在页面加载时调用,结果Chrome直接抛了个NotAllowedError。嗯,从那以后我都在按钮点击事件里调用了。
第三步:创建RTCPeerConnection
拿到本地流之后,就该创建PeerConnection了。这是WebRTC的核心对象,负责编码、传输、解码全流程。
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'pass'
}
]
});
// 添加本地流
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 监听远程流
pc.ontrack = (event) => {
document.getElementById('remoteVideo').srcObject = event.streams[0];
};
ICE服务器配置里,STUN是必填的,TURN是备选。我建议你至少配两个STUN服务器,因为有些网络环境对特定STUN服务器有屏蔽。
第四步:信令交换——Offer/Answer模型
这是整个流程中最容易出错的一环。两个浏览器必须严格按照Offer/Answer模型交换SDP。
发起方(A)创建Offer,设置本地描述,然后通过信令服务器发给接收方(B)。
// 发起方 A
async function call() {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendMessage({ type: 'offer', data: offer });
}
// 接收方 B
async function handleOffer(offer) {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendMessage({ type: 'answer', data: answer });
}
这里有个细节:setLocalDescription和setRemoteDescription的顺序不能乱。我见过有人先setRemote再setLocal,结果SDP状态机直接报错。说白了,setLocalDescription是告诉浏览器“我要用这个配置发送”,setRemoteDescription是“对方要用这个配置接收”。
第五步:ICE候选交换
SDP交换完成后,双方开始收集ICE候选。每个候选都是一个可能的连接地址。收集到的候选需要通过信令服务器发给对方。
pc.onicecandidate = (event) => {
if (event.candidate) {
sendMessage({ type: 'candidate', data: event.candidate });
}
};
// 收到对方候选时
function handleCandidate(candidate) {
pc.addIceCandidate(new RTCIceCandidate(candidate));
}
ICE候选的交换是异步的,可能持续几秒钟。你不需要等所有候选都收集完再发,收到一个就发一个。这样对方可以尽早开始连接尝试。
第六步:连接状态监控
连接建立后,你需要监控它的状态。WebRTC提供了几个事件,我一般会这样用:
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
if (pc.connectionState === 'connected') {
// 连接成功,可以隐藏loading提示
} else if (pc.connectionState === 'disconnected' ||
pc.connectionState === 'failed') {
// 尝试重连或提示用户
}
};
pc.oniceconnectionstatechange = () => {
console.log('ICE状态:', pc.iceConnectionState);
};
我曾经遇到一个场景:用户网络突然断开,但浏览器没有触发disconnected事件,而是直接跳到了failed。后来发现是因为ICE超时时间默认是30秒。如果你需要更快的检测,可以设置iceConnectionState超时参数。
完整流程时序图
把上面所有步骤串起来,整个流程是这样的:
常见问题与避坑指南
最后,分享几个我在项目中踩过的坑:
- SDP格式不匹配: 有时候浏览器版本不同,生成的SDP格式有细微差异。我建议在信令服务器端加一个SDP版本检查,或者统一用最新的Chrome做测试。
- ICE候选丢失: 如果信令服务器转发候选时丢包,可能导致连接失败。解决方案是在客户端加一个候选重传机制,或者用可靠的WebSocket连接。
- 音频回声: 这个太常见了。记得在getUserMedia中设置echoCancellation: true。如果还不行,可以在音频轨道上应用降噪处理。
- NAT穿透失败: 有些对称NAT环境下,STUN无法穿透。这时候TURN就是救命稻草。我曾经在客户现场遇到这个问题,最后加了TURN服务器才解决。
好了,到这里你已经掌握了构建一个完整WebRTC视频通话应用的所有核心步骤。从信令服务器到媒体流传输,每一步都有它的意义。下次当你看到两个浏览器成功建立视频通话时,你会知道背后发生了什么——嗯,这种感觉挺好的。
公众号:蓝海资料掘金营,微信deep3321