☰
Vue + WebRTC 低延迟音视频直播实战:信令、ICE 与弱网排查
2026/9/29 5:06:58 网站建设 项目流程

1. 为什么我最终选了 VUE + WebRTC 这套组合

做音视频直播这件事,坑比你想象的多。我最早接触这块是在一个企业内训场景里,需求很朴素:讲师端开摄像头麦克风,几十个学员端实时看画面、听声音,延迟要低,最好还能连麦提问。第一反应是上传统流媒体方案,OBS 推流到服务器,再用播放器拉流。跑通是能跑通,但延迟始终在两三秒以上,讲师问一句、学员答一句,中间那几秒空白让人抓狂,连麦体验基本没法看。后来换思路,直接上WebRTC,配合VUE做前端状态管理和交互,延迟一下子压到几百毫秒,交互感完全不一样。

先把这个标题拆开说清楚。VUE在这里承担的是前端框架的角色——管理房间列表、用户状态、设备开关、信令消息的收发和渲染,它不做音视频的编解码,音视频的活儿全部交给浏览器的 WebRTC 能力。WebRTC(Web 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 vite@latest 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 类或者 composable,Vue 组件只负责调用和渲染。目录大概这样:

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()生成 answer,setLocalDescription(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 里的a=ice-ufrag和a=ice-pwd是 ICE 认证用的,如果你在传输过程中对 SDP 做了任何截断或者格式破坏(比如某些 HTTP 中间件会把换行符处理掉),ICE 就会认证失败。我见过一次线上事故,就是 nginx 配置里某个参数把长文本截了,导致 SDP 不完整,连接死活建不起来。所以信令传输要么用 WebSocket,要么用可靠的 HTTP 接口,别用什么会被转码的通道。

3.3 ICE 候选的处理,Trickle 还是非 Trickle

ICE 候选可以等全部收集完再一次性发,叫非 Trickle 模式;也可以边收集边发,叫 Trickle 模式。强烈建议用 Trickle,因为非 Trickle 模式下,如果某台设备网络环境差,候选收集慢,整个连接建立就得干等,用户体感就是"卡在那转圈"。

Trickle 模式的写法是监听onicecandidate:

pc.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 份流。所以一对多必须用SFU(Selective Forwarding Unit)架构:主播推一路流给服务器,服务器再分发给各个观众。服务器只做转发不做混流,CPU 压力小,但带宽压力大。

SFU 不开源自建的方案里,常用的是 mediasoup、Janus、SRS 这几个。我在这块踩过的坑是:一开始想偷懒,直接用 P2P 做 20 人小班课,结果开班的时候 20 个人的上行直接把主播卡死了,画面糊成马赛克。后来老老实实上 SFU 才稳住。所以选架构之前一定要先想清楚并发规模,别拿 P2P 硬扛。

4.2 码率和分辨率,直播清晰度的命门

直播最怕画面糊,而糊的根因十有八九是码率没配对。码率、分辨率、帧率三者要匹配,不能随便堆。有个经验公式:720p@30fps 大概需要 1.5~2.5 Mbps,1080p@30fps 大概 3~5 Mbps,360p@30fps 大概 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 的 GCC(Google 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,和摄像头是两套 API:

async 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 后的真实公网地址,这个行为可能暴露用户的一些网络信息。在企业内网或者对隐私要求高的场景里,这确实是个需要注意的点。控制方式是使用iceTransportPolicy:

const config = { iceServers: [...], iceTransportPolicy: 'relay' // 只用中继,不暴露本地候选 };

设成relay后,浏览器只会用 TURN 中继的地址,本地的 host 候选不会发出去,也就不会暴露内网 IP。代价是所有流量都走 TURN 服务器,成本上升、延迟增加。所以这是个隐私和成本的权衡,不是默认就必须开的。

5.3 打包部署后布局异常的排查

vue 打包后布局异常这个搜索词太真实了,我自己也踩过。Vite 打包后样式错乱,八成是这三个原因之一。

第一,scoped 样式和组件库样式冲突。打包后 CSS 提取合并的顺序变了,优先级可能反转。解决方法是给关键样式加更明确的选择器,或者用 CSS Modules。

第二,动态类名被 tree-shaking 掉了。如果你用字符串拼接类名(比如class="btn-" + type),生产构建的 CSS 压缩可能识别不到这个类被用到,直接删了。解决方法是把类名写全,或者放进 safelist。

第三,相对路径和 base 配置。部署到子目录时base没配,导致资源 404,页面布局自然全乱。Vite 里在vite.config.js设base: '/your-subpath/'就行。

直播项目还有个特有的坑:视频容器的高度。开发时用固定像素看起来正常,打包后可能因为父容器 flex 布局的计算差异,video元素撑破了容器。我一般给视频容器加aspect-ratio或者明确的object-fit: contain,让它无论如何都保持比例,不会被拉伸变形。

5.4 快速上手的实操清单

最后给一份可以照着做的清单,帮你从零跑通一个最小直播 demo:

  1. 用 Vite 创建 Vue 项目,装好依赖。
  2. 本地起一个 Node.js WebSocket 信令服务,实现 join、offer、answer、candidate 四类消息的转发。
  3. 在 Vue 里封装useWebRTC.js,实现getUserMedia采集、RTCPeerConnection创建、ontrack渲染。
  4. 配置 STUN 服务器,测试时先用公共 STUN,上线前换自建。
  5. 用两个不同的浏览器窗口打开页面,一个发起,一个接收,观察chrome://webrtc-internals里的连接状态。
  6. 连不通时先加 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 怎么调,那是另一个量级的话题了,等你先把点对点这关过了,再来考虑这些也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询