# WebRTC API

> RTCPeerConnection 的 offer/answer、ICE 与 RTCDataChannel，getUserMedia 约束。配置成员、规范定义的每个异常与带回退的检测示例。

WebRTC 在两个浏览器之间（或浏览器与媒体服务器之间）通过加密 UDP 直接传输音频、视频和任意数据：`RTCPeerConnection` 经 SDP 的 offer/answer 交换协商编解码器与传输，ICE 寻找网络路径，`addTrack()` 挂上 `getUserMedia()` 采集的 `MediaStreamTrack`，`RTCDataChannel` 经 SCTP 传递应用消息。offer、answer 与 ICE candidate 的交换（信令）留给应用自己完成，页面已有的任何通道都行，通常是 WebSocket。

无前缀的 `RTCPeerConnection` 构造函数自 Chrome 56、Edge 15、Firefox 44、Safari 11、Samsung Internet 6 和 Android WebView 56 起可用，因此 Baseline 把它列为 2017-09 起广泛可用。`getUserMedia()` 带 `[SecureContext]`，采集需要 `https://` 或 `localhost`；对等连接本身不要求，但采集不了的页面通常也没什么可发送。

## 语法

```js
new RTCPeerConnection()
new RTCPeerConnection(configuration)

pc.createOffer()
pc.createOffer(options)
pc.createAnswer()
pc.setLocalDescription()
pc.setLocalDescription(description)
pc.setRemoteDescription(description)
pc.addIceCandidate(candidate)

pc.addTrack(track)
pc.addTrack(track, ...streams)
pc.createDataChannel(label)
pc.createDataChannel(label, options)
pc.close()

navigator.mediaDevices.getUserMedia(constraints)
```

`createOffer()` 与 `createAnswer()` 以 `RTCSessionDescriptionInit`（`{ type, sdp }`）兑现；两个设置描述的方法和 `addIceCandidate()` 以 `undefined` 兑现，并在连接的操作链上按调用顺序执行。不带参数调用 `setLocalDescription()` 会按当前 `signalingState` 创建所需的 offer 或 answer。`addTrack()` 同步返回 `RTCRtpSender`，`createDataChannel()` 同步返回 `RTCDataChannel`，`getUserMedia()` 以 `MediaStream` 兑现。

## 参数

构造函数的 `RTCConfiguration`、`createDataChannel()` 的 `RTCDataChannelInit` 和 `getUserMedia()` 的 `MediaStreamConstraints` 是一次典型调用会碰到的字典（见 [RTCConfiguration 成员](https://w3c.github.io/webrtc-pc/#dictionary-rtcconfiguration-members)（w3.org））。

| 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `configuration.iceServers` | `sequence<RTCIceServer>` | 否 | 默认 `[]`。每项含 `urls`（一个或多个 `stun:`/`turn:`/`turns:` URL）以及 `username` 和 `credential`，`turn:` 与 `turns:` 条目必须携带后两者。没有 TURN 服务器时，对称 NAT 后的对等方无法连通。 |
| `configuration.iceTransportPolicy` | `RTCIceTransportPolicy` | 否 | `"all"`（默认）或 `"relay"`；`"relay"` 只使用 TURN candidate，从而隐藏用户 IP。 |
| `configuration.bundlePolicy` | `RTCBundlePolicy` | 否 | `"balanced"`（默认）、`"max-compat"` 或 `"max-bundle"`；控制多个媒体段协商多少条传输。 |
| `configuration.rtcpMuxPolicy` | `RTCRtcpMuxPolicy` | 否 | 规范中只剩 `"require"`；不复用 RTCP 的远端描述会被拒绝。 |
| `configuration.certificates` | `sequence<RTCCertificate>` | 否 | 默认 `[]`。来自 `RTCPeerConnection.generateCertificate()` 的证书，使 DTLS 指纹跨会话稳定。 |
| `configuration.iceCandidatePoolSize` | `octet` | 否 | 默认 `0`。在 `setLocalDescription()` 之前预先收集的 candidate 数。 |
| `options.ordered`（数据通道） | `boolean` | 否 | 默认 `true`。`false` 时消息按到达顺序交付。 |
| `options.maxPacketLifeTime` / `options.maxRetransmits` | `unsigned short` | 否 | 互斥；任一项都把通道变为部分可靠。 |
| `options.negotiated` / `options.id` | `boolean` / `unsigned short` | 否 | `negotiated: true` 配合显式 `id`（0 到 65534）跳过带内通道通告；两端都必须创建该通道。 |
| `constraints.audio` / `constraints.video` | `boolean` 或 `MediaTrackConstraints` | 否 | 至少一项存在且为真值。对象形式接受 `width`、`height`、`frameRate`、`facingMode`、`deviceId`，每项可写裸值（偏好）或 `{ exact }` / `{ ideal }`。 |

## 异常

下列名称来自 webrtc-pc 与 mediacapture-main；`RTCPeerConnection` 的方法以拒绝 Promise 的方式报错，标注「抛出」的除外（带 `errorDetail` 的 SDP 与传输失败见 [RTCError](https://w3c.github.io/webrtc-pc/#rtcerror-interface)（w3.org））。

| 异常 | 条件 |
|---|---|
| `InvalidStateError` | `signalingState` 为 `"closed"` 后调用 `createOffer()`、`createAnswer()`、两个设置描述的方法、`addIceCandidate()`、`addTrack()`（抛出）和 `createDataChannel()`（抛出）；`remoteDescription` 为 `null` 时调用 `addIceCandidate()`；状态不是 `"have-remote-offer"` 或 `"have-local-pranswer"` 时调用 `createAnswer()`。 |
| `InvalidModificationError` | 传给 `setLocalDescription()` 的 offer 或 answer，其 `sdp` 与 `createOffer()`/`createAnswer()` 最近生成的不一致。 |
| `InvalidAccessError` | 构造函数收到过期或跨源证书，或没有 `username` 和 `credential` 的 `turn:` 服务器（抛出）；描述未通过 RFC 9429 语义校验、在 `rtcpMuxPolicy: "require"` 下不复用 RTCP，或重新协商 RID；对同一个 track 调用两次 `addTrack()`（抛出）。 |
| `SyntaxError` | `iceServers` 的某个 URL 无法解析、带路径，或 query 不是 `transport=udp` 或 `transport=tcp`（构造函数与 `setConfiguration()` 抛出）。 |
| `NotSupportedError` | `iceServers` 的某个 URL 使用浏览器未实现的协议（抛出）。 |
| `TypeError` | `addIceCandidate()` 收到非空 `candidate` 但 `sdpMid` 与 `sdpMLineIndex` 都为 `null`；`createDataChannel()` 同时设置 `maxPacketLifeTime` 和 `maxRetransmits`、用 `negotiated: true` 却不给 `id`，或使用 `id: 65535`（抛出）。 |
| `OperationError` | `addIceCandidate()` 的 `sdpMid` 匹配不到任何媒体段、`sdpMLineIndex` 越界，或 `usernameFragment` 与已应用的远端描述不匹配；`createDataChannel()` 发现 `id` 已被占用或耗尽（抛出）；应用描述因上述以外的原因失败；`createOffer()` 无法检查系统的编解码器与资源。 |
| `RTCError` | `DOMException` 的子类，`errorDetail` 指明失败类型：设置描述的方法给出 `"sdp-syntax-error"`（附 `sdpLineNumber`），传输层给出 `"dtls-failure"`、`"fingerprint-failure"`、`"sctp-failure"` 或 `"data-channel-failure"`，经 `RTCDataChannel` 的 `error` 事件送达。 |
| `NotAllowedError`（getUserMedia） | 用户拒绝授权、页面被 `camera`/`microphone` Permissions Policy 禁止，或调用缺少浏览器要求的激活。 |
| `NotFoundError`（getUserMedia） | 不存在所请求类型的设备。 |
| `NotReadableError`（getUserMedia） | 操作系统或其他应用占用着设备。 |
| `OverconstrainedError`（getUserMedia） | 某个 `exact` 约束无法满足；`error.constraint` 给出其名称。 |
| `TypeError` / `SecurityError`（getUserMedia） | `constraints` 既没有 `audio` 也没有 `video`；文档不是 fully active 或上下文不安全。 |

:::observed
`pc.close()` 之后，Chrome 以 `InvalidStateError: Failed to execute 'createOffer' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'.` 拒绝 `pc.createOffer()`。把 `createOffer()` 返回的 SDP 字符串改动后再传给 `setLocalDescription()`，以 `InvalidModificationError: Failed to execute 'setLocalDescription' on 'RTCPeerConnection': The SDP does not match the previously generated SDP for this type` 拒绝。两条消息在 Chromium 的 [`rtc_peer_connection.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.cc)（chromium.googlesource.com）中分别定义为 `kSignalingStateClosedMessage` 与 `kModifiedSdpMessage`。
:::

## 示例

三个示例构成一次完整通话：采集、协商、交换数据，每个都给出缺少支持或权限时所走的分支。

### 采集本地媒体并在约束不满足时回退

把 `RTCPeerConnection` 和 `navigator.mediaDevices` 一起检测；`http://` 页面的 `navigator.mediaDevices` 为 `undefined`。先请求理想分辨率，摄像头满足不了 `exact` 约束时改用最简单的 `video: true` 重试。

```js
async function capture() {
  if (!("RTCPeerConnection" in window) || !navigator.mediaDevices?.getUserMedia) {
    return null; // 改为显示上传表单，而不是实时通话
  }
  try {
    return await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: { exact: "user" } },
    });
  } catch (err) {
    if (err.name === "OverconstrainedError") {
      console.warn(`约束 ${err.constraint} 无法满足，去掉后重试`);
      return navigator.mediaDevices.getUserMedia({ audio: true, video: true });
    }
    if (err.name === "NotAllowedError") return null; // 权限被拒：走无音视频的回退
    throw err;
  }
}
```

桌面端最常触发它的是 `facingMode: { exact: "user" }`，因为网络摄像头根本不报告朝向。

### 由浏览器事件驱动 offer、answer 与 ICE

让 `negotiationneeded` 来创建 offer，这样之后加入的 track 会自动重新协商；把每个 `icecandidate` 经信令通道转发；只在 `setRemoteDescription()` 执行之后才把对端的 candidate 交给 `addIceCandidate()`。

```js
function connect(stream, signaling, iceServers) {
  const pc = new RTCPeerConnection({ iceServers });
  const controller = new AbortController();
  const { signal } = controller;
  for (const track of stream.getTracks()) pc.addTrack(track, stream);

  pc.addEventListener("negotiationneeded", async () => {
    await pc.setLocalDescription(); // 按当前状态创建 offer
    signaling.send({ description: pc.localDescription });
  }, { signal });
  pc.addEventListener("icecandidate", ({ candidate }) => {
    if (candidate) signaling.send({ candidate });
  }, { signal });
  pc.addEventListener("track", ({ streams }) => {
    document.querySelector("#remote").srcObject = streams[0];
  }, { signal });

  signaling.onmessage = async ({ description, candidate }) => {
    if (description) {
      await pc.setRemoteDescription(description);
      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaling.send({ description: pc.localDescription });
      }
    } else if (candidate && pc.remoteDescription) {
      await pc.addIceCandidate(candidate);
    }
  };

  return () => {
    controller.abort();
    for (const track of stream.getTracks()) track.stop();
    pc.close();
  };
}
```

返回的函数就是挂断：中止 signal 一次性移除全部监听器，停止 track 会熄灭摄像头指示灯，`close()` 结束 ICE。`addEventListener()` 的 `signal` 选项需要 Chrome 90、Firefox 86 或 Safari 15；低于这些版本时保留具名处理函数并调用 `removeEventListener()`。

### 通过数据通道收发消息并回退到 WebSocket

数据通道不需要任何媒体。在第一个 offer 之前创建通道，使其在带内被通告；每次 `send()` 前读取 `readyState`，通道只在 `"open"` 时可用。

```js
function openChat(pc, signalingSocket, onMessage) {
  if (!pc || typeof pc.createDataChannel !== "function") {
    signalingSocket.addEventListener("message", (e) => onMessage(e.data));
    return (text) => signalingSocket.send(text); // 经服务器中转
  }
  const channel = pc.createDataChannel("chat", { ordered: false, maxRetransmits: 0 });
  channel.binaryType = "arraybuffer";
  channel.addEventListener("message", (e) => onMessage(e.data));
  channel.addEventListener("error", (e) => console.error(e.error.errorDetail, e.error.message));

  pc.addEventListener("datachannel", ({ channel: incoming }) => {
    incoming.addEventListener("message", (e) => onMessage(e.data));
  });

  return (text) => {
    if (channel.readyState === "open") channel.send(text);
    else signalingSocket.send(text);
  };
}
```

`ordered: false` 配合 `maxRetransmits: 0` 给在线状态或光标位置更新提供类似 UDP 的交付；必须完整、有序到达的聊天文本则两个选项都不要设。

## 另请参阅

- [Screen Capture API](/zh/reference/capabilities/screen-capture/)，屏幕共享 track 的来源 `getDisplayMedia()`
- [WebCodecs](/zh/reference/capabilities/webcodecs/)，帧进入对等连接前的自定义编码
- [WebTransport API](/zh/reference/capabilities/webtransport/)，`RTCDataChannel` 的客户端到服务器替代方案
- [Document Picture-in-Picture](/zh/reference/capabilities/document-picture-in-picture/)，把远端视频保持在置顶窗口中
- [WebRTC: RTCPeerConnection constructor](https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-constructor)（w3.org）
- [WebRTC: addIceCandidate()](https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addicecandidate)（w3.org）
- [Media Capture and Streams: getUserMedia()](https://w3c.github.io/mediacapture-main/#dom-mediadevices-getusermedia)（w3.org）
- [RFC 8445: Interactive Connectivity Establishment (ICE)](https://www.rfc-editor.org/rfc/rfc8445)（rfc-editor.org）
- [WebRTC samples](https://webrtc.github.io/samples/)（webrtc.github.io）