尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Vue + WebRTC 低延迟音视频直播实战:信令、ICE 与弱网排查

发布时间:2026/9/29 5:07:00

资讯中心
01
ARTICLE

Vue + WebRTC 低延迟音视频直播实战:信令、ICE 与弱网排查

Vue + WebRTC 低延迟音视频直播实战:信令、ICE 与弱网排查
1. 为什么我最终选了 VUE WebRTC 这套组合做音视频直播这件事坑比你想象的多。我最早接触这块是在一个企业内训场景里需求很朴素讲师端开摄像头麦克风几十个学员端实时看画面、听声音延迟要低最好还能连麦提问。第一反应是上传统流媒体方案OBS 推流到服务器再用播放器拉流。跑通是能跑通但延迟始终在两三秒以上讲师问一句、学员答一句中间那几秒空白让人抓狂连麦体验基本没法看。后来换思路直接上WebRTC配合VUE做前端状态管理和交互延迟一下子压到几百毫秒交互感完全不一样。先把这个标题拆开说清楚。VUE在这里承担的是前端框架的角色——管理房间列表、用户状态、设备开关、信令消息的收发和渲染它不做音视频的编解码音视频的活儿全部交给浏览器的 WebRTC 能力。WebRTCWeb Real-Time Communication是浏览器原生支持的实时通信能力核心是RTCPeerConnection、getUserMedia、RTCDataChannel这几个 API负责采集、编码、传输、解码整条链路。音视频直播在这里指的是一对多或者少对多的实时推流场景重点是实时两个字延迟是它的生命线。这套方案适合谁如果你在做在线教育、远程会议、连麦互动、远程协助、监控预览这类对延迟敏感的场景VUE WebRTC 是非常值得考虑的路线。它不需要你自建昂贵的流媒体服务器集群用浏览器自带的编解码能力就能把延迟做得很低。但它也有明确的边界纯 WebRTC 的 P2P 模式适合小规模几个到十几个人的房间人数一多就必须上 SFU 架构做中转这时候服务器成本和运维复杂度会陡增。这个取舍后面我会详细展开。我写这篇的目的是把从零搭一套 VUE WebRTC 直播系统里那些文档里不写、但实操中一定会撞上的东西讲透。包括环境配置、信令设计、SDP 交换的坑、ICE 候选的处理、设备权限、NAT 穿透、以及最常见的几种连不上的排查思路。你看完应该能自己动手搭出一个能跑通的 demo并且知道它在什么条件下会失效。2. 先把 VUE 这边的摊子铺好2.1 环境配置别一上来就装一堆全局包很多人一搜vue安装及环境配置教程就告诉你npm install -g vue/cli走起。放三年前这没问题现在我的建议是直接上 Vite别再用 Vue CLI 那套基于 Webpack 的重型脚手架了。原因很实在WebRTC 项目本身对构建速度没多大要求但你在调试阶段会频繁改代码热更新Vite 的冷启动和热更新比 Vue CLI 快一个量级调试体感差很多。具体步骤如下。首先装 Node.js版本建议 18 或 20 的 LTS。装完后验证node -v npm -v如果公司内网环境没法直连源配个国内镜像就行npm config set registry https://registry.npmmirror.com然后创建项目我用的是 Vite 的方式npm create vitelatest webrtc-live -- --template vue cd webrtc-live npm install npm run dev跑起来默认在 5173 端口。这里有一个新手极易踩的坑WebRTC 的getUserMedia在非安全上下文下会被浏览器直接拒绝。什么意思就是你的页面必须跑在https://或者localhost下用 IP 地址访问比如http://192.168.1.100:5173调摄像头会直接报错报错信息通常是navigator.mediaDevices是 undefined。解决办法有两个一是本地开发就用localhost二是要用局域网 IP 给手机测试时得配个自签名证书走 https。我一般用 vite 的server.https配置加mkcert生成证书这个后面实操章节会讲。注意如果你在npm run dev后拿局域网 IP 给同事访问测试摄像头功能时一定要确认对方用的是 https否则他会以为是你代码写错了其实是浏览器把权限掐了。2.2 目录结构怎么划分才不乱WebRTC 相关的逻辑别都堆在.vue文件里否则一个组件能写到上千行后面维护起来想死。我的划分方式是把 WebRTC 的核心能力抽成一个独立的 JS 类或者 composableVue 组件只负责调用和渲染。目录大概这样src/ ├── components/ │ ├── VideoPlayer.vue // 单个视频画面 │ ├── DevicePanel.vue // 设备选择面板 │ └── RoomList.vue // 房间列表 ├── composables/ │ ├── useWebRTC.js // WebRTC 核心封装 │ └── useSignaling.js // 信令连接封装 ├── stores/ │ └── room.js // Pinia 状态管理 └── utils/ └── mediaHelper.js // 设备枚举、码率设置等工具为什么这么拆因为 WebRTC 的连接生命周期peer connection 的创建、销毁、重协商是一套相对独立的逻辑和 UI 状态是两回事。你把它抽出来之后改 UI 不会动到连接逻辑改连接逻辑也不会影响 UI测试起来也方便。用pinia vs vuex之争在这里不用纠结新项目直接上 Pinia语法更简洁TypeScript 支持也更好。我一般会在useWebRTC.js里维护这么几个核心状态本地流localStream、远端流集合remoteStreams、连接状态connectionState、以及一个peerConnections的 Map键是用户 ID值是 RTCPeerConnection 实例。这个 Map 的设计很关键多人场景下每个对端都得有独立的连接实例这是新手最容易忽略的点。2.3 摄像头和麦克风的采集这几行代码要背下来获取本地设备流是整条链路的起点async function getLocalStream(constraints) { try { const stream await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); return stream; } catch (err) { console.error(采集失败, err.name, err.message); throw err; } }这里有几个参数值得说道。分辨率给ideal而不是exact是为了让浏览器在设备不支持时能降级写死exact会导致直接失败。帧率卡在 30 是因为直播场景 30 帧足够60 帧对带宽和 CPU 都太奢侈。音频那三个参数echoCancellation、noiseSuppression、autoGainControl建议全开尤其是多人连麦场景不开回声消除基本没法用——你能听到自己说话的回声。用户选择设备的能力也要做。先用enumerateDevices列出设备注意这个接口在没授权之前返回的 label 是空的必须先拿到权限才能拿到设备名字async function listDevices() { const devices await navigator.mediaDevices.enumerateDevices(); return { cameras: devices.filter(d d.kind videoinput), microphones: devices.filter(d d.kind audioinput), speakers: devices.filter(d d.kind audiooutput) }; }切换设备的时候不要重新建连接正确做法是getUserMedia拿到新流然后用RTCRtpSender.replaceTrack()把发送轨道替换掉。这样不用重新协商画面直接就切过去了体验非常丝滑。这个技巧我在很多教材里没看到是实操中摸索出来的。3. WebRTC 的核心信令与连接建立3.1 信令服务器到底要做什么webrtc vue使用里绕不开的一个概念就是信令。WebRTC 本身不规定信令怎么走它只管媒体传输建立连接需要的交换信息这一步得你自己实现。这个交换信息主要包含三类SDP会话描述、ICE 候选地址、以及业务上的房间/用户状态。SDP 换句话说是我这边支持什么编解码、用什么加密、有哪些媒体流的说明书。两个人要通话双方得先交换这份说明书互相看一眼对方支持什么然后取交集。这个过程叫 Offer/Answer 交换发起方创建 Offer接收方收到后创建 Answer来回一回协商完成。ICE 候选地址是我可能从这个地址能找到我的列表包括局域网地址、公网地址、以及通过 STUN 服务器探测到的地址。双方把这些地址都发给对方然后互相尝试连接哪个通就用哪个。信令传输一般用 WebSocket因为它支持服务端主动推送适合做状态同步。我用 Node.js 搭一个极简的信令服务核心逻辑就三件事连接管理、消息转发、房间管理。消息转发这块很简单收到谁的offer、answer、candidate就往目标用户那边转发就行。// 信令服务伪代码 const clients new Map(); wss.on(connection, (ws) { ws.on(message, (raw) { const msg JSON.parse(raw); switch (msg.type) { case join: clients.set(msg.userId, ws); broadcastUserList(); break; case offer: case answer: case candidate: const target clients.get(msg.targetId); if (target target.readyState 1) { target.send(JSON.stringify({ ...msg, fromId: msg.userId })); } break; } }); });这里要注意一个坑信令服务器只负责转发不做媒体中转。很多人一开始分不清信令和媒体以为信令服务器挂了通话就得断。实际上连接建立之后媒体是走 P2P 直连的信令服务器挂了不影响已经在通话的双方只影响新的连接建立和挂断通知。理解这一点对后面排查问题非常关键。3.2 SDP 交换的完整流程一步步拆开这个过程我看过很多人写错尤其是在时序上。正确的流程是这样的发起方调createOffer()拿到 offer调setLocalDescription(offer)设置本地描述然后通过信令发给对方。接收方收到后调setRemoteDescription(offer)再调createAnswer()生成 answersetLocalDescription(answer)设置本地描述通过信令回给对方。发起方收到 answer 后调setRemoteDescription(answer)。到这里协商才完成。// 发起方 const pc new RTCPeerConnection(config); localStream.getTracks().forEach(track pc.addTrack(track, localStream)); const offer await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: offer, sdp: pc.localDescription, targetId }); // 接收方 pc.ontrack (event) { remoteStream.addTrack(event.track); }; await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); signaling.send({ type: answer, sdp: pc.localDescription, targetId });这段代码有两个隐藏的坑。第一个是创建 offer 和设置 localDescription 之间不要插入异步操作否则可能出现竞态。第二个是接收方必须在setRemoteDescription之前就把ontrack监听挂上否则轨道来了你可能漏掉。更稳妥的做法是把ontrack的注册放在创建 pc 的时候紧随其后。还有一个很多人栽的跟头SDP 里的aice-ufrag和aice-pwd是 ICE 认证用的如果你在传输过程中对 SDP 做了任何截断或者格式破坏比如某些 HTTP 中间件会把换行符处理掉ICE 就会认证失败。我见过一次线上事故就是 nginx 配置里某个参数把长文本截了导致 SDP 不完整连接死活建不起来。所以信令传输要么用 WebSocket要么用可靠的 HTTP 接口别用什么会被转码的通道。3.3 ICE 候选的处理Trickle 还是非 TrickleICE 候选可以等全部收集完再一次性发叫非 Trickle 模式也可以边收集边发叫 Trickle 模式。强烈建议用 Trickle因为非 Trickle 模式下如果某台设备网络环境差候选收集慢整个连接建立就得干等用户体感就是卡在那转圈。Trickle 模式的写法是监听onicecandidatepc.onicecandidate (event) { if (event.candidate) { signaling.send({ type: candidate, candidate: event.candidate, targetId }); } else { console.log(候选收集结束); } }; // 接收方 signaling.on(candidate, async (msg) { if (pc.remoteDescription) { await pc.addIceCandidate(new RTCIceCandidate(msg.candidate)); } else { // 还没设置远端描述先缓存 pendingCandidates.push(msg.candidate); } });这段代码里那个pendingCandidates缓存非常重要。因为候选消息和 SDP 消息是分开走的很可能候选先到、SDP 后到。这时候直接addIceCandidate会报错必须先缓存起来等setRemoteDescription完成后再把缓存的候选逐个加进去。我被这个问题坑过整整一个下午症状是连接时好时坏网络快的时候没问题网络慢的时候就失败排查了很久才发现是这个时序问题。3.4 STUN 和 TURN穿透能力的天花板WebRTC 要穿透 NAT 才能 P2P 直连靠的是 STUN 服务器探测公网地址。但如果双方都在比较严格的 NAT 后面比如企业网络、运营商大内网P2P 打不通就必须靠 TURN 服务器做中继。TURN 服务器会转发媒体流代价是带宽和服务器成本。配置长这样const config { iceServers: [ { urls: stun:stun.example.com:3478 }, { urls: turn:turn.example.com:3478, username: user, credential: pass } ], iceTransportPolicy: all // 或 relay 强制走中继 };生产环境一定要自建 TURN公共 STUN 服务在关键时刻可能会掉链子而且公共 TURN 流量贵得吓人。我一般用 coturn 自建配置里注意开lt-cred-mech做鉴权不然就是给别人白嫖带宽。判断要不要走 TURN 有个简单方法看连接的candidate类型如果typ host和typ srflx都不通最后落到typ relay说明靠了中继。relay一出现延迟和带宽成本就上去了。提示可以用chrome://webrtc-internals这个页面查看完整的连接过程、ICE 候选状态、码率、丢包率排查问题的时候这个工具比打日志强一百倍。它的用法后面排查章节我会细说。4. 直播场景的关键实现细节4.1 一对一直播和一对多直播架构完全不同先厘清一个概念标题里的音视频直播在 WebRTC 语境下有两种形态实现方式天差地别。一对一直播是 P2P 模式两端各建一个 RTCPeerConnection直接连。延迟最低服务器成本几乎为零除了信令和 STUN/TURN。适合一对一客服、双人连麦。一对多直播就麻烦了。如果还走 P2P主播端要给每个观众建立一条独立的 peer connection观众一多主播的上行带宽瞬间爆炸。10 个观众主播就得上传 10 份流。所以一对多必须用SFUSelective Forwarding Unit架构主播推一路流给服务器服务器再分发给各个观众。服务器只做转发不做混流CPU 压力小但带宽压力大。SFU 不开源自建的方案里常用的是 mediasoup、Janus、SRS 这几个。我在这块踩过的坑是一开始想偷懒直接用 P2P 做 20 人小班课结果开班的时候 20 个人的上行直接把主播卡死了画面糊成马赛克。后来老老实实上 SFU 才稳住。所以选架构之前一定要先想清楚并发规模别拿 P2P 硬扛。4.2 码率和分辨率直播清晰度的命门直播最怕画面糊而糊的根因十有八九是码率没配对。码率、分辨率、帧率三者要匹配不能随便堆。有个经验公式720p30fps 大概需要 1.5~2.5 Mbps1080p30fps 大概 3~5 Mbps360p30fps 大概 500~800 Kbps。这些是 H.264 的参考值实际会因画面动态程度浮动。在 WebRTC 里可以动态调整发送参数async function setVideoBitrate(pc, bitrate) { const sender pc.getSenders().find(s s.track.kind video); if (!sender) return; const params sender.getParameters(); if (!params.encodings || params.encodings.length 0) { params.encodings [{}]; } params.encodings[0].maxBitrate bitrate; await sender.setParameters(params); }这段代码有个坑刚创建 sender 时getParameters().encodings可能是空数组直接赋值params.encodings[0].maxBitrate会报错必须先判断并初始化一个空对象。这个细节网上很多示例都漏了直接抄会挂。另一个决定清晰度的是degradationPreference参数它决定网络差的时候浏览器优先保什么是保帧率丢清晰度还是保清晰度丢帧率。会议场景一般设balanced屏幕共享要设maintain-resolution因为文字糊了就没法看了。4.3 弱网对抗直播稳定性的真正考验网络不可能永远好弱网下怎么保体验才是真功夫。WebRTC 自带了一些机制NACK 丢包重传、FEC 前向纠错、以及基于 GCC 的带宽估计。这些默认是开的但你可以调一些参数。带宽估计这块WebRTC 会根据实时网络状况自动调整发送码率这就是所谓webrtc链路容量估计在搜索词里出现核心算法是 Google 的 GCCGoogle Congestion Control。它会根据丢包和延迟抖动来算当前可用带宽然后反馈给发送端调整码率。你不需要自己实现但要知道它的存在——当你看到码率在动态波动那不是 bug是拥塞控制在正常工作。如果要做更精细的弱网优化可以在 SDP 里调一些参数或者用 simulcast 发多路不同分辨率的流让 SFU 根据观众网络选合适的层下发。simulcast 是 SFU 架构下的利器const transceiver pc.addTransceiver(track, { direction: sendonly, sendEncodings: [ { rid: h, maxBitrate: 1500000, scaleResolutionDownBy: 1 }, { rid: m, maxBitrate: 600000, scaleResolutionDownBy: 2 }, { rid: l, maxBitrate: 200000, scaleResolutionDownBy: 4 } ] });这样主播发三路流给 SFU网络好的观众收高清网络差的收低清服务器按需分发整体体验比单一码率好很多。代价是主播上行带宽翻倍所以要权衡。4.4 屏幕共享和媒体切换直播场景经常需要切屏幕共享。屏幕共享走的是getDisplayMedia和摄像头是两套 APIasync function startScreenShare() { const stream await navigator.mediaDevices.getDisplayMedia({ video: { cursor: always }, audio: false }); const screenTrack stream.getVideoTracks()[0]; const sender pc.getSenders().find(s s.track.kind video); await sender.replaceTrack(screenTrack); screenTrack.onended () { // 用户点了停止共享切回摄像头 const camTrack localStream.getVideoTracks()[0]; sender.replaceTrack(camTrack); }; }replaceTrack这里再次派上用场切共享不用重新协商切换瞬间完成。onended一定要处理因为用户可能直接点浏览器的停止共享按钮你不处理的话画面就黑在那了。另外getDisplayMedia返回的流里音频在多数浏览器里默认拿不到系统声音这是浏览器安全策略限制的别指望能直接采到。5. 那些让你拍桌子的常见问题与排查5.1 连接建立失败的排查顺序直播连不上是最常见的问题我整理了一个排查顺序基本能覆盖八成情况。排查项检查方法常见原因设备权限看getUserMedia是否抛错https 未启用、用户拒绝信令连通看 WebSocket 是否连接成功地址错、跨域、服务没起SDP 完整性打印 SDP 对比传输被截断、格式被改ICE 候选看iceConnectionState状态STUN/TURN 不可达编解码兼容看 SDP 里 m 行两端无交集防火墙telnet 测 UDP 端口企业网络封 UDP排查的时候第一步永远是打开chrome://webrtc-internals这个页面能看到所有 peer connection 的实时状态。重点看iceConnectionState和connectionState的变化。如果是checking卡住不动基本是 ICE 候选打不通考虑加 TURN。如果是failed看候选列表里有没有typ relay。还有一个隐蔽问题同时开多个标签页测试时麦克风可能会被占用。浏览器对麦克风的独占性在 macOS 上尤其严格第二个标签页拿不到麦克风会静默失败或者只拿到空轨道。测试多人场景时记得用不同的浏览器或者无痕窗口别用两个相同标签页硬测。5.2 关于WebRTC 泄露这个说法搜索词里出现了webrtc泄露这里得正经说一下。所谓泄露指的是 WebRTC 在建立连接时会用 STUN 探测本机在 NAT 后的真实公网地址这个行为可能暴露用户的一些网络信息。在企业内网或者对隐私要求高的场景里这确实是个需要注意的点。控制方式是使用iceTransportPolicyconst config { iceServers: [...], iceTransportPolicy: relay // 只用中继不暴露本地候选 };设成relay后浏览器只会用 TURN 中继的地址本地的 host 候选不会发出去也就不会暴露内网 IP。代价是所有流量都走 TURN 服务器成本上升、延迟增加。所以这是个隐私和成本的权衡不是默认就必须开的。5.3 打包部署后布局异常的排查vue 打包后布局异常这个搜索词太真实了我自己也踩过。Vite 打包后样式错乱八成是这三个原因之一。第一scoped 样式和组件库样式冲突。打包后 CSS 提取合并的顺序变了优先级可能反转。解决方法是给关键样式加更明确的选择器或者用 CSS Modules。第二动态类名被 tree-shaking 掉了。如果你用字符串拼接类名比如classbtn- type生产构建的 CSS 压缩可能识别不到这个类被用到直接删了。解决方法是把类名写全或者放进 safelist。第三相对路径和 base 配置。部署到子目录时base没配导致资源 404页面布局自然全乱。Vite 里在vite.config.js设base: /your-subpath/就行。直播项目还有个特有的坑视频容器的高度。开发时用固定像素看起来正常打包后可能因为父容器 flex 布局的计算差异video元素撑破了容器。我一般给视频容器加aspect-ratio或者明确的object-fit: contain让它无论如何都保持比例不会被拉伸变形。5.4 快速上手的实操清单最后给一份可以照着做的清单帮你从零跑通一个最小直播 demo用 Vite 创建 Vue 项目装好依赖。本地起一个 Node.js WebSocket 信令服务实现 join、offer、answer、candidate 四类消息的转发。在 Vue 里封装useWebRTC.js实现getUserMedia采集、RTCPeerConnection创建、ontrack渲染。配置 STUN 服务器测试时先用公共 STUN上线前换自建。用两个不同的浏览器窗口打开页面一个发起一个接收观察chrome://webrtc-internals里的连接状态。连不通时先加 TURN再排查权限和 SDP。注意开发阶段就不要省 TURN 了直接配一个自建 coturn能省掉你无数个抓耳挠腮的夜晚。我见过太多人因为没配 TURN在同一个局域网测试一切正常一换到不同网络环境就彻底趴窝。6. 几个只有实操才会懂的经验关于RTCPeerConnection的关闭。组件销毁的时候一定要显式关闭 pc把pc.close()和localStream.getTracks().forEach(t t.stop())都调上。不调的话摄像头指示灯会一直亮着用户会以为你在偷拍他这个投诉我收到过。Vue 的onUnmounted里做这个清理最合适。关于 Vue 的响应式和 WebRTC 对象的微妙冲突。有个坑非常隐蔽如果你把RTCPeerConnection实例直接用ref或reactive包起来Vue 的 Proxy 会代理这个对象可能干扰它内部的一些属性访问导致莫名其妙的行为。正确做法是用shallowRef或者干脆放在组件外部的普通变量里。这个坑我在vue3 响应式相关的调试里撞过一次连接状态就是不更新排查半天才发现是代理惹的。关于视频元素的渲染时机。远端流到达后要确保video元素已经挂载否则srcObject赋值会失败。稳妥的写法是在nextTick里赋值或者用watch监听流的到来。还有个小细节给video元素加autoplay和playsinline属性前者让它自动播后者防止在 iOS 上强制全屏。iOS 上还有个大坑video元素必须静音才能自动播放所以音频通常要单独处理或者加一个用户点击播放的交互。踩过这些坑之后我最大的体会是WebRTC 的 API 本身不难难的是它依赖的网络环境太复杂同样的代码在不同网络下表现可能完全不同。所以调试的时候一定要有耐心把chrome://webrtc-internals用熟遇到问题先看连接状态和候选类型比盲目改代码有效得多。至于人数规模上去了之后要不要上 SFU、TURN 带宽怎么估算、simulcast 怎么调那是另一个量级的话题了等你先把点对点这关过了再来考虑这些也不迟。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。