跳转到内容

能力 · API

WebRTC:浏览器中的点对点音视频与数据

发布于 更新于

自 2017-09 起广泛可用W3C 草案

一句话: WebRTC 与 Media Capture and Streams API 一起,为 Web 带来了音视频通话、 文件交换与屏幕共享——建立点对点连接不需要特殊驱动或插件,一旦连接建立,往往也不需要 任何中间服务器。

  • RTCPeerConnection 表示本机与远端对等方之间的一条 WebRTC 连接;一旦建立并打开, 就可以向其添加媒体流和/或数据通道。
  • MediaStreamTrack 表示流中的单条媒体轨道——音频、视频或文本。
  • RTCDataChannel 添加到已打开的 RTCPeerConnection 上,用于在对等方之间直接携带 任意应用数据,可与媒体一起使用,也可单独使用。

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 在建立连接之前,必须先在两个对等方之间交换会话信息——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 会协商彼此之间的媒体/数据流转,往往(但并非 总是)不需要任何服务器来中转媒体本身。

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 的内容在几乎所有情况下都与你无关——而对信令服务器 来说,这条消息更是一个它根本无需解读的黑箱。

像上面那样手动调用 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,它表示仍在排队待发的字节数。

当连接状态发生变化时——包括对端终止通话时——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
  1. 由 browser-compat-data 镜像自 Chrome 的数据推导。
  2. 由 browser-compat-data 镜像自 Safari 的数据推导。
  3. 由 browser-compat-data 镜像自 Chrome Android 的数据推导。
  4. 由 browser-compat-data 镜像自 Chrome Android 的数据推导。

源数据: /compatibility/web-rtc.json · 全球使用占比: 93 % (StatCounter 2026-05)

来源: 规范 · MDN · 最近核验 2026-10-03 · 置信度: 高 (由来源计算)