三年前的园区巡检项目甲方在验收现场问了一句为什么我在对讲里说完一句话要等两秒才看到对面的人点头当时团队用的是 HLS 拉流切片四秒一个延迟压在六秒左右已经是极限了。那次之后我们把视频链路整个推倒重做换成了 WebRTC延迟直接降到了两百毫秒以内。如果你正在做一个 Vue 项目需要让两端甚至多端看得见、说得上、反应快那这套组合基本绕不开。这篇文章不讲概念科普只讲我在 Vue 工程里把 WebRTC 音视频直播真正跑起来的过程信令怎么设计、响应式为什么会把流搞坏、一对多怎么扩展、连不上时该怎么一步步排查。适合已经会写 Vue、但对实时通信还比较陌生的人也适合做过 WebRTC 但在 Vue 里踩过坑的人对照着看。1. 延迟这道门槛把 Vue 项目逼向了 WebRTC1.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-HLSWebRTC 每条连接都要独立维护服务器成本和带宽成本随人数线性上涨需要回看、录制归档推流到服务端转 HLSWebRTC 直播本身不留档录制要靠服务端旁路单向播报不需要互动WebRTC 推流 HLS 拉流推流端用 WebRTC 保证上传实时性观众端用 HLS 保证规模网络环境极差丢包 30% 以上需要中继节点兜底直连基本不可能成功全部流量走中继成本翻倍只需要传文字和状态WebSocket / SSE完全没必要动用媒体通道我自己的判断标准很简单如果这个场景里延迟是核心体验指标且同时在线人数在几百以内WebRTC 就是最优解反之先想想别的路。小班课、远程指导、在线问诊、客服视频、多人协作白板这些都是 WebRTC 的主场。2. 信令层没有标准这是整个项目里唯一必须自己动手的地方2.1 先把 SDP 和 ICE Candidate 这两段文本看懂WebRTC 最反直觉的地方在于它管传输但不管两边怎么找到对方。这个找对方的过程叫信令而信令的协议、格式、通道WebRTC 规范一个字都没规定。这意味着你必须自己搭一个服务让两端能互相传话。传的话主要就两种。第一种是SDPSession Description Protocol本质是一段结构化的纯文本描述了我这边支持什么编码、用什么传输、有几个媒体流。发起方调用createOffer()生成一份 SDP通过信令发给对方对方setRemoteDescription()收下再调用createAnswer()生成自己的 SDP 回传。这段文本看起来吓人实际只需要关注几行mvideo 9 UDP/TLS/RTP/SAVPF 96 97 98 artpmap:96 VP8/90000 artpmap:97 rtx/90000 artpmap:98 H264/90000 asendrecvmvideo那行声明了这是视频流后面跟的是候选编码的 payload type。artpmap把编号映射到具体编码器。asendrecv表示这条流是双向的改成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 - Setws 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结果加到第三个功能的时候就发现没法区分这条消息属于哪条连接。后来改成下面这样稳定用了很久字段类型说明typestringjoin/offer/answer/ice-candidate/leaveroomIdstring房间标识加入时必须带clientIdstring发送方唯一标识前端生成 UUIDtostring可选定向发送时指定目标 clientIdpayloadobject具体内容放 sdp 或 candidateclientId一定要在前端生成并保持稳定不要依赖服务端分配。原因是重连时你需要用同一个 ID 重新进房间服务端如果重新分配 ID其他端就认不出你了。另外to字段虽然当前版本用广播实现但协议里先留着等人数上去切换到定向转发时前端代码一行都不用改。3. Vue 工程侧的准备从建项目到拿到摄像头流3.1 脚手架和依赖的真实取舍有个常见的误解以为 WebRTC 需要装一堆包。实际上getUserMedia、RTCPeerConnection、RTCDataChannel全都是浏览器原生 API一个依赖都不用装。你只需要一个能跑起来的 Vue 工程和一个 WebSocket 客户端。# 创建工程用 Vite 比 Vue CLI 快很多 npm create vitelatest 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 reflocalVideoRef 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 }) /scriptautoplay和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多点控制单元服务器把所有流解码混成一路画面再编码下发。好处是下行只占一路带宽客户端压力极小。坏处是服务器必须有强大的转码能力延迟也更高而且布局被服务器定死用户想单独放大某个人做不到。适合网络极差或者需要录制合成的场景。维度MeshSFUMCU上行带宽随人数线性增长恒定一路恒定一路服务端成本无中只转发高需转码延迟增加最低一跳约 50ms两跳以上布局灵活性客户端完全自由客户端完全自由服务端决定推荐人数2 到 44 到数百数十实现难度低中高我的建议如果现在只是做 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 里会出现 asendonly// 观众端只收不发不调用 addTrack const offer await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })生成出来的 SDP 里主播端是asendonly观众端是arecvonly。这两个方向一旦明确SFU 就只需要做单向转发服务器负载能降一大截。观众想连麦的时候再走一次完整的重新协商把方向改成sendrecv或者更常见的做法是让观众和主播之间单独建一条连接避免影响其他观众。6. 连不上时的完整排查链路6.1 先按症状分类别乱试WebRTC 的失败特别容易让人抓瞎因为没画面这一个现象背后可能是五六个完全不同的原因。我的习惯是先把症状归类再按对应的路径查。症状最可能的原因第一步查什么本地预览就是黑的摄像头没拿到或权限被拒getUserMedia是否 resolve双方connectionState一直是newoffer/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两边的mvideo里没有重叠的 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 收集慢、还是媒体协商慢。这条日志加进去只花五分钟但能省掉后面无数次靠猜来定位问题的痛苦。做实时通信这行可观测性永远比优化技巧更重要因为问题是随机出现的你没法在本地复现只能靠数据说话。