3. RTCPeerConnection 入门:连接状态机、ICE 框架简介、STUN/TURN 服务器配置

好,咱们进入正题。RTCPeerConnection 是整个 WebRTC 的核心,说白了就是浏览器里那个负责点对点连接的对象。你调它的 API,它帮你搞定 NAT 穿透、加密传输、音视频数据收发。我刚开始接触时也觉得它像个黑盒,但摸清它的状态机和 ICE 流程后,其实没那么玄乎。

3.1 连接状态机:从 New 到 Closed 的完整旅程

RTCPeerConnection 内部维护了一套状态机,用来描述连接的生命周期。你想想看,一个连接从创建到销毁,中间要经历信令交换、候选收集、连通性检测,每一步都有对应的状态。

我习惯把状态机分成两个维度来看:连接状态(connectionState)和 ICE 连接状态(iceConnectionState)。前者是更高层的抽象,后者更贴近 ICE 协议本身。实际开发中,我主要监听 connectionstatechange 事件。

状态 含义 触发时机
new 连接刚创建,还没开始 ICE 调用构造函数后
connecting 正在收集候选、进行连通性检查 调用 setLocalDescription 后
connected 至少一条候选路径可用 ICE 连通性检查通过
disconnected 连接暂时中断,但还在尝试恢复 ICE 超时或丢包严重
failed 连接彻底失败,无法恢复 ICE 检查全部超时
closed 连接已关闭 调用 close()

核心要点: disconnected 和 failed 的区别在于——前者还有救,后者没救了。我曾经在项目中遇到网络闪断,状态在 disconnected 和 connected 之间来回跳,这时候千万别直接关连接,给它一点恢复时间。

3.2 ICE 框架简介:它到底在干什么?

ICE(Interactive Connectivity Establishment)是 WebRTC 的穿透引擎。它的任务只有一个:帮两个端点找到一条能通的路。你想想看,两个设备可能都在 NAT 后面,没有公网 IP,怎么找到对方?

ICE 的流程可以概括为三步:

  1. 收集候选地址:每个端点收集自己可能的 IP 和端口组合,包括本地地址、STUN 反射地址、TURN 中继地址。
  2. 交换候选:通过信令通道把候选列表发给对端。
  3. 连通性检查:两端互相发 STUN 绑定请求,测试哪一对候选能通。

我画了一张图,帮你把 ICE 框架的层次理清楚:

ICE 框架结构图 ICE 框架核心层次 应用层(WebRTC API) ICE 层(连通性检查 & 候选管理) 候选收集 → 优先级排序 → 连通性检查 → 选定路径 传输层(UDP / TCP / TLS) 候选类型(Candidate Types) host(本地地址) srflx(反射地址) relay(中继地址) ICE 按优先级从高到低尝试:host → srflx → relay

我的经验: 候选优先级是 ICE 选路的关键。host 候选优先级最高,relay 最低。但别以为 host 一定最快——有些局域网环境虽然能通,但延迟反而比公网中继高。我在一个跨国项目中就遇到过这种情况,最后强制走 TURN 反而更稳定。

3.3 STUN / TURN 服务器配置

STUN 和 TURN 是 ICE 的左右手。STUN 帮你拿到公网反射地址,TURN 则是在 P2P 不通时当中继。配置其实很简单,就是往 iceServers 数组里塞 URL。

const config = {
  iceServers: [
    {
      urls: 'stun:stun.l.google.com:19302'
    },
    {
      urls: 'turn:turn.example.com:3478',
      username: 'user',
      credential: 'pass'
    }
  ]
};

const pc = new RTCPeerConnection(config);

这里有几个坑,我一个个说:

  • STUN 服务器不需要认证,但 TURN 必须要有用户名和密码。我曾经见过有人把 STUN 的配置也加了 credential,结果浏览器直接报错。
  • URL 协议要写对:STUN 用 stun: 前缀,TURN 用 turn: 或 turns:(TLS 加密)。
  • 端口别搞错:STUN 默认 3478,TURN 也是 3478,但有些服务商用 5349 做 TLS 端口。

避坑指南: 我曾经在生产环境只配了一个 STUN 服务器,结果有 30% 的用户连不上——因为他们都在对称 NAT 后面,STUN 拿到的反射地址根本没用。后来加了 TURN 服务器,连接成功率才提到 99% 以上。记住:生产环境必须配 TURN,别省这个钱。

3.4 ICE 候选收集与连通性检查

当你调用 setLocalDescription 后,浏览器会自动开始收集候选。每个候选会通过 onicecandidate 回调告诉你。你需要把这些候选通过信令通道发给对端。

pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 通过信令通道发送给对端
    signalingChannel.send({
      type: 'candidate',
      candidate: event.candidate
    });
  }
};

对端收到后,调用 addIceCandidate 添加进来。ICE 层会维护一个候选对列表,然后逐一发送 STUN 绑定请求做连通性检查。

我习惯把候选收集的过程想象成「撒网」——你撒出去的候选越多,捞到鱼的概率越大。但也不是越多越好,候选太多会导致连通性检查时间变长。实际项目中,我一般会设置 iceCandidatePoolSize 来控制并发数。

候选类型 优先级 典型延迟 适用场景
host 最高 < 1ms 局域网内直连
srflx 中 10-50ms 公网直连(非对称 NAT)
relay 最低 50-200ms 对称 NAT / 防火墙限制

关键点: ICE 连通性检查是并发的,不是串行的。浏览器会同时尝试多个候选对,谁先通就用谁。所以即使你配了 TURN,只要 host 或 srflx 能通,就不会走中继——这能省下不少带宽费用。

3.5 状态机实战:监听连接变化

最后,我建议你在项目中一定要监听连接状态的变化,并做相应的处理。下面是我常用的模板:

pc.onconnectionstatechange = () => {
  switch (pc.connectionState) {
    case 'connected':
      console.log('连接已建立');
      break;
    case 'disconnected':
      console.warn('连接中断,尝试恢复...');
      // 可以启动重连计时器
      break;
    case 'failed':
      console.error('连接失败,需要重建');
      // 关闭旧连接,创建新连接
      pc.close();
      createNewConnection();
      break;
    case 'closed':
      console.log('连接已关闭');
      break;
  }
};

嗯,这里要注意:disconnected 状态触发后,浏览器会尝试恢复,默认超时是 30 秒。如果 30 秒内没恢复,就会变成 failed。我在项目中遇到过一种情况——用户切换了 Wi-Fi,状态从 connected 跳到 disconnected,然后又跳回 connected,整个过程不到 5 秒。所以别在 disconnected 时立刻重建连接,给它一点时间。

RTCPeerConnection 的状态机、ICE 框架和 STUN/TURN 配置,这三块是 WebRTC 的基石。你把这些搞清楚了,后面处理媒体流、数据通道、重连策略都会顺手很多。说白了,连接都建不起来,后面全是白搭。