跳到主要内容

WebRTC:浏览器端实时音视频与 P2P 数据通道(从入门到实战)

当你想在浏览器里实现这些能力时,WebRTC 往往是“正解”:

  • 一对一视频/语音通话(类似 Web 版微信/Zoom)
  • 屏幕共享、远程协作
  • P2P 数据通道:低延迟聊天、文件传输、多人白板同步

WebRTC 的关键点也很容易被误解:

  • WebRTC 不是“一个接口”,而是一整套实时通信能力(采集、编解码、传输、拥塞控制、加密……)
  • WebRTC 不负责信令(Signaling):offer/answer、ICE candidate 必须通过你自己的信令通道转发(WebSocket/HTTP/Socket.IO 都行)
  • WebRTC 不一定纯 P2P:遇到复杂 NAT/防火墙时,常常需要 TURN 中继;多人会议通常需要 SFU

1. WebRTC 由哪些“积木”组成?

把 WebRTC 拆成四块理解,会清晰很多:

  1. 采集(Capture)getUserMedia() / getDisplayMedia() 得到 MediaStream
  2. 连接(Transport)RTCPeerConnection 负责协商与传输
  3. 媒体(Media):音视频 track 的编码、传输、抖动缓冲、丢包恢复等(浏览器内部做)
  4. 数据(DataChannel)RTCDataChannel 提供类似“点对点 WebSocket”的通道
一句话记住

WebRTC = 采集(MediaStream) + 连接(RTCPeerConnection) +(可选)数据通道(RTCDataChannel) + 你自己的信令。


2. 关键 API 速查(你必须认识的对象)

2.1 navigator.mediaDevices.getUserMedia():采集摄像头/麦克风

const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: {width: 1280, height: 720, frameRate: 30},
});

const videoEl = document.querySelector('video#local');
videoEl.srcObject = stream;
await videoEl.play();

常用配套 API:

  • navigator.mediaDevices.getDisplayMedia():屏幕共享
  • navigator.mediaDevices.enumerateDevices():列出摄像头/麦克风设备(做“切换设备”功能)
经验提醒

WebRTC(尤其是采集)通常要求 HTTPS 安全上下文(本地开发 http://localhost 一般被视为安全)。

2.2 RTCPeerConnection:连接的“总管”

你会频繁用到的属性/事件:

  • pc.onicecandidate:产生 ICE candidate(需要通过信令转发给对端)
  • pc.ontrack:收到对端音视频 track
  • pc.connectionState / pc.iceConnectionState / pc.signalingState:排障必看

你会频繁用到的方法:

  • pc.addTrack(track, stream):把本地 track 加入连接
  • pc.createOffer() / pc.createAnswer():生成 SDP
  • pc.setLocalDescription(desc) / pc.setRemoteDescription(desc):设置本地/远端描述
  • pc.addIceCandidate(candidate):添加对端 candidate
  • pc.getStats():获取链路与质量统计

2.3 RTCDataChannel:P2P 数据通道

const dc = pc.createDataChannel('chat', {ordered: true});
dc.onopen = () => dc.send('hello');
dc.onmessage = (e) => console.log('msg:', e.data);

它适合:

  • 低延迟的互动数据(聊天、协作编辑、白板)
  • 小文件/分片文件传输(注意流控)

3. 建连全流程(Offer/Answer + ICE)——最重要的一节

先把大图背下来,后面所有细节都是在解释它:

3.1 你必须自己实现的:信令通道

WebRTC 规范刻意不规定“信令怎么做”,因此常见做法是:

  • WebSocket(最常见)
  • HTTP 轮询 / SSE(能用,但不如 WS 自然)
  • 第三方 IM 通道(只要能转发消息就行)

你信令消息里通常就三类:

{"type":"offer","sdp":"..."}
{"type":"answer","sdp":"..."}
{"type":"candidate","candidate":{"candidate":"...","sdpMid":"0","sdpMLineIndex":0}}
为什么要“候选交换”(candidate)?

因为双方处在不同网络里(NAT/防火墙/多网卡),可用的“连接方式”不止一种。ICE 会不停尝试各种路径,直到找到能通的那条。

3.2 最小可跑的 1v1 示例(省略 UI,只保留核心)

下面示例把“信令发送/接收”抽象成 signal.send() / signal.on(),你可以用 WebSocket 自己实现它。

3.2.1 公共:创建 RTCPeerConnection

function createPeerConnection() {
const pc = new RTCPeerConnection({
iceServers: [
{urls: 'stun:stun.l.google.com:19302'},
// 生产环境强烈建议配 TURN(示例不放真实账号)
// {urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p'},
],
});

pc.addEventListener('icecandidate', (e) => {
if (e.candidate) signal.send({type: 'candidate', candidate: e.candidate});
});

pc.addEventListener('track', (e) => {
const remoteVideo = document.querySelector('video#remote');
remoteVideo.srcObject = e.streams[0];
});

return pc;
}

3.2.2 呼叫方(发起 offer)

const pc = createPeerConnection();

const localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
for (const track of localStream.getTracks()) pc.addTrack(track, localStream);

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signal.send({type: 'offer', sdp: pc.localDescription.sdp});

signal.on('answer', async ({sdp}) => {
await pc.setRemoteDescription({type: 'answer', sdp});
});

signal.on('candidate', async ({candidate}) => {
await pc.addIceCandidate(candidate);
});

3.2.3 接听方(收到 offer,回 answer)

const pc = createPeerConnection();

signal.on('offer', async ({sdp}) => {
await pc.setRemoteDescription({type: 'offer', sdp});

const localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
for (const track of localStream.getTracks()) pc.addTrack(track, localStream);

const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signal.send({type: 'answer', sdp: pc.localDescription.sdp});
});

signal.on('candidate', async ({candidate}) => {
await pc.addIceCandidate(candidate);
});
真实项目别忽略的细节
  • addIceCandidate() 可能在 setRemoteDescription() 之前到达:要么缓存 candidate 等到 RD 完成再加,要么使用成熟的信令封装。
  • 断线重连不是“自动就有”:你要处理重新协商、重试、TURN 兜底等策略。

4. ICE / STUN / TURN:WebRTC 能否连得上,80% 看它们

4.1 ICE 是什么?

ICE(Interactive Connectivity Establishment)是一套“找路 + 试路”的机制:

  • 找路:收集本机可能的地址(host)、通过 STUN 得到公网映射地址(srflx)、必要时通过 TURN 拿到中继地址(relay)
  • 试路:双方互换 candidate,然后做连通性检查,选出最佳的 candidate pair

4.2 STUN vs TURN:一句话区分

组件解决什么是否转发媒体成本
STUN帮你“探测公网映射地址”
TURN直连不通时“中继转发媒体/数据”高(带宽钱)
实战结论

只配 STUN 的 WebRTC,在公司网/校园网/部分运营商网络里经常“时灵时不灵”。
想要稳定可用,必须准备 TURN 作为兜底。

4.3 什么时候一定要 TURN?

常见场景:

  • 双方都在“对称 NAT(Symmetric NAT)”
  • 企业防火墙只放行 443/TCP,或限制 UDP
  • 运营商网络对 P2P 打洞不友好

你也可以临时“强制走 TURN”来排查问题:

const pc = new RTCPeerConnection({
iceTransportPolicy: 'relay', // 只使用 TURN relay candidate
iceServers: [{urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p'}],
});

5. 媒体轨道的常用操作(静音/切摄像头/屏幕共享)

5.1 静音/关摄像头:最简单

// 关闭麦克风(不再发送音频)
audioTrack.enabled = false;

// 打开麦克风
audioTrack.enabled = true;

5.2 切换摄像头:replaceTrack() 是关键

切摄像头/切屏共享时,很多人第一反应是“重新建连接”。其实通常没必要,你可以替换发送端 track:

async function switchCamera(sender, deviceId) {
const stream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: {exact: deviceId}},
audio: false,
});
const newVideoTrack = stream.getVideoTracks()[0];
await sender.replaceTrack(newVideoTrack);
}
注意

replaceTrack() 不等于“什么都不用管”。如果你改变了编码参数/分辨率/轨道数量,可能仍会触发重新协商(renegotiation)。


6. Perfect Negotiation:解决“同时发 offer”导致的冲突(真实项目必备)

当你支持“随时开关摄像头/共享屏幕/切分辨率”时,连接会发生重新协商。最常见的坑是:

  • A、B 几乎同时触发 negotiationneeded
  • 两边都在 createOffer() 并发送 offer
  • 结果出现 offer collision(glare),状态机进入混乱,通话断掉或卡死

社区推荐的稳定做法叫 Perfect Negotiation:选一端作为 polite(礼貌方),在冲突时让礼貌方回退/接受对方 offer。

下面是“核心逻辑骨架”(示意代码,建议你在工程里封装成类/模块):

let makingOffer = false;
const polite = true; // 两端一真一假(例如按 userId 大小决定)

pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(await pc.createOffer());
signal.send({type: 'offer', sdp: pc.localDescription.sdp});
} finally {
makingOffer = false;
}
};

signal.on('offer', async ({sdp}) => {
const offerCollision = makingOffer || pc.signalingState !== 'stable';
if (offerCollision && !polite) return; // 不礼貌方直接忽略

await pc.setRemoteDescription({type: 'offer', sdp});
await pc.setLocalDescription(await pc.createAnswer());
signal.send({type: 'answer', sdp: pc.localDescription.sdp});
});
你不一定要完整照抄,但要记住关键思想

冲突不可避免,必须有一端在冲突时“让路”。


7. DataChannel 实战:聊天与文件传输的关键点

7.1 建立 DataChannel 的两种方式

  • 发起方:pc.createDataChannel('name')
  • 接收方:监听 pc.ondatachannel
// 发起方
const dc = pc.createDataChannel('chat');
dc.onmessage = (e) => console.log(e.data);

// 接收方
pc.ondatachannel = (e) => {
const dc = e.channel;
dc.onmessage = (ev) => console.log(ev.data);
};

7.2 文件传输要做“流控”(Backpressure)

RTCDataChannel 有缓冲区,疯狂 send() 可能直接把内存顶爆。你至少要看两个属性:

  • dc.bufferedAmount:当前缓冲了多少字节
  • dc.bufferedAmountLowThreshold + bufferedamountlow 事件:低于阈值再继续发
async function sendFile(dc, file) {
dc.binaryType = 'arraybuffer';
dc.bufferedAmountLowThreshold = 256 * 1024;

const chunkSize = 16 * 1024;
let offset = 0;

while (offset < file.size) {
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
dc.send(chunk);
offset += chunkSize;

if (dc.bufferedAmount > 1 * 1024 * 1024) {
await new Promise((resolve) => dc.addEventListener('bufferedamountlow', resolve, {once: true}));
}
}
}

8. 多人通话架构:Mesh vs SFU vs MCU(面试常问)

多人通话很少用纯 P2P Mesh,因为连接数会爆炸:

  • 3 人:每人要连 2 条
  • 10 人:每人要连 9 条(带宽与 CPU 压力都很大)

常见三种架构:

架构思路优点缺点
Mesh(全互连)每人和每人直连最简单、无需媒体服务器N 大时不可用(连接数/带宽爆炸)
SFU(选择性转发)每人只连服务器,服务器转发媒体可扩展、延迟低、客户端压力小需要部署媒体服务器
MCU(混流)服务器把多路混成一路客户端最省服务器成本高、延迟更高
现实世界的结论

做“像会议软件那样的多人通话”,基本就是 SFU 路线(再配合信令、房间管理、录制、鉴权等)。


9. 调试与排障清单(别只会“改改代码”)

9.1 先看状态机:这三个最有用

  • pc.signalingState:协商状态是否稳定(是否在乱发 offer/answer)
  • pc.iceConnectionState:ICE 是否连通(checking/connected/failed
  • pc.connectionState:整体连接状态(更高层的汇总)

9.2 再看统计:getStats() 关注哪些指标?

通常你会关心:

  • RTT(往返时延)
  • packet loss(丢包率)
  • jitter(抖动)
  • bitrate(码率)
  • frames dropped(丢帧)
  • candidate pair(最终选中的网络路径:host/srflx/relay)

9.3 浏览器自带工具(非常好用)

  • Chrome / Edge:chrome://webrtc-internals(可导出日志)
  • Firefox:about:webrtc

10. 安全与隐私:为什么 WebRTC 默认就“加密”?

WebRTC 在设计上就要求媒体与数据传输加密(常见组合是 DTLS-SRTP):

  • 媒体:SRTP(并通过 DTLS 进行密钥协商)
  • 数据通道:基于 DTLS 的 SCTP

你需要做的是把“外围”也做好:

  • 站点走 HTTPS,避免中间人攻击与权限问题
  • 设计好鉴权与房间权限(谁能呼叫谁、谁能进房)
  • 注意隐私提示:摄像头/麦克风权限、屏幕共享提示

11. 高频面试题(含答案)

  1. Q:WebRTC 建连为什么要“信令服务器”?
    A:WebRTC 只定义了点对点传输与协商协议,但不规定“怎么把 offer/answer/candidate 送到对方”。信令服务器负责“传话”,常用 WebSocket 实现。

  2. Q:Offer/Answer(SDP)里大概有什么?能手改吗?
    A:包含编解码能力、媒体方向(sendrecv/recvonly)、候选信息、ICE/DTLS 参数等。通常不建议手改 SDP;更推荐通过 API(如 addTransceiver、编码参数)来影响协商结果。

  3. Q:ICE、STUN、TURN 的关系是什么?
    A:ICE 是“找路+试路”的框架;STUN 帮你发现公网映射地址(便于打洞);TURN 在直连失败时提供中继转发(保证可用性)。

  4. Q:为什么只配 STUN 经常不稳定?
    A:企业网/对称 NAT/防火墙限制 UDP 等情况会导致打洞失败;没有 TURN 兜底就会出现“某些网络完全连不上”。

  5. Q:WebRTC 的音视频是不是明文?
    A:不是。WebRTC 强制加密,媒体常用 DTLS-SRTP,数据通道用 DTLS(承载 SCTP)。

  6. Q:DataChannel 和 WebSocket 有什么区别?
    A:WebSocket 走客户端-服务器;DataChannel 是点对点(或经 TURN 中继),延迟更低、可配置可靠性/顺序,但需要先完成 WebRTC 连接与协商。

  7. Q:多人通话为什么更推荐 SFU?
    A:Mesh 连接数和带宽随人数线性/平方增长;SFU 让每个客户端只维护一条上/下行到服务器的连接,更易扩展且延迟更低(相对 MCU)。

  8. Q:什么是“重新协商(Renegotiation)”?什么时候会触发?
    A:当媒体轨道/方向/编码参数发生变化时需要重新交换 SDP,比如新增/移除 track、共享屏幕、切分辨率等。

  9. Q:什么是 offer collision(glare)?怎么解决?
    A:双方同时发 offer 导致状态机冲突。常用 Perfect Negotiation:指定礼貌方在冲突时回退并接受对方 offer,不礼貌方忽略冲突 offer。

  10. Q:如何快速定位“到底是信令问题还是 ICE 问题”?
    A:先看 signalingState 是否稳定(offer/answer 是否配对),再看 iceConnectionState 是否进入 connected/completed。信令错通常表现为 setRemoteDescription 报错或状态不对;ICE 错通常表现为 failed

  11. Q:如何判断是否走了 TURN?
    A:通过 getStats() 找最终选中的 candidate pair,candidate type 为 relay 基本就是 TURN 中继;也可以临时设置 iceTransportPolicy: 'relay' 强制走 TURN 验证。

  12. Q:WebRTC 为什么更“低延迟”?
    A:它基于 UDP 优先的实时传输、拥塞控制与抖动缓冲,并针对音视频做了大量实时优化;但低延迟的前提是网络可达与合理的码率/分辨率策略。

  13. Q:切换摄像头一定要重建连接吗?
    A:通常不需要,优先用 RTCRtpSender.replaceTrack() 替换发送轨道;必要时再做 renegotiation。

  14. Q:浏览器端怎么调试 WebRTC?
    A:Chrome/Edge 用 chrome://webrtc-internals,Firefox 用 about:webrtc,配合 getStats() 查看码率、丢包、RTT、candidate 类型等。

  15. Q:为什么有时候能看到画面但听不到声音?
    A:常见原因包括:音频 track 没 addTrack、浏览器自动播放策略导致音频被阻止、设备权限/输入设备选择错误、对端静音或音量为 0、编码协商不一致等。排查时先确认 ontrack 是否收到 audio track,再看是否能播放与音量状态。