能力 · API
WebRTC:浏览器中的点对点音视频与数据
发布于 更新于
一句话: WebRTC 与 Media Capture and Streams API 一起,为 Web 带来了音视频通话、 文件交换与屏幕共享——建立点对点连接不需要特殊驱动或插件,一旦连接建立,往往也不需要 任何中间服务器。
RTCPeerConnection表示本机与远端对等方之间的一条 WebRTC 连接;一旦建立并打开, 就可以向其添加媒体流和/或数据通道。MediaStreamTrack表示流中的单条媒体轨道——音频、视频或文本。RTCDataChannel添加到已打开的RTCPeerConnection上,用于在对等方之间直接携带 任意应用数据,可与媒体一起使用,也可单独使用。
采集本地媒体
Section titled “采集本地媒体”MediaDevices.getUserMedia() 请求其约束所指定的媒体类型——音频、视频,或两者兼有——
返回的 Promise 会以 MediaStream resolve:
const constraints = { audio: true, video: { width: 1280, height: 720 } };const stream = await navigator.mediaDevices.getUserMedia(constraints);videoElement.srcObject = stream;navigator.mediaDevices.enumerateDevices() 列出可用的输入设备。把某个设备的
deviceId 作为普通约束值传入,只是一种偏好,浏览器可以不遵循;要强制使用该特定
设备,需要传入 { exact: deviceId }。
信令不属于 WebRTC 的一部分
Section titled “信令不属于 WebRTC 的一部分”WebRTC 在建立连接之前,必须先在两个对等方之间交换会话信息——offer、answer 与网络候选 地址——MDN 明确指出这个交换过程需要「某种中间服务器」:WebRTC 本身并不定义这条信令 通道,因此它可以通过应用已有的任何传输方式(WebSocket、既有 API 等)承载。典型流程 如下:
// 两者都是「每次通话」的生命周期状态,所以用 `let` 声明:挂断时会把 `pc` 置回// null,发起下一次通话时两者都会被替换。若用 `const`,这次赋值会抛出// `TypeError: Assignment to constant variable`。let pc = new RTCPeerConnection();// 用一个 controller 统管本次通话注册的所有监听器,挂断时 abort 一次即可// 全部摘除——参见「观察连接状态与挂断」。let callAbort = new AbortController();pc.addTrack(stream.getTracks()[0], stream);
const offer = await pc.createOffer();await pc.setLocalDescription(offer);// 通过你自己的信令通道,把 `offer` 发送给远端对等方。接收方使用该 offer 调用 setRemoteDescription(),创建自己的 answer 并通过同一条信令
通道发回——之后两端的 RTCPeerConnection 会协商彼此之间的媒体/数据流转,往往(但并非
总是)不需要任何服务器来中转媒体本身。
接下来是 ICE 候选地址
Section titled “接下来是 ICE 候选地址”offer 与 answer 决定了使用哪些编解码器,却没有解决数据包如何穿越网络。这由 ICE(交互式
连接建立)负责。每一端通过 icecandidate 事件按发现顺序抛出自己的候选地址,并持续抛出
直到再无可选项——即便媒体已经开始传输也是如此。你要做的只是把每个候选地址通过同一条
信令通道转发出去,并把收到的候选地址交给 addIceCandidate():
pc.addEventListener('icecandidate', ({ candidate }) => { if (candidate) signaling.send({ type: 'new-ice-candidate', candidate });}, { signal: callAbort.signal });
// 接收端:在调用过 setRemoteDescription() 之后await pc.addIceCandidate(incoming.candidate);MDN 把职责说得很直白:在 ICE 协商过程中,你的代码只需在 onicecandidate 处理函数触发时,
接收来自 ICE 层的候选地址并通过信令连接发给对端;再把收到的候选地址消息通过调用
addIceCandidate() 交给自己的 ICE 层。SDP 的内容在几乎所有情况下都与你无关——而对信令服务器
来说,这条消息更是一个它根本无需解读的黑箱。
让浏览器告诉你何时该协商
Section titled “让浏览器告诉你何时该协商”像上面那样手动调用 createOffer() 只是简化版。在 MDN 自己的通话流程里,offer 并不是由定时器
或按钮触发的:当发起方创建好 RTCPeerConnection、创建好媒体流并把轨道添加到连接上之后,
浏览器会向该 RTCPeerConnection 投递一个 negotiationneeded 事件,表示它已准备好与对端开始
协商。由这个事件驱动 offer,才能避免后续的变化——通话中新增的轨道、被重新协商的格式——悄无声息地
漏掉协商:
pc.addEventListener('negotiationneeded', async () => { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: 'video-offer', sdp: pc.localDescription });}, { signal: callAbort.signal });一旦 setLocalDescription() 兑现,ICE agent 就会开始发送上一节讲的 icecandidate 事件。
所谓 WebRTC「播放器」,其实就是一个指向对端发来的那条流的 <video> 元素。当有新轨道被添加到
RTCPeerConnection 上时——无论是因为对端调用了 addTrack(),还是因为流的格式被重新协商——
该连接都会为每一条新增轨道收到一个 track 事件。把收到的媒体挂到某个 HTML 元素上是常见需求,
MDN 给出的处理函数只有一行:
function handleTrackEvent(event) { document.getElementById('received_video').srcObject = event.streams[0]; document.getElementById('hangup-button').disabled = false;}
pc.addEventListener('track', handleTrackEvent, { signal: callAbort.signal });这段代码执行完后,对端正在发送的视频就会显示在本地浏览器窗口中。真正起作用的属性是
HTMLMediaElement.srcObject:它设置或返回作为该元素媒体来源的对象,可接受 MediaStream、
MediaSource、Blob 或 File——因此同一个元素之后也可以被重新指向本地 getUserMedia() 的流,
而不必动 src。
注意这个处理函数从未调用 play()。这并不是因为赋值本身就意味着会播放,而是因为 MDN 示例中
的接收端元素带有 autoplay 属性,所以流一旦挂上去就会自行开始播放。如果你的元素没有该属性,
启动播放就是你的事,并且会受浏览器自动播放策略的约束。
另一件需要处理的事,是那些老到没有 srcObject 的浏览器;MDN 记录的回退方案是 object URL,
并且明确说明这是遗留做法,而不是一种可选风格:
if ('srcObject' in video) { video.srcObject = remoteStream;} else { // 不要在新浏览器中使用这种写法,它正在被淘汰。 video.src = URL.createObjectURL(remoteStream);}RTCPeerConnection 并非一定要承载媒体。RTCDataChannel 是一条用于在对等方之间双向传输
任意数据的网络通道;每条数据通道都归属于某个 peer connection,而每个连接理论上最多可以有
65,534 条数据通道(实际上限因浏览器而异)。发起方调用 createDataChannel();远端则通过
datachannel 事件得知它的存在:
// 发起方const channel = pc.createDataChannel('chat');channel.addEventListener('open', () => channel.send('hello'));
// 接收方pc.addEventListener('datachannel', (event) => { event.channel.addEventListener('message', (e) => render(e.data));}, { signal: callAbort.signal });有两个默认值值得记住。投递默认是有序的(ordered 为 true);二进制数据默认以
ArrayBuffer 形式到达,除非你把 binaryType 设为 'blob'。发送任何数据之前先检查
readyState——只有在它为 open 时通道才可用(其余取值为 connecting、closing、
closed);若要推送大量数据,还应关注 bufferedAmount,它表示仍在排队待发的字节数。
观察连接状态与挂断
Section titled “观察连接状态与挂断”当连接状态发生变化时——包括对端终止通话时——ICE 层会发送 iceconnectionstatechange 事件。
MDN 的示例在 iceConnectionState 变为 "closed" 或 "failed" 时拆除通话,并且刻意不监听
"disconnected":该状态可能只代表暂时性问题,过一段时间后还可能回到 "connected"。示例同时也
监听 signalingState 为 "closed" 的情况,这是为了向后兼容——该取值已被弃用,取而代之的是
iceConnectionState 的 "closed"。此外还有 icegatheringstatechange,它告知候选地址收集过程的
状态变化,可用于调试,也可用于检测收集是否已经结束。
关闭通话不止是调用 close()。MDN 的 closeVideoCall() 会移除连接上的事件处理函数,用
MediaStreamTrack.stop() 停掉远端与本地两条流上的每一条轨道,关闭 RTCPeerConnection,
把对等连接变量置为 null,然后才清除 video 元素的 src/srcObject:
function closeVideoCall() { if (!pc) return; // 摘掉上面所有以 `{ signal: callAbort.signal }` 注册的监听器。 // 注意 MDN 较早的示例是把各个 `on*` 属性置为 null;那只会清掉*通过这些属性* // 赋值的处理函数,用 addEventListener() 注册的监听器依然挂着——残留的 // `negotiationneeded` 回调就可能在本函数已把 `pc` 置为 null 之后仍被触发。 callAbort.abort();
for (const el of [remoteVideo, localVideo]) { if (el.srcObject) el.srcObject.getTracks().forEach((track) => track.stop()); }
pc.close(); // `pc` 与 `callAbort` 就是通话建立时那两个 `let` 绑定。把 `pc` 置为 null 正是上面 // `if (!pc) return` 幂等保护生效的前提:这样第二次挂断——按钮触发一次、 // `iceconnectionstatechange` 再触发一次——就是空操作,而不是又拆一遍。 pc = null;
for (const el of [remoteVideo, localVideo]) { el.removeAttribute('src'); // `srcObject` 是一个不反射到内容属性的 IDL 属性,因此 // removeAttribute('srcObject') 是空操作。只有赋值为 null 才会释放该元素 // 对这条流的引用。 el.srcObject = null; }}
// 发起下一次通话时要同时替换这两个绑定:已经 abort 过的 controller 会一直处于// aborted 状态,复用它会让新注册的监听器在注册瞬间就被摘掉。function startNewCall() { pc = new RTCPeerConnection(); callAbort = new AbortController(); return pc;}有两个细节决定了这段代码到底有没有真的释放资源。监听器必须以注册时同样的方式摘除:如果是用
addEventListener() 注册的,把对应的 on* 属性置为 null 什么也不会移除,所以要么保留具名
回调并调用 removeEventListener(),要么——像上面那样——让所有注册共用同一个 AbortSignal,
挂断时 abort 一次。而元素对流的引用要靠 el.srcObject = null 这次赋值来清除;HTML 标准正是
把这次赋值指定为释放该引用的方式,因为 srcObject 只是一个 IDL 属性,并没有可供
removeAttribute() 操作的内容属性。
AbortSignal 这条捷径比 WebRTC 本身更新
Section titled “AbortSignal 这条捷径比 WebRTC 本身更新”本页通篇使用的「abort 一次全部摘除」并非在所有支持 WebRTC 的浏览器上都可用。MDN 的兼容性
数据把 addEventListener() 的 signal 选项记录为自 Chrome 90、Edge 90、Firefox 86 与
Safari 15 起加入,而 RTCPeerConnection() 构造函数自 2017 年 9 月起就已广泛可用。在低于
这些版本下限的浏览器上,该选项根本不会被识别,于是 callAbort.abort() 什么也摘不掉,本节
所讲的残留回调又会原样回来。如果你的支持范围覆盖到这些更老的版本,就改用具名回调并显式
移除:
// 使用具名回调,使拆除不依赖 `options.signal`。const onIceCandidate = ({ candidate }) => { if (candidate) signaling.send({ type: 'new-ice-candidate', candidate });};pc.addEventListener('icecandidate', onIceCandidate);
// ……然后在 closeVideoCall() 里,于 pc.close() 之前:pc.removeEventListener('icecandidate', onIceCandidate);这条回退路径不会构造或解引用 AbortController;它会保留具名回调,并显式移除监听器。
MDN 把这个流程的目的描述为关闭并重置连接并释放资源——这正是为什么逐轨道的 stop() 调用是
其中的一部分而非可选附加项,也是为什么要先摘掉处理函数:这样才能避免在连接关闭过程中收到残留事件。
根据 MDN 的兼容性数据,RTCPeerConnection() 构造函数具有 Baseline「广泛可用」状态:
该特性自 2017 年 9 月起就已在各主流浏览器中生效。
这比本页示例所用的一个便利写法更早。addEventListener() 的 signal 选项在 MDN 的兼容性
数据中记录为自 Chrome 90、Edge 90、Firefox 86 与 Safari 15 起加入,因此「abort 一次全部
摘除」只在这些版本及以上成立;低于该下限时,请按「AbortSignal 这条捷径比 WebRTC 本身
更新」一节所示,用 removeEventListener() 摘除监听器。
async function startCall(constraints) { if (!('RTCPeerConnection' in window) || !navigator.mediaDevices?.getUserMedia) { // 这条通话流程同时需要 peer connection 与本地采集。 // 两者缺一时,回退到非实时路径(例如上传表单), // 而不是尝试发起通话。 return null; } const stream = await navigator.mediaDevices.getUserMedia(constraints); const connection = new RTCPeerConnection(); stream.getTracks().forEach((track) => connection.addTrack(track, stream)); return connection;}
// 赋值给信令那一节里的通话生命周期变量 `pc`;为 null 表示没有发起通话,// 此时 closeVideoCall() 也没有任何东西需要拆除。pc = await startCall({ audio: true, video: true });- 在发起通话之前,同时对
RTCPeerConnection与navigator.mediaDevices.getUserMedia做特性检测,并在缺失任一项时提供回退路径。 - 只在用户能理解的上下文中调用
getUserMedia()。它需要用户许可,但请求也可能在 不显示权限提示的情况下被拒绝。 - 自行搭建信令通道;WebRTC 不提供信令,只在 offer/answer/candidate 交换完成后 提供连接本身。
- 在创建 offer 之前先向
RTCPeerConnection添加轨道,这样它们才会被包含在协商的 会话中。 - 当需要强制指定特定摄像头或麦克风而非默认设备时,使用
enumerateDevices()配合deviceId: { exact: deviceId }约束。 - 通话看起来已经接通之后,仍要继续转发 ICE 候选地址——媒体开始传输后两端依然会继续发送 候选地址,丢掉后来的那些,可能让你错过更优的候选。
- 把每个收到的候选地址交给
addIceCandidate(),把每个发出的候选地址原样转发——不要试图 解读候选地址的 SDP 字符串。 - 数据通道的
send()要以readyState === 'open'为前提,并关注bufferedAmount, 而不是盲目推送大批数据。 - 用
negotiationneeded事件驱动 offer,而不是只在通话建立时创建一次,这样通话中新增的 轨道或被重新协商的格式才不会漏掉协商。 -
track事件是按每条入站轨道各触发一次,而不是每次通话只触发一次——把每条轨道分别挂到 应当渲染它的元素上,不要假设只会触发一次。 - 远端流优先使用
srcObject;只在浏览器缺少它时才回退到URL.createObjectURL(stream)——MDN 已标明那条路径正在被淘汰。 - 不要在
iceConnectionState === 'disconnected'时就拆除通话——它可能只是暂时性问题, 还可能回到'connected'。应当对'closed'与'failed'作出反应。 - 挂断时要移除连接上的事件处理函数、对两条流上的每条轨道调用
stop()、调用close(), 并清除 video 元素的src/srcObject——仅调用close()并不会释放这些资源。 - 摘除监听器的方式要和注册时一致:把
pc.onnegotiationneeded置为 null,并不会移除addEventListener('negotiationneeded', …)注册的那一个。要么让所有监听器共用同一个AbortSignal并在挂断时 abort,要么保留具名回调以便removeEventListener()。 - 如果你的支持范围低于 Chrome/Edge 90、Firefox 86 或 Safari 15,就不要只依赖
{ signal }——WebRTC 比这个选项更早,不识别它的浏览器会直接忽略它,于是abort()会把所有监听器都留在原处。这个范围内请保留具名回调并使用removeEventListener()。 - 把连接及其
AbortController放在let绑定里而不是const:挂断时要执行pc = null才能让拆除逻辑幂等,而下一次通话需要一个全新的 controller——已 abort 的 那个会让新监听器在注册瞬间就被摘掉。 - 清空媒体元素的流要用
el.srcObject = null,而不是removeAttribute('srcObject')——srcObject没有对应的内容属性,那次removeAttribute()会静默地什么都不做, 流依然被引用着。
- Web 能力索引 —— 本参考中其他与设备、网络相关的浏览器 API。
- WebTransport —— 本参考中记录的另一项浏览器 网络能力。
- 屏幕捕获 —— 页面如何获取显示内容的
MediaStream,是屏幕共享场景中getUserMedia()的搭档。 - WebCodecs —— 更底层地访问 WebRTC 替你协商的那些 编解码器。
- Document Picture-in-Picture ——
如何把播放远端流的
<video>元素移到一个始终置顶的窗口里。
规范
| 规范 | 状态 |
|---|---|
| WebRTC(RTCPeerConnection) | W3C 草案 |
- 图例
- 支持
- 部分支持
- 需开启标志
- 不支持
- 未知
| 浏览器 / 平台 | 支持 | 版本 | 置信度 | 来源 | 备注 |
|---|---|---|---|---|---|
| Chrome (Desktop) | 支持 | 56 | 高 | 来源 | — |
| Chrome (Android) | 支持 | 56 | 高 | 来源 | 1 |
| Edge (Desktop) | 支持 | 15 | 高 | 来源 | — |
| Firefox (Desktop) | 支持 | 44 | 高 | 来源 | — |
| Firefox (Android) | 支持 | 44 | 高 | 来源 | — |
| Safari (macOS) | 支持 | 11 | 高 | 来源 | — |
| Safari (iOS) | 支持 | 11 | 高 | 来源 | 2 |
| Samsung Internet | 支持 | 6.0 | 高 | 来源 | 3 |
| WebView (Android) | 支持 | 56 | 高 | 来源 | 4 |
- 由 browser-compat-data 镜像自 Chrome 的数据推导。
- 由 browser-compat-data 镜像自 Safari 的数据推导。
- 由 browser-compat-data 镜像自 Chrome Android 的数据推导。
- 由 browser-compat-data 镜像自 Chrome Android 的数据推导。