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 拆成四块理解,会清晰很多:
- 采集(Capture):
getUserMedia()/getDisplayMedia()得到MediaStream - 连接(Transport):
RTCPeerConnection负责协商与传输 - 媒体(Media):音视频 track 的编码、传输、抖动缓冲、丢包恢复等(浏览器内部做)
- 数据(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:收到对端音视频 trackpc.connectionState/pc.iceConnectionState/pc.signalingState:排障必看
你会频繁用到的方法:
pc.addTrack(track, stream):把本地 track 加入连接pc.createOffer()/pc.createAnswer():生成 SDPpc.setLocalDescription(desc)/pc.setRemoteDescription(desc):设置本地/远端描述pc.addIceCandidate(candidate):添加对端 candidatepc.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}}
因为双方处在不同网络里(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. 高频面试题(含答案)
-
Q:WebRTC 建连为什么要“信令服务器”?
A:WebRTC 只定义了点对点传输与协商协议,但不规定“怎么把 offer/answer/candidate 送到对方”。信令服务器负责“传话”,常用 WebSocket 实现。 -
Q:Offer/Answer(SDP)里大概有什么?能手改吗?
A:包含编解码能力、媒体方向(sendrecv/recvonly)、候选信息、ICE/DTLS 参数等。通常不建议手改 SDP;更推荐通过 API(如addTransceiver、编码参数)来影响协商结果。 -
Q:ICE、STUN、TURN 的关系是什么?
A:ICE 是“找路+试路”的框架;STUN 帮你发现公网映射地址(便于打洞);TURN 在直连失败时提供中继转发(保证可用性)。 -
Q:为什么只配 STUN 经常不稳定?
A:企业网/对称 NAT/防火墙限制 UDP 等情况会导致打洞失败;没有 TURN 兜底就会出现“某些网络完全连不上”。 -
Q:WebRTC 的音视频是不是明文?
A:不是。WebRTC 强制加密,媒体常用 DTLS-SRTP,数据通道用 DTLS(承载 SCTP)。 -
Q:DataChannel 和 WebSocket 有什么区别?
A:WebSocket 走客户端-服务器;DataChannel 是点对点(或经 TURN 中继),延迟更低、可配置可靠性/顺序,但需要先完成 WebRTC 连接与协商。 -
Q:多人通话为什么更推荐 SFU?
A:Mesh 连接数和带宽随人数线性/平方增长;SFU 让每个客户端只维护一条上/下行到服务器的连接,更易扩展且延迟更低(相对 MCU)。 -
Q:什么是“重新协商(Renegotiation)”?什么时候会触发?
A:当媒体轨道/方向/编码参数发生变化时需要重新交换 SDP,比如新增/移除 track、共享屏幕、切分辨率等。 -
Q:什么是 offer collision(glare)?怎么解决?
A:双方同时发 offer 导致状态机冲突。常用 Perfect Negotiation:指定礼貌方在冲突时回退并接受对方 offer,不礼貌方忽略冲突 offer。 -
Q:如何快速定位“到底是信令问题还是 ICE 问题”?
A:先看signalingState是否稳定(offer/answer 是否配对),再看iceConnectionState是否进入connected/completed。信令错通常表现为 setRemoteDescription 报错或状态不对;ICE 错通常表现为failed。 -
Q:如何判断是否走了 TURN?
A:通过getStats()找最终选中的 candidate pair,candidate type 为relay基本就是 TURN 中继;也可以临时设置iceTransportPolicy: 'relay'强制走 TURN 验证。 -
Q:WebRTC 为什么更“低延迟”?
A:它基于 UDP 优先的实时传输、拥塞控制与抖动缓冲,并针对音视频做了大量实时优化;但低延迟的前提是网络可达与合理的码率/分辨率策略。 -
Q:切换摄像头一定要重建连接吗?
A:通常不需要,优先用RTCRtpSender.replaceTrack()替换发送轨道;必要时再做 renegotiation。 -
Q:浏览器端怎么调试 WebRTC?
A:Chrome/Edge 用chrome://webrtc-internals,Firefox 用about:webrtc,配合getStats()查看码率、丢包、RTT、candidate 类型等。 -
Q:为什么有时候能看到画面但听不到声音?
A:常见原因包括:音频 track 没 addTrack、浏览器自动播放策略导致音频被阻止、设备权限/输入设备选择错误、对端静音或音量为 0、编码协商不一致等。排查时先确认ontrack是否收到 audio track,再看是否能播放与音量状态。