三年前的园区巡检项目,甲方在验收现场问了一句:"为什么我在对讲里说完一句话,要等两秒才看到对面的人点头?"当时团队用的是 HLS 拉流,切片四秒一个,延迟压在六秒左右已经是极限了。那次之后,我们把视频链路整个推倒重做,换成了 WebRTC,延迟直接降到了两百毫秒以内。如果你正在做一个 Vue 项目,需要让两端甚至多端"看得见、说得上、反应快",那这套组合基本绕不开。这篇文章不讲概念科普,只讲我在 Vue 工程里把 WebRTC 音视频直播真正跑起来的过程:信令怎么设计、响应式为什么会把流搞坏、一对多怎么扩展、连不上时该怎么一步步排查。适合已经会写 Vue、但对实时通信还比较陌生的人,也适合做过 WebRTC 但在 Vue 里踩过坑的人对照着看。

1. 延迟这道门槛,把 Vue 项目逼向了 WebRTC

1.1 m3u8 能解决分发,但解决不了"对话"

大多数人第一次做直播,脑子里冒出来的方案都是 m3u8。原因很实在:资料多、CDN 支持好、后端只需要一个转码服务把 RTMP 推流切成 TS 切片,前端拿 hls.js 或者 video.js 一挂就能播。我在早期项目里也是这么干的,流程跑通得很快。

但 m3u8 的本质是"把连续视频切成一个个小文件,通过 HTTP 分发"。这个设计天生对"分发"友好,对"实时"不友好。切片时长决定了延迟下限:主流配置是 4 到 6 秒一个切片,播放器为了保证不断流,还会预缓冲 2 到 3 个切片。算下来,从主播张嘴到观众听见,中间隔着 6 到 15 秒是常态。哪怕换成低延迟 HLS,把切片压到 1 秒以内、用 HTTP/2 推送分块传输,延迟也就压到 3 秒左右,而且对 CDN 配置要求很高,一旦某个环节没配好,回退到普通 HLS 是分分钟的事。

关键在于, 这个延迟是架构决定的,不是你优化代码能解决的 。切片轮询、播放器缓冲、TCP 重传,每一环都在往里加时间。做点播、做大型活动直播,这个延迟完全能接受;但只要场景里出现"我问一句、你答一句",它就废了。

1.2 WebRTC 的快来自三个底层取舍

WebRTC 之所以能把延迟压到几百毫秒,不是因为它有什么黑科技,而是它在三个地方做了明确的取舍。

第一是 传输层选 UDP 而不是 TCP 。视频流最怕的不是丢一两个包,而是"等一个包等到天荒地老"。TCP 的可靠传输保证有序、不丢,代价就是一个包丢了,后面所有包都得排队等它重传。WebRTC 用的是 RTP over UDP,丢失的包基本就是丢了,解码器用前向纠错和丢包隐藏去补,画面可能糊一下,但时间轴不会卡住。对于实时通话,这是唯一合理的选择。

第二是 端到端直连或者最短路径中转 。传统直播是"主播→服务器→CDN→观众",中间经过的节点越多,累加延迟越大。WebRTC 的信令只负责交换地址信息,真正的媒体流走的是点对点通道,即使是多人场景用 SFU 转发,中间也只有一跳。路径短,延迟自然小。

第三是 编码器为实时优化 。HLS 里的 H.264 通常用较大的 GOP,一个关键帧间隔两秒甚至四秒,为的是压缩率。WebRTC 会主动调整 GOP 长度、开启低延迟编码模式,牺牲一点压缩效率换时间。同时它的拥塞控制算法会持续探测带宽,动态调整码率,网络一变差立刻降码率而不是拼命重传。

理解这三点,你就能判断自己的场景到底该不该用 WebRTC。

1.3 什么情况下别硬上 WebRTC

这几年我见过不少团队,一听说 WebRTC 延迟低就往里冲,最后被成本和复杂度劝退。有几个典型的"不适合"场景,提前知道能省很多事。

场景特征 更适合的方案 原因
单场观众过万,纯观看 HLS / LL-HLS WebRTC 每条连接都要独立维护,服务器成本和带宽成本随人数线性上涨
需要回看、录制归档 推流到服务端转 HLS WebRTC 直播本身不留档,录制要靠服务端旁路
单向播报,不需要互动 WebRTC 推流 + HLS 拉流 推流端用 WebRTC 保证上传实时性,观众端用 HLS 保证规模
网络环境极差,丢包 30% 以上 需要中继节点兜底 直连基本不可能成功,全部流量走中继,成本翻倍
只需要传文字和状态 WebSocket / SSE 完全没必要动用媒体通道

我自己的判断标准很简单: 如果这个场景里,"延迟"是核心体验指标,且同时在线人数在几百以内,WebRTC 就是最优解;反之先想想别的路 。小班课、远程指导、在线问诊、客服视频、多人协作白板,这些都是 WebRTC 的主场。

2. 信令层没有标准,这是整个项目里唯一必须自己动手的地方

2.1 先把 SDP 和 ICE Candidate 这两段文本看懂

WebRTC 最反直觉的地方在于:它管传输,但不管"两边怎么找到对方"。这个"找对方"的过程叫信令,而信令的协议、格式、通道,WebRTC 规范一个字都没规定。这意味着你必须自己搭一个服务,让两端能互相传话。

传的话主要就两种。第一种是 SDP(Session Description Protocol) ,本质是一段结构化的纯文本,描述了"我这边支持什么编码、用什么传输、有几个媒体流"。发起方调用 createOffer() 生成一份 SDP,通过信令发给对方;对方 setRemoteDescription() 收下,再调用 createAnswer() 生成自己的 SDP 回传。这段文本看起来吓人,实际只需要关注几行:

m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=rtpmap:98 H264/90000
a=sendrecv

m=video 那行声明了这是视频流,后面跟的是候选编码的 payload type。 a=rtpmap 把编号映射到具体编码器。 a=sendrecv 表示这条流是双向的,改成 sendonly 就是我发不收、 recvonly 就是我收不发。做"主播只发、观众只收"的分离时,靠的就是改这里。

第二种是 ICE Candidate 。SDP 描述的是"我支持什么",Candidate 描述的是"我可能从这个地址被联系上"。每端都会收集一组候选地址:本机内网地址(host)、经 STUN 服务器探测出的公网映射地址(srflx)、经 TURN 服务器分配的中继地址(relay)。双方把各自的候选地址互相发过去,然后两两组合尝试连通,谁先通就用谁。这个过程叫连通性检查,一般是几百毫秒到几秒。

// 收集候选地址,每收到一个就通过信令发出去
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signaling.send({
      type: 'ice-candidate',
      candidate: event.candidate.candidate,
      sdpMid: event.candidate.sdpMid,
      sdpMLineIndex: event.candidate.sdpMLineIndex
    })
  }
}

提示: onicecandidate 在收集结束时还会触发一次,此时 event.candidate 是 null,这是一个"收集完成"的信号。如果你用 addIceCandidate 收到 null 会直接报错,所以一定要先判断。

2.2 用 WebSocket 搭一个最小可用信令服务

生产环境里信令服务的实现语言无所谓,Node、Go、Java 都行,核心要求只有两个: 能按房间广播,能保证消息顺序不乱 。WebSocket 天然满足,所以我一般直接用它。

下面这个 Node 版本大概六十行,能覆盖小规模场景的全部需求:

// server.js
import { WebSocketServer } from 'ws'

const rooms = new Map()  // roomId -> Set<ws>

const wss = new WebSocketServer({ port: 8080 })

wss.on('connection', (ws) => {
  let currentRoom = null
  let clientId = null

  ws.on('message', (raw) => {
    let msg
    try {
      msg = JSON.parse(raw.toString())
    } catch (e) {
      return
    }

    if (msg.type === 'join') {
      currentRoom = msg.roomId
      clientId = msg.clientId
      if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set())
      const members = rooms.get(currentRoom)

      // 通知房间里已有的成员:来新人了
      members.forEach((client) => {
        if (client.readyState === 1) {
          client.send(JSON.stringify({ type: 'peer-joined', clientId }))
        }
      })

      members.add(ws)
      // 把现有成员列表回给新人,让它知道该主动发起连接
      ws.send(JSON.stringify({
        type: 'room-joined',
        members: [...members].length - 1
      }))
      return
    }

    // 其余消息(offer / answer / ice-candidate)直接广播给同房间其他人
    if (currentRoom && rooms.has(currentRoom)) {
      rooms.get(currentRoom).forEach((client) => {
        if (client !== ws && client.readyState === 1) {
          client.send(JSON.stringify({ ...msg, from: clientId }))
        }
      })
    }
  })

  ws.on('close', () => {
    if (!currentRoom || !rooms.has(currentRoom)) return
    const members = rooms.get(currentRoom)
    members.delete(ws)
    members.forEach((client) => {
      if (client.readyState === 1) {
        client.send(JSON.stringify({ type: 'peer-left', clientId }))
      }
    })
    if (members.size === 0) rooms.delete(currentRoom)
  })
})

这里有几个设计决定值得说一下。 为什么用房间广播而不是点对点定向转发 :小规模场景下广播的额外开销可以忽略,代码量却能少一半,而且天然支持后续扩展成多人。 为什么不在服务端保存连接状态 :信令服务应当尽量无状态,一旦在内存里维护复杂的连接拓扑,横向扩容就会变得很麻烦。 为什么 peer-joined 和 room-joined 要分开 :新人需要知道"我要主动发起 offer",老人只需要知道"有人来了,等他发过来就行",这两个语义不一样,混在一起容易写出双方同时发 offer 的死锁。

2.3 消息协议设计,别让它变成后期返工点

信令消息的字段设计是我踩过坑的地方。第一版我只设计了 type 和一个 payload,结果加到第三个功能的时候就发现没法区分"这条消息属于哪条连接"。后来改成下面这样,稳定用了很久:

字段 类型 说明
type string join / offer / answer / ice-candidate / leave
roomId string 房间标识,加入时必须带
clientId string 发送方唯一标识,前端生成 UUID
to string 可选,定向发送时指定目标 clientId
payload object 具体内容,放 sdp 或 candidate

clientId 一定要在前端生成并保持稳定,不要依赖服务端分配。原因是重连时你需要用同一个 ID 重新进房间,服务端如果重新分配 ID,其他端就认不出你了。另外 to 字段虽然当前版本用广播实现,但协议里先留着,等人数上去切换到定向转发时,前端代码一行都不用改。

3. Vue 工程侧的准备:从建项目到拿到摄像头流

3.1 脚手架和依赖的真实取舍

有个常见的误解:以为 WebRTC 需要装一堆包。 实际上 getUserMedia 、 RTCPeerConnection 、 RTCDataChannel 全都是浏览器原生 API,一个依赖都不用装 。你只需要一个能跑起来的 Vue 工程和一个 WebSocket 客户端。

# 创建工程,用 Vite 比 Vue CLI 快很多
npm create vite@latest rtc-live -- --template vue

cd rtc-live
npm install
npm install ws   # 只有写本地信令服务时才需要

npm run dev

那 simple-peer 、 peerjs 这些库是干什么的?它们把 RTCPeerConnection 的调用包了一层事件化的接口,写起来确实短一些。但我个人的建议是 第一版直接用原生 API 。原因是这些封装库在需要精细控制的时候会变成障碍:比如你想手动设置码率、想加 simulcast、想读 getStats() 的原始数据,都得绕回原生对象。等你把原生流程走通一遍,再决定要不要用库会更从容。

还有一点容易被忽略:开发环境必须是 https 或者 localhost ,否则浏览器会直接拒绝摄像头权限。Vite 默认跑在 localhost 上没问题,但如果要用手机连你电脑测试,就必须配 HTTPS 证书。这个坑我在第一次做移动端调试时卡了整整一个下午。

3.2 getUserMedia 的约束参数怎么写才不翻车

getUserMedia 的参数看着简单,实际能玩出很多花样。最基本的一版:

const constraints = {
  audio: {
    echoCancellation: true,   // 回声消除,对讲场景必开
    noiseSuppression: true,   // 降噪
    autoGainControl: true     // 自动增益
  },
  video: {
    width: { ideal: 1280 },   // 用 ideal 而不是 min/max
    height: { ideal: 720 },
    frameRate: { ideal: 25, max: 30 },
    facingMode: 'user'        // 移动端前置摄像头
  }
}

const stream = await navigator.mediaDevices.getUserMedia(constraints)

这里最关键的一点: 尺寸和帧率要用 ideal 而不是 exact 。用 exact 意味着"必须给我这个规格",一旦设备不支持,整个调用直接失败抛出 OverconstrainedError 。用 ideal 是"我倾向于这个规格",设备会返回最接近的。做兼容性的时候,这个差别能决定你的页面在旧设备上是不是白屏。

facingMode 在移动端要特别注意。不指定的话,很多安卓设备会返回后置摄像头,用户看到自己拍的是桌面,体验很怪。如果要做前后摄切换,不能靠改约束重新获取——那样会重新申请权限、重新协商,体验很割裂。正确做法是用 enumerateDevices() 列出所有摄像头,用 deviceId 切换:

async function switchCamera(deviceId) {
  // 先停掉旧的轨道,再拿新的
  stream.getVideoTracks().forEach(t => t.stop())

  const newStream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: deviceId }, width: { ideal: 1280 } },
    audio: false
  })

  const newTrack = newStream.getVideoTracks()[0]
  const sender = pc.getSenders().find(s => s.track?.kind === 'video')
  // replaceTrack 不会触发重新协商,这是它的核心价值
  await sender.replaceTrack(newTrack)
}

replaceTrack 是这里的关键 API。它能在不重新协商 SDP 的前提下换掉正在发送的轨道,切换瞬间对面几乎无感。如果用 removeTrack 加 addTrack ,就得走一遍完整的 offer/answer,中间会有明显黑屏。

3.3 把流挂到 video 标签上的三个细节

这段代码看起来只有几行,但每一行都有讲究:

<template>
  <video
    ref="localVideoRef"
    autoplay
    muted
    playsinline
  ></video>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const localVideoRef = ref(null)
let localStream = null

onMounted(async () => {
  localStream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
  })
  // 用 srcObject 而不是 src
  localVideoRef.value.srcObject = localStream
})
</script>

autoplay 和 muted 必须同时存在 。浏览器的自动播放策略规定:没有用户交互的情况下,只有静音的媒体才能自动播放。本地预览如果被自己的麦克风采集到会形成尖锐的回啸,所以本地必须静音,这刚好和自动播放策略一致。远端视频则需要在用户点击"接听"之类的操作之后播放,或者也先静音再提示用户手动开声音。

playsinline 在 iOS 上是必需的 。不加这个属性,Safari 会自动把视频切成全屏播放,一个页面里嵌多路视频的布局会彻底崩掉。这个属性看起来不起眼,但它是移动端能不能正常显示的分水岭。

用 srcObject 而不是 src 。 src 需要的是一个 URL,你得用 URL.createObjectURL(stream) 转一下,而这个方法早就被废弃了,有些浏览器上已经不支持。 srcObject 直接接收 MediaStream 对象,是现在的标准做法。

时序上还有一个细节:一定要在 onMounted 之后再赋值。如果写成 onMounted 之外或者在 setup 顶层执行, localVideoRef.value 还是 null,赋值会直接抛错。如果流是异步拿到的,拿到的时机可能晚于组件挂载,那就更要在赋值前判断 ref 是否存在。

4. Vue 响应式与 MediaStream 的冲突,我踩过最久的那个坑

4.1 被 Proxy 包住的流,为什么画面是黑的

这是我在这个项目里耗时最长的 bug,值得单独讲。

第一版的代码写得很"Vue":把所有状态都塞进 reactive 对象里,包括流和连接实例。

// 错误示范
const state = reactive({
  localStream: null,
  remoteStream: null,
  pc: null
})

结果就是:权限拿到了, getUserMedia 返回了正常的 MediaStream,控制台能看到它有两条轨道,但 video 标签死活是黑的。更诡异的是,某些浏览器上 pc.createOffer() 会直接抛出一个莫名其妙的错误,堆栈里完全看不出原因。

根因在于 Vue 3 的 reactive 基于 Proxy 做深度代理 。当你把一个对象放进 reactive ,它和它内部的所有嵌套对象都会被包一层 Proxy。而 MediaStream 、 RTCPeerConnection 这些都是 宿主对象 ,它们的内部行为由浏览器引擎实现,很多方法依赖对象内部的"内部槽"来工作。一旦这些对象被 Proxy 包裹,方法调用时的 this 指向了 Proxy 而不是原对象,内部槽取不到,于是行为变得不可预测。

这个问题的隐蔽之处在于它 不是稳定复现的 。Chrome 上可能只是 srcObject 赋值失败,Safari 上可能是 addTrack 报错,Firefox 上又表现得正常。你写代码的时候测试通过,上线之后用户报过来的问题千奇百怪,排查方向全被带偏了。

4.2 三种正确的存法,按场景选

解决思路是: 把这类宿主对象放在响应式系统之外 。具体有三种做法,效果略有差别。

第一种是用 shallowRef 。只对 .value 这一层的赋值做响应,不会深入到对象内部:

import { shallowRef } from 'vue'

const localStream = shallowRef(null)
const remoteStream = shallowRef(null)
const pc = shallowRef(null)

// 赋值之后组件能感知到变化,但内部对象保持原样
localStream.value = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })

第二种是用 markRaw ,明确告诉 Vue"这个对象不要代理":

import { reactive, markRaw } from 'vue'

const state = reactive({
  localStream: markRaw(stream),
  pc: markRaw(new RTCPeerConnection(config))
})

第三种最简单也最彻底: 用一个普通变量存,不进响应式 。

// 模块作用域或 setup 作用域内的普通变量
let localStream = null
let pc = null

那什么时候需要响应式?我的经验是: 只有当这个对象的变化需要驱动模板渲染时,才用 shallowRef 。比如视频轨道是否开启、当前是否是静音状态,这些需要更新 UI,用 shallowRef 包一个布尔值就够了。而 RTCPeerConnection 实例本身从来不需要驱动渲染,用普通变量最合适。

存法 是否触发响应 适用对象
ref / reactive 是,深度代理 普通 JSON 数据、UI 状态
shallowRef 是,浅层 需要驱动渲染的流对象
markRaw 否 混在 reactive 里的宿主对象
普通变量 否 PC 实例、定时器、各种句柄

4.3 组件卸载时的资源回收清单

直播类应用如果回收做得不干净,用户切换几次页面就会发现摄像头指示灯一直亮着、风扇狂转、甚至电脑卡死。这份清单我建议直接抄。

onBeforeUnmount(() => {
  // 1. 停止所有本地轨道,这是释放摄像头的唯一方式
  localStream?.getTracks().forEach(track => track.stop())

  // 2. 关闭所有连接实例
  peers.forEach(peer => peer.close())
  peers.clear()

  // 3. 清空 video 标签的引用,避免内存泄漏
  if (localVideoRef.value) localVideoRef.value.srcObject = null

  // 4. 注销所有事件监听
  pc?.removeEventListener('icecandidate', onIceCandidate)
  pc?.removeEventListener('track', onTrack)
  pc?.removeEventListener('connectionstatechange', onConnectionStateChange)

  // 5. 关闭信令连接,并发送离开消息
  signaling?.send({ type: 'leave', clientId })
  signaling?.close()

  // 6. 清理所有定时器
  clearInterval(statsTimer)
  clearTimeout(reconnectTimer)
})

我要强调第 1 条: track.stop() 是释放摄像头的唯一途径 。把 srcObject 设成 null、把组件销毁、把变量置空,这些操作都不会让摄像头灯熄灭。只有对每个 track 显式调用 stop() ,浏览器才会真正释放硬件资源。这一条在开发阶段特别容易被忽略,因为开发时你总是刷新页面,浏览器刷新会强制释放一切,问题就被掩盖了。

第 5 条也值得多说一句。只关闭 WebSocket 而不发 leave 消息,服务端要等 TCP 超时才能感知到你掉线,这段时间里别的用户看到的你依然"在线",但他发给你的 offer 全部石沉大海,界面上表现为"连接中"卡住不动。主动发一条 leave 能让服务端立刻广播 peer-left ,其他端可以实时更新列表。

5. 从一对一扩展到多人直播,架构上的分水岭

5.1 Mesh、SFU、MCU 三种拓扑的代价在哪

一对一很简单,两个人一条连接就完事。一旦人数变成三及以上,就得选拓扑结构了。这三种方案我在不同项目里都用过,各自的边界很清楚。

Mesh(网状) :每个人都和其他所有人建立一条独立连接。四个人就是每人三条上行、三条下行。优点是不需要任何媒体服务器,架构最简单,成本最低。缺点是上行带宽消耗是 (N-1) 倍,六个人以上时手机端基本就扛不住了,而且每个人要维护多个编码器实例,CPU 占用飙升。 适用边界是四人以内的小班互动 。

SFU(选择性转发) :每个人都只和服务器建一条连接,上行推一路流到服务器,服务器根据订阅关系转发给其他人。上行带宽从 (N-1) 倍降到 1 倍,这是最大的收益。服务器压力在于转发,但转发不需要解码重编码,只是搬数据包,一台普通服务器能扛几百路。延迟增加只有一跳,通常在 50 毫秒以内。 这是目前绝大多数商用方案的选择 ,开源的 mediasoup、Janus、LiveKit 都是这个路子。

MCU(多点控制单元) :服务器把所有流解码,混成一路画面再编码下发。好处是下行只占一路带宽,客户端压力极小。坏处是服务器必须有强大的转码能力,延迟也更高,而且布局被服务器定死,用户想单独放大某个人做不到。 适合网络极差或者需要录制合成的场景 。

维度 Mesh SFU MCU
上行带宽 随人数线性增长 恒定一路 恒定一路
服务端成本 无 中,只转发 高,需转码
延迟增加 最低 一跳,约 50ms 两跳以上
布局灵活性 客户端完全自由 客户端完全自由 服务端决定
推荐人数 2 到 4 4 到数百 数十
实现难度 低 中 高

我的建议:如果现在只是做 demo 或者内部工具,人数明确不会超过四个,Mesh 最省事。一旦预期人数会涨, 直接上 SFU,不要等 Mesh 撑不住再重构 ,那个重构成本会高得让你怀疑人生。

5.2 用 Vue 组合式函数封装连接管理器

无论选哪种拓扑,代码组织上我都建议把 WebRTC 的逻辑抽成一个独立的组合式函数,让组件只管 UI。这样做的价值在测试和复用时特别明显。

// composables/usePeerConnection.js
import { shallowRef, ref } from 'vue'

export function usePeerConnection(config) {
  const remoteStream = shallowRef(null)
  const connectionState = ref('new')

  let pc = null

  function create() {
    pc = new RTCPeerConnection({
      iceServers: config.iceServers,
      iceTransportPolicy: 'all',
      bundlePolicy: 'max-bundle'
    })

    pc.onconnectionstatechange = () => {
      connectionState.value = pc.connectionState
      if (pc.connectionState === 'failed') {
        // 失败时尝试重启 ICE,比重新协商便宜
        pc.restartIce()
      }
    }

    pc.ontrack = (event) => {
      remoteStream.value = event.streams[0]
    }

    pc.onicecandidate = (event) => {
      if (event.candidate) {
        config.onIceCandidate(event.candidate)
      }
    }

    return pc
  }

  async function createOffer() {
    const offer = await pc.createOffer()
    await pc.setLocalDescription(offer)
    return offer
  }

  async function acceptOffer(offer, stream) {
    await pc.setRemoteDescription(new RTCSessionDescription(offer))
    stream.getTracks().forEach(track => pc.addTrack(track, stream))
    const answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    return answer
  }

  async function acceptAnswer(answer) {
    if (pc.signalingState === 'stable') return
    await pc.setRemoteDescription(new RTCSessionDescription(answer))
  }

  function close() {
    pc?.close()
    pc = null
  }

  return {
    remoteStream,
    connectionState,
    create,
    createOffer,
    acceptOffer,
    acceptAnswer,
    close,
    get raw() { return pc }
  }
}

这段代码里有三个细节是我加过血的。第一, bundlePolicy: 'max-bundle' 把音频和视频复用到同一个传输通道上,能减少一次 ICE 协商,建连速度明显快一些。第二, connectionState 用 ref 包而不封装成对象,是因为它就是个字符串,模板里直接绑定很方便。第三, acceptAnswer 里那个 signalingState 判断 ,能防住一类很隐蔽的 bug:如果 offer 和 answer 因为网络乱序到达,重复调用 setRemoteDescription 会让状态机直接崩掉,加这行判断就稳了。

5.3 主播只发不收、观众只收不发

做一对多直播时,主播端和观众端的媒体方向是不一样的。如果两边都无脑 addTrack ,主播会收到一堆自己的画面,观众之间也在互相推送,带宽白白浪费。

正确做法是在创建 offer 时显式指定方向:

// 主播端:只发不收
const offer = await pc.createOffer({
  offerToReceiveAudio: false,
  offerToReceiveVideo: false
})
await pc.setLocalDescription(offer)
// SDP 里会出现 a=sendonly
// 观众端:只收不发,不调用 addTrack
const offer = await pc.createOffer({
  offerToReceiveAudio: true,
  offerToReceiveVideo: true
})

生成出来的 SDP 里,主播端是 a=sendonly ,观众端是 a=recvonly 。这两个方向一旦明确,SFU 就只需要做单向转发,服务器负载能降一大截。观众想连麦的时候,再走一次完整的重新协商把方向改成 sendrecv ,或者更常见的做法是让观众和主播之间单独建一条连接,避免影响其他观众。

6. 连不上时的完整排查链路

6.1 先按症状分类,别乱试

WebRTC 的失败特别容易让人抓瞎,因为"没画面"这一个现象背后可能是五六个完全不同的原因。我的习惯是先把症状归类,再按对应的路径查。

症状 最可能的原因 第一步查什么
本地预览就是黑的 摄像头没拿到或权限被拒 getUserMedia 是否 resolve
双方 connectionState 一直是 new offer/answer 没送达 信令消息有没有发出去
状态从 checking 停在不动 ICE 穿透失败 getStats() 里有几条候选
连上了但对方黑屏 track 没加进去或方向错了 SDP 里是 sendrecv 还是 recvonly
有声音没画面 视频编码协商失败 SDP 里有没有共同的视频编码
几秒后自动断流 长时间无媒体或 ICE 超时 iceConnectionState 的变化过程

这个表最好打印出来贴显示器旁边。我见过太多人一上来就怀疑 TURN 服务器,结果查了半天发现是信令消息根本没发出去。

6.2 用 webrtc-internals 看清整条链路

Chrome 有一个内置的调试页面,地址栏直接输入 chrome://webrtc-internals ,任何使用 WebRTC 的标签页的连接细节都会实时显示在这里。这是排查过程中最有力的工具,没有之一。

进去之后重点看三个地方。 第一个是 SDP 的原始文本 ,展开 setLocalDescription 和 setRemoteDescription 的记录,看 offer 和 answer 的 m= 行有没有对齐。如果一方只支持 H264、另一方只支持 VP8,两边的 m=video 里没有重叠的 payload type,协商就会失败。 第二个是 ICE candidate 列表 ,正常情况应该能看到 host、srflx 至少各一条,如果只有 host 没有任何 srflx,说明 STUN 服务器没配好,内网之外根本连不上。 第三个是 connectionState 的时间线 ,正常流程是 new → connecting → connected ,每一步大概几百毫秒。如果卡在 connecting 超过五秒,就是穿透失败。

除了这个页面,代码里读 getStats() 也很关键:

async function collectStats(pc) {
  const stats = await pc.getStats()
  stats.forEach(report => {
    if (report.type === 'candidate-pair' && report.state === 'succeeded') {
      console.log('实际使用的候选对:', report.localCandidateId, '->', report.remoteCandidateId)
      console.log('往返时延 RTT:', report.currentRoundTripTime)
    }
    if (report.type === 'inbound-rtp' && report.kind === 'video') {
      console.log('收包数:', report.packetsReceived, '丢包:', report.packetsLost)
      console.log('当前码率帧率:', report.bytesReceived, report.framesPerSecond)
    }
  })
}

succeeded 的候选对会告诉你实际走的是哪条路径。如果 localCandidateId 对应的类型是 relay ,说明穿透失败走了中继,延迟会高一截,这时候就该考虑优化网络环境了。丢包率超过 5% 的时候画面就会明显卡顿,超过 15% 基本没法看。

6.3 STUN 和 TURN 的真实成本,别算漏了

做穿透绕不开这两个服务。 STUN 做的事很简单:告诉你"从公网看,你的地址是什么" 。它只在建连阶段用一下,流量几乎为零,所以公共的 STUN 服务基本够用。

TURN 是兜底:当双方都在严格 NAT 后面、直连不可能时,所有媒体流都得从 TURN 服务器中转 。问题在于这也意味着带宽成本。如果 20% 的连接走了 TURN,每分钟视频按 2Mbps 算,一个 100 人同时在线的场景,光中转流量就是一笔不小的开销。

我的建议是在服务端记录每次连接走的是 host 、 srflx 还是 relay ,做成指标长期观察。如果 relay 比例超过 20%,就该考虑在更多区域部署 TURN 节点,或者优化接入网络策略,而不是无脑扩容。

const iceConfig = {
  iceServers: [
    { urls: 'stun:你的stun服务地址:3478' },
    {
      urls: 'turn:你的turn服务地址:3478',
      username: '动态生成的用户名',
      credential: '动态生成的密码'
    }
  ],
  iceTransportPolicy: 'all'  // 生产环境不要用 relay,成本会失控
}

注意: iceTransportPolicy 设成 relay 会强制所有流量走中继,只在排查"是不是穿透问题"时临时用一下。生产环境写这个值等于把所有流量都变成付费流量。

TURN 的用户名密码一定要动态生成,带过期时间。静态配置的凭据一旦被扒出来,就会有人拿你的服务器做免费中转,账单能吓死你。

7. 上线前后那些没人愿意干但必须干的活

7.1 码率和分辨率要主动管,别指望默认值

浏览器的默认码率策略是按屏幕分辨率走的,一个 1080p 的摄像头它可能给你塞 4Mbps,多路上行直接把上行带宽打满。实际业务里, 移动端 640x360、25 帧、600kbps 就已经完全够用了 ,画质损失在手机小屏上基本看不出来。

主动控制码率要分两步。第一次是在 getUserMedia 阶段限制采集规格,第二次是在 setParameters 阶段限制发送码率:

async function applyBitrateLimit(pc, maxBitrate) {
  const senders = pc.getSenders()
  for (const sender of senders) {
    if (!sender.track || sender.track.kind !== 'video') continue
    const params = sender.getParameters()
    // 有些浏览器返回的 encodings 是空数组,必须兜底初始化
    if (!params.encodings || params.encodings.length === 0) {
      params.encodings = [{}]
    }
    params.encodings[0].maxBitrate = maxBitrate
    params.encodings[0].maxFramerate = 25
    params.degradationPreference = 'balanced'
    await sender.setParameters(params)
  }
}

这里有一个必踩的坑: getParameters() 返回的 encodings 在某些浏览器上确实是空数组 ,你直接往 encodings[0] 上写属性会抛 TypeError 。上面那三行兜底代码看起来多余,但它能让这段逻辑在 Chrome 和 Safari 上表现一致。

degradationPreference 这个参数值得单独提一句。它的取值有三种: balanced 是同时降帧率和分辨率, maintain-framerate 是宁可糊也要保帧率, maintain-resolution 是宁可卡也要保清晰度。做远程指导这类需要看清细节的场景用 maintain-resolution ,做动作教学这类需要看清连贯性的用 maintain-framerate 。

7.2 弱网下的降级策略要提前写

网络变差时如果什么都不做,表现是画面卡住不动,用户以为程序崩了。我的做法是在 getStats() 的轮询里加阈值判断,主动降级并给出提示。

function startNetworkMonitor(pc, onDegrade) {
  return setInterval(async () => {
    const stats = await pc.getStats()
    let packetLossRate = 0
    let rtt = 0

    stats.forEach(report => {
      if (report.type === 'inbound-rtp' && report.kind === 'video') {
        const total = report.packetsReceived + report.packetsLost
        if (total > 0) packetLossRate = report.packetsLost / total
      }
      if (report.type === 'candidate-pair' && report.state === 'succeeded') {
        rtt = report.currentRoundTripTime || 0
      }
    })

    // 丢包超过 8% 或 RTT 超过 400ms 就降级
    if (packetLossRate > 0.08 || rtt > 0.4) {
      onDegrade({ packetLossRate, rtt })
    }
  }, 3000)
}

降级的动作按顺序来:先把视频码率砍半,还是不行就关视频只保音频,最后如果音频也扛不住就提示用户检查网络。 音频永远优先于视频 ,因为人耳对声音中断的容忍度远低于画面模糊。

另外记得处理 iceconnectionstatechange 变成 disconnected 的情况。很多情况下这只代表网络抖动,过一两秒会自己回到 connected ,这时候立刻弹"连接已断开"是误报。我的做法是设一个三秒的宽限期,三秒内恢复了就什么都不做,超时了再提示用户。

7.3 HTTPS、Nginx 和部署时那些绕不过去的配置

摄像头权限、WebSocket 连接、 getUserMedia ,全都要求页面在安全上下文里运行。本地开发用 localhost 可以豁免,一旦部署到线上就必须配 HTTPS。这里说我常用的 Nginx 配置:

server {
    listen 443 ssl http2;
    server_name your-domain.com;

    ssl_certificate     /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # Vue 打包产物的静态资源
    location / {
        root /var/www/rtc-live/dist;
        try_files $uri $uri/ /index.html;
    }

    # 信令服务的 WebSocket 代理
    location /ws {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        # 长连接超时要调大,默认 60s 会把空闲连接掐掉
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

try_files $uri $uri/ /index.html 这行是给 Vue Router 的历史模式兜底的。没有它,用户刷新 /room/123 这种路由会直接 404。

WebSocket 代理的两处配置我要重点提醒。 proxy_read_timeout 的默认值是 60 秒 ,如果信令连接闲着超过 60 秒没消息,Nginx 会主动断开。直播场景里主播不说话的时候,信令可能几分钟都没动静,连接被掐掉之后就会出现"用着用着突然断了"的诡异现象。调大到 3600 秒能解决这个问题。另一个就是 Upgrade 和 Connection 那两个 header,少一个 WebSocket 握手就会失败。

部署顺序上我一般是这样:先起信令服务,确认能连上;再打包 Vue 前端放到 Nginx 目录;最后配代理和证书。反过来做的话,前端起来了但信令连不上,控制台一堆红色报错,很难判断是哪一环的问题。

最后分享一个我在实际项目里用到的小技巧: 在连接建立阶段打一条带时间戳的日志 ,记录 join 、 offer 、 answer 、 connected 这四个时刻。上线之后如果用户反馈"连得慢",你把这四个时间点一看,就知道是信令传输慢、还是 ICE 收集慢、还是媒体协商慢。这条日志加进去只花五分钟,但能省掉后面无数次靠猜来定位问题的痛苦。做实时通信这行,可观测性永远比优化技巧更重要,因为问题是随机出现的,你没法在本地复现,只能靠数据说话。

Logo

邀请您加入社区

更多推荐