Vue WebRTC 实时音视频直播实战:低延迟、信令与响应式避坑
三年前的园区巡检项目,甲方在验收现场问了一句:"为什么我在对讲里说完一句话,要等两秒才看到对面的人点头?"当时团队用的是 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 收集慢、还是媒体协商慢。这条日志加进去只花五分钟,但能省掉后面无数次靠猜来定位问题的痛苦。做实时通信这行,可观测性永远比优化技巧更重要,因为问题是随机出现的,你没法在本地复现,只能靠数据说话。
更多推荐

所有评论(0)