综合实战:构建一个完整的WebRTC视频通话应用

终于到了这一步。前面我们聊了STUN、TURN、ICE、SDP,全是理论。说实话,光看这些协议栈,你可能会觉得WebRTC很复杂。但真正动手搭一个视频通话应用,你会发现——嗯,核心流程其实就那么几条线。

今天我们就来走一遍完整的流程。从信令服务器搭建,到媒体流传输,最后让两个浏览器能互相看到对方的脸。我会用Node.js做信令层,前端用原生JS + WebRTC API。你不用复制粘贴所有代码,重点是理解每个环节在干什么。

核心目标: 两个浏览器之间建立P2P视频通话,信令服务器只负责交换元数据,不转发媒体流。

整体架构:信令 + 媒体,两条线

先看一张图,把整个流程串起来。我习惯把WebRTC通话拆成两个平面:信令平面和媒体平面。

WebRTC 视频通话整体架构 信令服务器 (Node.js) 浏览器 A 本地流 + PeerConnection 浏览器 B 本地流 + PeerConnection WebSocket 信令交换 P2P 媒体流 (SRTP/SCTP) STUN / TURN 服务器

看到没?信令走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转发。你想想看,这不就是一个带路由的广播器吗?

我的习惯: 生产环境中我会加一个房间管理模块。用户先创建房间,其他人通过房间号加入。这样就不需要手动交换ID了。

第二步:浏览器端——获取本地媒体流

在浏览器里,第一步永远是拿摄像头和麦克风。用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是“对方要用这个配置接收”。

注意: SDP交换必须严格遵循“发起方创建Offer → 接收方回复Answer”的顺序。如果双方同时发起Offer,就会产生“ glare ”冲突。我在项目中遇到过,解决方案是用一个简单的锁机制,或者让一方作为主叫方。

第五步: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超时参数。

完整流程时序图

把上面所有步骤串起来,整个流程是这样的:

WebRTC 视频通话完整时序 浏览器 A 信令服务器 浏览器 B 1. 获取本地媒体流 1. 获取本地媒体流 2. 创建Offer Offer SDP 转发 Offer 3. 设置远程描述 + 创建Answer Answer SDP 转发 Answer 4. ICE候选交换 4. ICE候选交换 5. P2P媒体流传输 5. P2P媒体流传输

常见问题与避坑指南

最后,分享几个我在项目中踩过的坑:

  • SDP格式不匹配: 有时候浏览器版本不同,生成的SDP格式有细微差异。我建议在信令服务器端加一个SDP版本检查,或者统一用最新的Chrome做测试。
  • ICE候选丢失: 如果信令服务器转发候选时丢包,可能导致连接失败。解决方案是在客户端加一个候选重传机制,或者用可靠的WebSocket连接。
  • 音频回声: 这个太常见了。记得在getUserMedia中设置echoCancellation: true。如果还不行,可以在音频轨道上应用降噪处理。
  • NAT穿透失败: 有些对称NAT环境下,STUN无法穿透。这时候TURN就是救命稻草。我曾经在客户现场遇到这个问题,最后加了TURN服务器才解决。
调试小技巧: 打开chrome://webrtc-internals,可以看到所有WebRTC连接的详细信息,包括SDP、ICE候选、丢包率等。我每次排查问题第一件事就是看这个页面。

好了,到这里你已经掌握了构建一个完整WebRTC视频通话应用的所有核心步骤。从信令服务器到媒体流传输,每一步都有它的意义。下次当你看到两个浏览器成功建立视频通话时,你会知道背后发生了什么——嗯,这种感觉挺好的。


公众号:蓝海资料掘金营,微信deep3321