浏览器中的 WebRTC API:核心四件套

说实话,很多初学者一上来就被 WebRTC 的各种 API 搞懵了。我当年也是。打开 MDN 文档,RTCPeerConnection、RTCSessionDescription、RTCIceCandidate、MediaStream…… 一堆名词堆在一起,看着就头大。

但你别怕。这四样东西,说白了就是 WebRTC 的「四肢」和「大脑」。搞懂了它们,你就掌握了浏览器端 WebRTC 的命脉。

今天我就带你把这四个核心 API 拆开揉碎了讲。我会结合我实际项目里踩过的坑,让你少走弯路。

1. MediaStream:你的摄像头和麦克风

先讲最简单的。MediaStream,顾名思义,就是「媒体流」。它代表的是从摄像头、麦克风或者屏幕捕获到的音视频数据。

在浏览器里,我们通常用 navigator.mediaDevices.getUserMedia() 来获取它。

// 获取摄像头和麦克风
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});

// 把视频流塞到 <video> 标签里
const videoElement = document.querySelector('video');
videoElement.srcObject = stream;

嗯,这里要注意一点。我在项目里遇到过用户拒绝授权的情况。如果用户点了「拒绝」,getUserMedia 会抛出一个 NotAllowedError。所以一定要加 try-catch。

避坑指南: 我曾经在线上环境忘了处理这个异常,结果用户拒绝摄像头后整个页面白屏。后来我加了个降级方案——如果拿不到摄像头,就显示一个默认的占位图。

MediaStream 还有一个重要特性:它可以包含多个轨道(Track)。比如一个流里既有视频轨道又有音频轨道。你可以单独控制每个轨道的启用或禁用。

// 关闭麦克风
stream.getAudioTracks().forEach(track => track.enabled = false);

// 关闭摄像头
stream.getVideoTracks().forEach(track => track.stop());

我个人习惯在通话结束时,主动调用 track.stop() 来释放摄像头资源。否则有些浏览器会一直亮着摄像头指示灯,用户会慌的。

2. RTCPeerConnection:连接的核心

RTCPeerConnection 是整个 WebRTC 的「心脏」。它负责管理端到端的连接,包括音视频数据的编码、传输、以及网络穿透。

你可以把它想象成一个「管道工」。它帮你把本地的媒体流,通过复杂的网络环境,送到对方那里。

创建一个连接很简单:

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }
  ]
});

这里有个关键参数——iceServers。它告诉浏览器去哪里找 STUN 或 TURN 服务器。我建议至少配一个 Google 的公共 STUN 服务器,不然很多内网环境根本连不上。

RTCPeerConnection 的核心工作流程是这样的:

  1. 把本地 MediaStream 的轨道添加到连接中
  2. 创建 Offer(SDP 提案)
  3. 设置本地描述
  4. 通过信令服务器把 Offer 发给对方
  5. 对方设置远程描述,创建 Answer
  6. 双方交换 ICE 候选者
  7. 连接建立,开始传输数据

你想想看,这其实就是一个「握手」的过程。双方先互相打个招呼,说「我支持这些编码格式,我的网络地址是这些」,然后协商出一个双方都能接受的方案。

核心要点: RTCPeerConnection 不负责信令传输。它只负责媒体协商和网络穿透。信令(比如交换 SDP 和 ICE 候选者)需要你自己实现,通常用 WebSocket 或者 HTTP 轮询。

3. RTCSessionDescription:协商的「合同」

RTCSessionDescription 说白了就是一份「技术合同」。它描述了媒体会话的参数,比如用什么编码格式、音频采样率多少、视频分辨率多大。

它有两种类型:

  • Offer:发起方提出的提案
  • Answer:接收方给出的回应

创建 Offer 和 Answer 的代码长这样:

// 发起方:创建 Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 然后把 offer 通过信令发给对方

// 接收方:收到 Offer 后创建 Answer
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 然后把 answer 发回给发起方

这里有个坑,我踩过好几次。就是 setLocalDescriptionsetRemoteDescription 的顺序不能乱。必须先设置本地描述,再设置远程描述,或者反过来?

其实规则很简单:

  • 发起方:先 createOffer,然后 setLocalDescription(offer)
  • 接收方:先 setRemoteDescription(offer),然后 createAnswer,再 setLocalDescription(answer)

顺序错了,浏览器会直接报错。我刚开始做的时候,经常把 setLocalDescriptionsetRemoteDescription 搞混,结果调试了半天才发现是顺序问题。

小技巧: 你可以监听 pc.oniceconnectionstatechange 事件来判断连接是否建立成功。如果状态变成 connected,说明握手完成了。

4. RTCIceCandidate:网络穿透的「探针」

RTCIceCandidate 是 WebRTC 里最神奇的部分。它代表一个可能的网络路径。每个候选者包含一个 IP 地址和端口号,以及传输协议(UDP/TCP)。

ICE(Interactive Connectivity Establishment)协议会收集三类候选者:

类型 来源 优先级
Host 本机网卡 IP 最高
Srflx STUN 服务器反射地址 中等
Relay TURN 服务器中继地址 最低

ICE 的工作流程是这样的:

  1. 浏览器收集所有本地候选者
  2. 通过信令把候选者发给对方
  3. 双方尝试用候选者配对连接
  4. 如果直连失败,就尝试用 TURN 服务器中继

代码层面,你只需要监听 onicecandidate 事件:

pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 把候选者通过信令发给对方
    signalingChannel.send({
      type: 'candidate',
      candidate: event.candidate
    });
  }
};

反过来,收到对方的候选者后,调用 addIceCandidate

await pc.addIceCandidate(new RTCIceCandidate(candidate));

我曾经在一个跨国项目中遇到过一个问题:用户在内网,对方在海外,直连死活连不上。后来发现是防火墙把 UDP 端口封了。解决方案是强制走 TURN 服务器,用 TCP 中继。虽然延迟高了一点,但至少能通。

注意: 不要以为配了 STUN 就万事大吉。在对称 NAT 环境下,STUN 是没用的。你必须准备一个 TURN 服务器作为兜底方案。我建议用 coturn 自建,或者用云服务商的 TURN 服务。

核心逻辑流程图

下面这张图展示了四个 API 之间的协作关系。我画的时候特意把流程简化了,方便你理解整体脉络。

WebRTC 核心 API 协作流程 MediaStream 摄像头/麦克风 RTCPeerConnection 连接管理 RTCSessionDescription + RTCIceCandidate SDP:媒体协商(编码、分辨率等) ICE:网络穿透(候选者交换) 信令服务器(WebSocket / HTTP) 图:四个核心 API 的协作关系

从这张图你可以看到,MediaStream 提供数据源,RTCPeerConnection 负责管理连接,RTCSessionDescription 和 RTCIceCandidate 则通过信令服务器完成协商和穿透。四者缺一不可。

总结一下

这四个 API 的关系,我用一句话概括:

MediaStream 是原料,RTCPeerConnection 是生产线,RTCSessionDescription 是生产图纸,RTCIceCandidate 是物流路线。

你只要记住这个比喻,后面的章节学起来就会轻松很多。下一节我们会深入 ICE 的细节,讲讲 STUN 和 TURN 到底是怎么工作的。


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