简介:面向有一定前端基础、希望快速上手WebRTC多人实时互动的开发者,这份Vue Demo源码以多人互动为场景,围绕WebRTC多对多连接、Socket.IO信令交互和Vue组件化开发,覆盖了从用户加入房间、交换SDP与ICE候选,到建立P2P音视频通道的完整闭环,支持多人同时入会、实时音视频通话与数据共享,也展示了前端界面与后端信令服务的协作方式。资源共18个文件,其中9个js负责信令服务与辅助逻辑,2个vue承载界面组件,2个json为项目配置,另有说明文档、静态图标及HTML入口页面,压缩包仅113KB,结构清晰紧凑,便于快速定位与二次开发。目前已有703人学习下载。通过该示例,读者既能掌握RTCPeerConnection、getUserMedia、DataChannel等关键接口的实际用法,也能理解STUN/TURN服务器在NAT穿透中的作用,还能学习内网HTTPS环境下配合ngrok进行手机调试的实用技巧,适合作为后续开发视频会议、在线课堂等实时通信应用的参考脚手架与排错蓝本。
1. 一屋子人同时开摄像头的真正瓶颈不在摄像头,在信令
视频会议 Demo 里最容易被低估的是信令这一环。两台浏览器之间的 SDP 与 ICE 候选如果错过了,哪怕编码器再强、带宽再大,用户也只能看着那块“正在重连”的占位头像。这份 Vue Demo 解压后是两个互相独立的目录:signaling-server 负责 Node.js 信令网关,vue-webrtc 负责前端多人互动界面。它的技术栈组合起来很典型:Vue 的组件响应式负责画面管理,socket.io 的 room 机制负责把 offer 精准送到同房间的第二个人手里。适合刚把 getUserMedia 跑通、想从双人 Demo 跳到多人会议的前端,也适合想搞清楚信令网关该怎么和前端约定事件类型的后端。拆完这个实例,你得到的是一条可以照着组织代码的事件流。
2. Socket.IO信令服务:把每一帧SDP和ICE候选送到该去的地方
2.1 为什么多人场景不直接用WebSocket,而选Socket.IO
音视频数据一旦建立 P2P 连接就不再经过服务端,但建立之前的 offer、answer、ICE candidate 必须有一条可信通道。WebSocket 也能做,只是 Socket.IO 额外给了 room 分组和断线自动重连。room 这个概念在多人会议里几乎是刚需,它让服务端可以把一条信令精确地广播给同一房间的其余 socket,而不必自己在内存里维护一张在线用户表。自动重连则避免了手机切到后台再切回来时,对端连接已经进入 failed 状态,你这边却毫无感知。
从压缩包文件结构看,信令服务拆成了 logger.js、rtc-service.js 和入口文件。这个拆分很值得沿用:logger.js 统一日志格式,rtc-service.js 专门定义事件名和 payload 结构,入口文件只做 socket 路由分发。这样前后端联调时,打开后端日志就能直接看到“谁向谁发了什么事件”,而不是在回调堆里翻。
下面是参照该目录结构写的最小实现,保留了 room 管理和信令转发两个核心动作:
const http = require('http'); const { Server } = require('socket.io'); const logger = require('./logger'); const rtcService = require('./rtc-service'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('webrtc-conference signaling server'); }); const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } }); io.on('connection', (socket) => { socket.on('join', (roomId, callback) => { socket.join(roomId); const roomPeers = io.sockets.adapter.rooms.get(roomId); const peers = roomPeers ? [...roomPeers] : []; if (typeof callback === 'function') { callback({ ok: true, socketId: socket.id, peers }); } socket.to(roomId).emit('peer-joined', { socketId: socket.id }); }); socket.on('signal', (data) => { const { roomId, to, signal } = data; socket.to(to).emit('signal', { from: socket.id, roomId, signal }); }); socket.on('disconnecting', () => { [...socket.rooms].forEach((room) => { socket.to(room).emit('peer-left', { socketId: socket.id }); }); }); }); server.listen(19090, () => { logger.info('signaling server listening on 19090'); });这段代码把多人信令收敛成五个事件,join 返回当前房间的 peers 数组,新成员靠它知道有哪些连接对象可以发起协商;peer-joined 通知老成员有新设备进来,老成员需要主动发起 offer,因为老成员手里已经持有本地媒体流轨道。signal 是统一的事件出口,无论发 SDP 还是 ICE candidate 都走它,避免为每一种信令类型单独发明事件名。disconnecting 里遍历 socket.rooms 是对的,socket 一旦断开,它自己默认所在的私有房间会被自动清理,不用额外 remove。
参数说明看下面这张表,事件方向决定了逻辑在服务端还是前端:
| 事件名 | payload 关键字段 | 方向 | 用途 |
|---|---|---|---|
| join | roomId | 前端 → 服务端 | 加入房间并取回已有成员列表 |
| peer-joined | socketId | 服务端 → 前端 | 通知老成员,有新成员到达 |
| signal | roomId, to, signal | 前端 → 服务端 | 转发 SDP / ICE candidate |
| signal | from, roomId, signal | 服务端 → 前端 | 对端信令到达 |
| peer-left | socketId | 服务端 → 前端 | 成员离开,前端销毁连接对象 |
2.2 前端信令回调:offer、answer、candidate 的分派顺序
这里最容易踩的坑,是收到 offer 时对应的 RTCPeerConnection 还没创建。多人环境下不能像双人 Demo 那样假设“只有一个对端”,必须用一个以 socketId 为 key 的 Map 保存连接实例。收到 invite 就先从 Map 里取,取不到就新建,再让用户媒体轨道进去。下面的分派逻辑可以直接落到 Vue 组件的 socket 监听里:
socket.on('signal', async ({ from, signal }) => { const pc = ensurePeer(from); if (signal.type === 'offer') { await pc.setRemoteDescription(signal.sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('signal', { roomId, to: from, signal: { type: 'answer', sdp: answer } }); } else if (signal.type === 'answer') { await pc.setRemoteDescription(signal.sdp); } else if (signal.type === 'ice-candidate') { await pc.addIceCandidate(signal.candidate); } });ensurePeer 函数内部的逻辑是,peers Map 里没有对应 socketId 就调用 RTCPeerConnection 构造函数并挂上 onicecandidate、ontrack、onconnectionstatechange 三个监听器。setRemoteDescription 拿到 offer 后 createAnswer 之前,本地轨道必须已经通过 pc.addTrack 添加完成,否则返回的 answer 里没有媒体描述,对端会出现“连接成功但没声音没画面”的假象。addIceCandidate 偶发抛错,是因为 remoteDescription 还没设置完 candidate 就到了,严格做法是在 await setRemoteDescription 之后再消费 ICE 队列。
2.3 多人房间的 offer 发起方向与 glare 冲突
WebRTC 规范里没有规定谁先发 offer,但两边的 RTCPeerConnection 同时处于 have-local-offer 状态就是 glare 冲突。规范的处理方式是其中一个 peer 退避重试,浏览器底层虽然实现了 ICE 冲突解决,但多人群组里最好在应用层就固定方向,把状态机的不确定性降到最低。
这个 Demo 里比较自然的约定是:新成员调用 join 拿到 peers 列表后,不主动发 offer,而是等房间里每个老成员通过 peer-joined 事件向它发起 offer。这样每个连接只存在一个 offer 发起方,且发起方一定持有最新媒体流轨道。前端收到 peer-joined 后的核心动作是调用 createOffer 并 setLocalDescription,然后把 SDP 放进 signal 事件发给新成员。这个方向一旦定下来,后面接聊天室、屏幕共享、切换摄像头,都只要在这个事件模型上叠加逻辑。
3. Vue组件层封装:RTCPeerConnection如何在生命周期里活着
3.1 把连接对象放进响应式数据之前的三个决定
把 RTCPeerConnection 直接塞进 Vue 组件的 data,打开 DevTools 时会发现整个面板卡到几乎没办法操作,因为 peer 对象含大量 getter、setter 和内部状态,Vue 3 虽然只对访问过的属性做依赖收集,但大对象被 reactive 包装后仍然存在额外开销。合理的划分是:peer 实例放在普通 Map 里,远程媒体流用 ref(new Map()),本地流用 ref,只有需要触发视图更新的数据才进入响应式系统。
第二个决定是远程流的更新时机。ontrack 回调触发时,event.streams[0] 可能被多个 track 事件重复关联,频繁执行 set 会引发视频组件不必要的重渲染。常见做法是先判断 remoteStreams 里是否已有该 socketId,没有才写入。第三个决定是组件卸载时的清理动作。Vue Router 负责的是页面切换,不会帮你关闭 WebRTC 连接,不显式调用 pc.close() 的话摄像头指示灯可能一直亮着,麦克风占用也无法释放。
3.2 一个可复用的 useWebRTC 组合式函数
把它封装成 Vue 组合式函数,多人房间页面只要关心本地流、远程流和加入房间三个行为。下面的结构对照了项目 src 目录里常用的封装方式:
import { onBeforeUnmount, ref } from 'vue'; import { io } from 'socket.io-client'; export function useWebRTC(roomId) { const localStream = ref(null); const remoteStreams = ref(new Map()); const peers = new Map(); const socket = io('/'); const rtcConfig = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ], iceTransportPolicy: 'all', bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }; async function initLocalStream() { localStream.value = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 24 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); } function createPeer(socketId) { const pc = new RTCPeerConnection(rtcConfig); localStream.value.getTracks().forEach((track) => { pc.addTrack(track, localStream.value); }); pc.ontrack = (event) => { if (!remoteStreams.value.has(socketId)) { remoteStreams.value.set(socketId, event.streams[0]); } }; pc.onicecandidate = (event) => { if (event.candidate) { socket.emit('signal', { roomId, to: socketId, signal: { type: 'ice-candidate', candidate: event.candidate } }); } }; pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') { console.warn(`peer ${socketId} connection failed`); } }; peers.set(socketId, pc); return pc; } function cleanup() { peers.forEach((pc) => pc.close()); peers.clear(); remoteStreams.value.clear(); localStream.value.getTracks().forEach((track) => track.stop()); socket.close(); } onBeforeUnmount(cleanup); return { localStream, remoteStreams, initLocalStream, createPeer }; }这个函数把媒体流的生命周期收拢在一个作用域里。getUserMedia 的参数值得留意:video 里不直接写 1920 这样的绝对值,而是用 ideal,让浏览器根据设备能力自动选择最接近的分辨率;audio 的三个布尔开关全部打开,会议场景下扬声器外放的回声会被抑制。rtcConfig 里的 iceTransportPolicy 默认为 all,允许候选包含 host、srflx、relay 三种类型;bundlePolicy 设为 max-bundle 可以把多条 RTP 流复用到同一对 UDP 端口,减少候选数量,多人连接时明显缩短建连时间。
socket 统一用 io('/') 直连开发服务器,signaling-server 与前端并不需要在同一个进程,但通过 vue.config.js 的代理配置实现同源访问,省去处理跨域。onBeforeUnmount 清理完成后,还必须把 socket 的 signal 监听断开,否则组件销毁后回调仍会执行,remoteStreams 更新到已卸载的组件上,控制台会报一堆警告。
3.3 路由切换与组件卸载时的连接回收
多人房间在 Vue Router 里通常是动态路由,路径类似 /conference/:roomId,组件里可以直接拿到 route.params.roomId 作为 join 参数。但这个设计会带来一个隐蔽问题:用户从会议室退回列表页时,RTCPeerConnection 不是立刻断开的,信令服务器也还认为该 socket 在线,此时另一个人推流给你,信令照样转发,前端却已经没有任何组件在消费远程流了。
解决是靠生命周期钩子收口。路由离开时,先遍历 peers 调用 close,再 stop 掉本地流的每个 track,最后 socket.close() 并把它从信令服务器的 room 里移除。顺序不能乱,先关 peer 再停轨道,否则部分浏览器会触发 ontrack 的清理逻辑,往已经关闭的连接里写状态。如果业务需要保存房间内的消息列表,在路由离开前把消息快照写到 sessionStorage,组件重新挂载时再读回来,比在 Vuex 里维护一份容易失真的房间状态更轻。
4. 从局域网到公网:STUN/TURN配置与手机HTTPS调试
4.1 为什么本地能跑通,换台电脑就黑屏
同一 Wi-Fi 下的两台电脑,ICE candidate 里通常直接出现 host 类型的局域网地址,连接建立很快。一旦一方切到 4G 网络,或者公司网络里启用了对称型 NAT,host 候选就彻底失效,必须靠 STUN 拿到公网映射地址。STUN 只能解决普通 NAT 映射,遇到对称型 NAT 时,STUN 服务器拿到的是一个和实际通信端口不一致的映射,媒体包永远送不到对端,只能降级到 TURN 中继。
这个 Demo 的 rtcConfig 里放一个 STUN 服务地址就能覆盖大部分家庭网络场景,但正式会议产品必须有自己的 TURN 中继服务。TURN 的本质是媒体数据转发,客户端与 TURN 服务器之间建立中继分配,然后把 relay 候选通过信令交给对端。配置格式与 STUN 相比多了鉴权字段:
iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'turn:turn.example.com:3478', username: 'conference-demo', credential: 'your-secret', } ]注意 credential 不能写成纯文本存放在前端仓库里,常见做法是登录后由后端签一个临时凭证返回给前端,过期时间控制在会话长度内。多人互动对 TURN 带宽的消耗是线性增长的,4 人 mesh 连接可能需要 3 份上行媒体流同时经过中继,这一个因素往往比服务器 CPU 更先成为容量瓶颈。
4.2 vue.config.js 里的HTTPS与端口细节
getUserMedia 对安全上下文有硬性要求。localhost 被浏览器视为安全来源,所以开发阶段一切正常,换成手机通过局域网 IP 访问 http://192.168.1.10:8081 时,navigator.mediaDevices 直接是 undefined,摄像头权限根本不会弹出。解决办法是给 devServer 配上 HTTPS 证书,再让手机访问 https 地址。项目里的 vue.config.js 大致承担了这段配置:
const fs = require('fs'); module.exports = { publicPath: './', devServer: { host: '0.0.0.0', port: 8081, https: { key: fs.readFileSync('./certs/dev.key'), cert: fs.readFileSync('./certs/dev.crt') }, allowedHosts: 'all' }, lintOnSave: false };host 写 0.0.0.0 而不是 localhost,是让开发服务器监听所有网卡接口,手机才能通过局域网 IP 访问到它。publicPath 设成相对路径,后续把打包产物放到服务器子目录不会出现 CSS、JS 文件 404 导致的整体布局异常。certs 目录下的自签证书用 openssl 生成即可,iOS 第一次访问会在证书校验处停下来,需要先到“设置-通用-关于本机-证书信任设置”里手动信任。
如果你的手机和电脑不在同一网段,局域网方案就走不通。一种常见做法是弄一台有公网 HTTPS 地址的跳板机,把本地端口映射过去,把信令服务和静态资源都暴露成一个安全来源,手机直接访问这个地址。这么做的好处是,浏览器安全上下文问题、局域网路由问题、NAT 穿透问题同时被绕开,缺点是媒体流如果走 relay 就会经过那台机器,带宽要按会议人数预留。
4.3 手机浏览器调试:权限、安全上下文与mDNS
在 Android Chrome 上调试还有一个临时手段:地址栏输入 chrome://flags,开启 Insecure origins treated as secure 选项,再把局域网 IP 填进列表,重启浏览器后就能在 http 环境下调用摄像头。这个开关只应该用于开发机,一旦浏览器升级或用户清数据就会失效,不能作为交付依据。
手机端实际连麦时,你会发现 candidate 列表里出现大量 mDNS 生成的 host 候选,形如 uuid.local,而不是 192.168.x.x。这是浏览器的隐私保护机制,WebRTC 在建立连接时默认隐藏真实局域网 IP。多人场景下这不算坏事,反而避免了对端通过 ICE 信息探测你的内网结构。排查连接问题时,要分清楚 srflx 候选里出现的公网 IP 是映射地址,而 host 候选被 mDNS 遮蔽后无法直接和网卡对应,这是预期行为,不要当成缺陷去翻。
5. DataChannel:多人房间里的文本消息与连接质量验证
5.1 用同一个连接传非音视频数据的参数选择
多人会议通常还需要文本消息、送花、投票这类互动能力。与其再开一个 WebSocket,不如直接在已建立的 RTCPeerConnection 上创建 DataChannel,让聊天数据也走 P2P 链路,服务端零带宽占用。创建通道时的三个参数需要特意选:
const chatChannel = pc.createDataChannel('chat', { ordered: true, maxRetransmits: 3, protocol: 'json' }); chatChannel.onopen = () => { chatChannel.send(JSON.stringify({ type: 'hello', from: 'me' })); }; chatChannel.onmessage = (event) => { const payload = JSON.parse(event.data); // 按 payload.type 分发到消息列表 };ordered 为 true 时,接收端会按发送顺序向上抛数据,保证聊天消息不乱序;maxRetransmits 限制重传次数为 3 次,避免弱网下数据无限重传挤占音视频带宽。注意 maxRetransmits 和 maxPacketLifeTime 是互斥的,前者按次数控制重传,后者按时间控制,二者不能同时指定,否则浏览器直接抛 TypeError。聊天场景选 maxRetransmits 更直观,游戏类实时交互则可以允许乱序并放弃重传,把 ordered 设成 false 换取低延迟。
5.2 连接质量自检:candidate类型怎么看
多人调试时最常问的一句话是“到底连上没有”。除了看 onconnectionstatechange 状态,更直接的手段是在浏览器 Console 里观察 candidate:
pc.onicecandidate = (event) => { if (event.candidate) { console.log(event.candidate.candidate); } };candidate 字符串里第二个字段是优先级数字,最后一个类型字段决定协议路径。下表是三类候选的判别标准:
| 类型 | candidate 特征 | 含义 |
|---|---|---|
| host | 形如 192.168.x.x 或 uuid.local | 本机直连,无需穿越 |
| srflx | 有公网 IP 和端口,且不是服务器地址 | STUN 映射成功,具备 P2P 条件 |
| relay | 指向 TURN 服务器地址 | 媒体流经中继转发,带宽成本最高 |
如果最终选中的 candidate-pair 是 relay,说明两端之间没有可用的 P2P 路径,此时观察 TURN 服务器的带宽占用就能定位瓶颈。还可以用 getStats 拿到实时的往返时延与丢包数据,辅助判断是网络问题还是编码参数问题:
const report = await pc.getStats(); report.forEach((stat) => { if (stat.type === 'candidate-pair' && stat.state === 'succeeded') { console.log(`rtt=${stat.currentRoundTripTime}ms`); } if (stat.type === 'inbound-rtp' && stat.kind === 'video') { console.log(`packetsLost=${stat.packetsLost} fps=${stat.framesPerSecond}`); } });把这两个数值叠加到会议界面的实时监控角标里,比在用户反馈后才查日志要快得多。RTT 持续高于 300ms 或 packetsLost 占总包数比例超过一定阈值时,就该主动触发分辨率降级,WebRTC 的链路容量估计会自动调节码率,但手动降低帧率往往能得到更平滑的画面过渡。
本文还有配套的精品资源,点击获取