资讯动态

Vue3+Socket.IO实现WebRTC多人视频Mesh全互联实战

发布时间:2026/9/15 14:36:10 来源:尧图企业网站定制
简介基于Vue.js与WebRTC的多人实时互动Demo源码面向有前端基础的开发者演示音视频通话、P2P连接与信令服务完整实现。项目覆盖RTCPeerConnection、getUserMedia、DataChannels、STUN/TURN穿透及Node.jsSocket.IO信令服务等核心知识点可用来理解浏览器实时通信从建连到数据交换的全链路。压缩包共18个文件以js脚本、vue组件、json配置为主并含HTML入口、说明文档和图标资源约113KB结构清晰。已有703人学习下载适合作为WebRTC入门到进阶的参考案例。对照源码可快速跑通信令服务和Vue客户端掌握内网HTTPS下手机调试方法封装好的RTC服务与组件可直接复用减少踩坑成本是构建实时互动应用时较实用的参考资料。1. 多人 WebRTC 互动项目连接矩阵才是 demo 的灵魂打开一个「js webrtc 多人互动 vue demo 源码」的压缩包最容易让你卡住的通常不是 Vue 组件而是浏览器控制台里一排红色的 ICE 失败日志。单对单视频连接只要十几行配置人数一多信令顺序和连接生命周期就成了主要矛盾。下面这套方案以 Vue 3 Socket.IO 原生 WebRTC 的 Mesh 全互联为主线把信令协议、加入离开流程、带宽自适应一次讲透给出的代码可以直接落成一个最小可跑的 demo。适合要在两三天内交付一个可演示的多人视频页面的前端工程师也适合想把连接管理从 example 代码里抽出来重写的熟手。2. 先定连接模型Mesh 架构与信令协议的设计取舍2.1 Mesh 模式先做数学题P2P 数量与上行带宽多人互动的第一件事不是写代码而是算连接数。Mesh 又叫全互联房间内每个客户端和其他所有人都建立一条 RTCPeerConnectionn 个人的连接数是 n(n-1)/24 人是 6 条6 人是 15 条8 人就是 28 条。每条连接里本端把本地流上行推给对端同时接收对端的下行流所以一个人的上行永远是 1 路视频码率但下行、解码、渲染的开销随人数线性上涨。这里补一段 webrtc 技术详解里最容易被跳过的部分链路容量估计在 Mesh 模式下主要看你自己的上行而不是服务器带宽。4 人房间每人推 1 路 1Mbps 视频上行占 1Mbps如果按 1 对 1 的思路给 8 个 peer 各推一路流上行就变成 8Mbps家用宽带的瓶颈立刻暴露。demo 源码普遍选 Mesh 的原因也很实际不需要部署媒体服务器信令服务只转发 JSON 不碰媒体流和一个小 Node 服务共用端口就够了。我一般按三个条件决定是否换 SFU常态同时在线超过 6 人移动端占比高且弱网场景多预算能覆盖 TURN 中继流量费。三条都不命中Mesh 是最快的交付路径这也是大量「webrtc 实例」类开源 demo 拿 Mesh 做默认架构的原因。2.2 信令协议先写下来6 条消息覆盖一个房间的全部状态多人互动的信令比 1 对 1 多了一个维度谁是新来的、谁是老的、谁离开了。协议不在代码之前定清楚后面接重连和断线清理时一定会乱。下面这张表是 Mesh demo 最常用的最小消息集我用 Socket.IO 实现因为它自带房间概念、断线自动触发 disconnect、还支持 ack 回执省掉自己写事件路由的功夫。消息方向载荷作用join客户端→服务端roomId, name请求入房服务端 ack 返回成员列表joined服务端→客户端memberIds, selfId把已有成员 id 列表回给新加入者peer-joined服务端→老成员id, name通知老成员有新人进来等它的 offeroffer / answer客户端之间sdp交换会话描述ice客户端之间candidate交换 ICE 打洞候选peer-left服务端→全场id触发对端关闭连接和清理 UI这个协议里最关键的决策是「新成员发起所有 offer」。如果老成员和新成员同时发起 offer会触发 WebRTC 的 glare 冲突现代浏览器虽然支持 rollback但状态机要在 stable、have-local-offer 之间来回跳排错成本高。统一让新加入者当 offerer老成员只回 answer状态只在 stable 和 have-local-offer 两个值之间切换几乎不会出竞态。2.3 STUN/TURN 选型与地址暴露预期iceServers 配置是 demo 最容易漏的部分漏掉它会在受限网络里看到连接永远停在 connecting。STUN 的作用是让两端知道各自的公网地址ICE 再按 host、srflx、relay 三种候选类型寻找可用路径打洞失败时TURN 中继是最后的兜底。最简配置如下const iceConfig { iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: turn:turn.example.com:3478, username: demo, credential: demo-secret } ] };公共 STUN 服务器由 Google 免费提供对 demo 来说够用但生产环境有并发限制最好自建。另一个容易忽略的点是地址暴露预期ICE 协商过程中本机公网地址会被写入候选并发给房间内其他成员这是 WebRTC 协议本身的特性对地址可见性敏感的业务需要自建 TURN并通过配置把 host 类型候选关掉。3. 用 Vue 3 Socket.IO 搭信令通道跑通第一条 RTCPeerConnection3.1 初始化 Vue 项目并安装 socket.io-client 依赖先搭一个干净的 Vue 3 工程这一步对应的就是热词里常搜的「vue 安装依赖」。项目脚手架用 Vite命令如下npm create vitelatest rtc-room -- --template vue cd rtc-room npm install npm install socket.io-clientsocket.io-client 的版本必须和信令服务端的 socket.io 大版本一致都用 4.x否则握手协议不兼容控制台会出现The client is using an unsupported version of the Socket.IO or Engine.IO protocols的报错。开发环境下 Vite 跑在 5173 端口信令服务跑在 3000 端口跨域交给服务端 CORS 处理前端通过环境变量VITE_SIGNAL_URL指定信令地址。3.2 信令服务端一个文件完成转发与房间管理服务端只需要做三件事维护房间成员表、转发 offer/answer/ice、广播离开事件。完整实现如下// signaling.js——Socket.IO 信令服务只转发 JSON不碰媒体流 const http require(http); const { Server } require(socket.io); const server http.createServer(); const io new Server(server, { cors: { origin: process.env.CORS_ORIGIN || * } }); const rooms new Map(); // roomId - MapsocketId, memberInfo io.on(connection, (socket) { socket.on(join, ({ roomId, name }, ack) { socket.join(roomId); if (!rooms.has(roomId)) rooms.set(roomId, new Map()); rooms.get(roomId).set(socket.id, { name }); socket.data.roomId roomId; const memberIds [...rooms.get(roomId).keys()].filter((id) id ! socket.id); ack({ memberIds, selfId: socket.id }); // ack 保证成员表已更新再回执 socket.to(roomId).emit(peer-joined, { id: socket.id, name }); }); socket.on(offer, ({ to, sdp }) socket.to(to).emit(offer, { from: socket.id, sdp })); socket.on(answer, ({ to, sdp }) socket.to(to).emit(answer, { from: socket.id, sdp })); socket.on(ice, ({ to, candidate }) socket.to(to).emit(ice, { from: socket.id, candidate })); socket.on(disconnect, () { const roomId socket.data.roomId; if (!roomId) return; socket.to(roomId).emit(peer-left, { id: socket.id }); const room rooms.get(roomId); room?.delete(socket.id); if (room room.size 0) rooms.delete(roomId); // 空房间回收防 Map 无限增长 }); }); server.listen(3000, () console.log(signaling server on 3000));这里有两个参数层面的细节socket.to(roomId).emit会把消息发给房间内除自己以外的所有成员所以peer-jjoined不会回传给新成员自己ack({ memberIds })回调保证了前端拿到成员列表时服务端的 rooms 表已经更新完offer 不会比成员列表先到。3.3 封装 useRTC把连接生命周期收进 composableVue 组件里直接操作 RTCPeerConnection 会让模板和连接逻辑耦合在一起可读性差。我一般把 WebRTC 的全部生命周期收进一个 composable组件只负责调函数和渲染。这个文件是整个 demo 源码里最值得抄的 webrtc 实例// src/composables/useRTC.js import { ref } from vue; import { io } from socket.io-client; const SIGNAL_URL import.meta.env.VITE_SIGNAL_URL || http://localhost:3000; export function useRTC() { const socket io(SIGNAL_URL, { transports: [websocket] }); const peers new Map(); // peerId - RTCPeerConnection const pendingCandidates new Map(); // peerId - RTCIceCandidate[] const localStream ref(null); const remoteStreams ref(new Map()); // peerId - MediaStream const iceConfig { iceServers: [{ urls: stun:stun.l.google.com:19302 }] }; async function getLocalMedia() { if (localStream.value) return localStream.value; localStream.value await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); return localStream.value; } function createPeer(peerId) { const pc new RTCPeerConnection(iceConfig); localStream.value?.getTracks().forEach((t) pc.addTrack(t, localStream.value)); pc.ontrack (e) { const next new Map(remoteStreams.value); next.set(peerId, e.streams[0]); // 每次都用新 Map 替换触发 Vue 响应式 remoteStreams.value next; }; pc.onicecandidate (e) { if (e.candidate) socket.emit(ice, { to: peerId, candidate: e.candidate }); }; peers.set(peerId, pc); return pc; } function flushPending(peerId) { const list pendingCandidates.get(peerId) || []; pendingCandidates.delete(peerId); const pc peers.get(peerId); if (pc) list.forEach((c) pc.addIceCandidate(c).catch(console.warn)); } async function handleOffer(from, sdp) { const pc peers.get(from) || createPeer(from); await pc.setRemoteDescription(sdp); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit(answer, { to: from, sdp: pc.localDescription }); flushPending(from); } async function handleAnswer(from, sdp) { const pc peers.get(from); if (!pc || pc.signalingState ! have-local-offer) return; await pc.setRemoteDescription(sdp); flushPending(from); } async function handleIce(from, candidate) { const pc peers.get(from); if (!pc) return; if (pc.remoteDescription) await pc.addIceCandidate(candidate); else pendingCandidates.set(from, [...(pendingCandidates.get(from) || []), candidate]); } async function offerTo(peerId) { const pc createPeer(peerId); const offer await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: true }); await pc.setLocalDescription(offer); socket.emit(offer, { to: peerId, sdp: pc.localDescription }); } function closePeer(peerId) { const pc peers.get(peerId); if (pc) { pc.ontrack null; pc.onicecandidate null; pc.close(); } peers.delete(peerId); pendingCandidates.delete(peerId); const next new Map(remoteStreams.value); next.delete(peerId); remoteStreams.value next; } function init() { socket.on(offer, ({ from, sdp }) handleOffer(from, sdp)); socket.on(answer, ({ from, sdp }) handleAnswer(from, sdp)); socket.on(ice, ({ from, candidate }) handleIce(from, candidate)); return socket; } return { socket, localStream, remoteStreams, getLocalMedia, offerTo, closePeer, init }; }这些 js 函数的设计意图值得逐一说清remoteStreams每次重新 new 一个 Map 再赋值是为了让 Vue 能追踪到变化直接remoteStreams.value.set()改同一个引用不会触发更新pendingCandidates处理的是打洞候选先于远端描述到达的情况ICE 候选往往比 SDP 协商结果更早到没设置 remoteDescription 时调用 addIceCandidate 会抛 InvalidStateErrorhandleAnswer里先判断 signalingState避免重复的 answer 把状态机打乱。事件与处理函数的对应关系如下排错时按这张表对号入座服务端事件处理函数最常见的失败点offerhandleOffersdp 重复设置需检查 signalingStateanswerhandleAnswer状态不是 have-local-offer 时直接 returnicehandleIceremoteDescription 为空时是否进了 pending 队列3.4 Vue 3 绑定 srcObject 的两个关键坑模板里渲染视频流是这个 demo 最容易出低级问题的地方。Vue 3 可以直接用:srcObject绑定 MediaStream 对象因为 Vue 的 patch 逻辑会把非字符串值作为 DOM 属性设置而不是 attributetemplate video v-foritem in streamItems :keyitem.peerId :srcObjectitem.stream autoplay playsinline /video /template script setup import { computed } from vue; const props defineProps({ streamMap: { type: Map, required: true } }); const streamItems computed(() [...props.streamMap.entries()].map(([peerId, stream]) ({ peerId, stream })) ); /script第一个坑是有人沿用老写法:srcURL.createObjectURL(stream)在 Chrome 里 createObjectURL 对 MediaStream 的支持已被标记废弃而且每次渲染都会生成新的 blob URL 造成内存泄漏统一用:srcObject即可。第二个坑是同一个 peer 重协商后ontrack里拿到的可能是同一个 stream 对象引用没变 Vue 就不重新赋值画面会冻在旧帧所以上面的代码每次都 new Map配合:keypeerId必要的时候手动el.srcObject null; el.srcObject stream强制刷新。autoplay和playsinline必须成对出现否则 iOS Safari 会黑屏。4. 房间内全互连加入、应答、离开释放的核心循环4.1 新成员加入拿到成员列表后逐个发起 offervue 项目实战里最常见的多人房间实现入房逻辑写在 Room 视图的 setup 里。新成员先用emitWithAck拿到成员列表再对每个已有成员调一次offerTo// src/views/Room.vue 的 setup 片段 import { ref } from vue; import { useRTC } from ../composables/useRTC; const { socket, localStream, remoteStreams, getLocalMedia, offerTo, closePeer, init } useRTC(); init(); const roomId ref(demo-room); const joined ref(false); async function joinRoom(name) { await getLocalMedia(); // 必须先拿到本地流addTrack 才有内容可加 const res await socket.emitWithAck(join, { roomId: roomId.value, name }); for (const pid of res.memberIds) { await offerTo(pid); // 逐个发起避免并发 createOffer 互相干扰 } joined.value true; } socket.on(peer-left, ({ id }) closePeer(id));emitWithAck是 socket.io-client v4.6 之后提供的 Promise 版回执方法旧版本用socket.emit(join, payload, callback)代替。逐个 await 而不是 Promise.all是因为同时触发多个 createOffer 会让浏览器在同一时间片里做多次编码协商低端机器会出现明显卡顿demo 阶段逐个更稳。老成员收到peer-joined后什么都不用做等对方的 offer 即可这也再次印证了 2.2 里「新成员主动」的协议约束。4.2 老成员应答answer 与 ontrack 的绑定顺序老成员的应答入口是handleOffer。收到对方的 sdp 后先setRemoteDescription再createAnswer并setLocalDescription最后发送 answer。这里最容易被忽略的是顺序必须在 setRemoteDescription 之后才 flush 缓存的 ICE 候选因为 addIceCandidate 依赖远端描述已就位。signalingState 的合法流转如下signalingState含义允许的操作stable没有进行中的协商可 createOffer可 setRemoteDescription(offer)have-local-offer已发 offer 未收 answer只能 setRemoteDescription(answer)have-remote-offer收到 offer 未回 answercreateAnswer 后回到 stableclosed连接已关闭任何描述操作都会抛错ontrack的绑定也要在addTrack之后立刻发生因为本地流一旦 addTrack远端任何媒体轨到达都会触发回调。绑定完成前的 track 事件会丢失这也是很多人「能看到自己但看不到别人」的原因createPeer 里先 addTrack 再挂 ontrack 的顺序不能倒。4.3 成员离开close 连接并释放摄像头资源peer-left 事件触发 closePeer代码在 3.3 里已经给出。这段逻辑虽然在 demo 里看起来很短但它决定了多人房间长时间开着会不会卡死RTCPeerConnection 不 close 会持续占用网络栈资源解码器实例也不会释放。close 时把 ontrack 和 onicecandidate 置空是为了防止关闭瞬间的异步事件继续回调到 Vue 的响应式数据里。还有一个高频问题摄像头灯不灭。原因是本地流的 MediaStreamTrack 没有 stop。当房间里所有人都离开时应该把本地流整个关掉import { watch } from vue; watch(remoteStreams, (m) { if (m.size 0 localStream.value) { localStream.value.getTracks().forEach((t) t.stop()); // 关摄像头灯 localStream.value null; } });track.stop()会真正释放采集设备而不是仅仅停止传输。测试环境里如果发现换房间后摄像头灯还亮着基本就是这里没做。4.4 多人视频网格渲染与回声控制网格布局用一个 CSS Grid 就能覆盖 2 到 9 人不用引第三方组件库template div classgrid video v-foritem in streamItems :keyitem.peerId :srcObjectitem.stream autoplay playsinline /video video :srcObjectlocalStream autoplay playsinline muted/video /div /template style scoped .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 8px; background: #111; min-height: 480px; } video { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } /style注意本地预览的 video 必须加muted远端视频绝对不能加。muted 的作用是让本地扬声器不播放自己的声音避免麦克风又把这路声音收回去形成回声和啸叫。另一个细节是 getUserMedia 必须在用户点击事件的调用链里触发否则浏览器自动播放策略会拦住非静音的视频流这也是入房按钮必须真实可点击而不是页面加载自动 join 的原因。5. 用 getStats 做链路容量估计动态收敛上行码率5.1 采样两个核心维度的参数多人互动跑通只是第一步真正影响体验的是弱网下的链路容量估计。WebRTC 内部有拥塞控制但 demo 里往往要自己读指标来做 UI 提示或主动降级。getStats 按 report type 区分数据我只采样两个维度inbound-rtp看接收质量candidate-pair看连接往返时延。// statsMonitor.js——每 2 秒采样一次 export async function sampleVideoStats(pc) { const stats await pc.getStats(); const out { jitter: 0, packetsLost: 0, fps: 0, rtt: 0 }; stats.forEach((s) { if (s.type inbound-rtp s.kind video) { out.jitter s.jitter * 1000; // 秒转毫秒更直观 out.packetsLost s.packetsLost; out.fps s.framesPerSecond || 0; } if (s.type candidate-pair s.state succeeded) { out.rtt s.currentRoundTripTime * 1000; // 秒转毫秒 } }); return out; }注意jitter和currentRoundTripTime的单位都是秒不换算会得到一堆接近 0 的小数导致阈值判断全部失灵。framesPerSecond在画面暂停或卡顿时会掉到 0要配合连续多次采样再下结论。5.2 用 setParameters 给发送端上码率锁采样到弱网后能立刻见效的动作是限制本端上行码率而不是等 WebRTC 内置拥塞控制慢慢收敛。通过 RTCRtpSender 的 setParameters 可以直接锁 maxBitrateexport async function clampOutboundBitrate(pc, maxBps) { const sender pc.getSenders().find((s) s.track s.track.kind video); if (!sender) return; const params sender.getParameters(); if (!params.encodings || params.encodings.length 0) return; params.degradationPreference maintain-framerate; // 保帧率降分辨率 params.encodings[0].maxBitrate maxBps; // 单位 bps如 400_000 即 400kbps await sender.setParameters(params); }degradationPreference有三个取值balanced默认、maintain-framerate优先保帧率、maintain-resolution优先保分辨率。互动场景里掉帧比掉分辨率更影响体验所以选 maintain-framerate。maxBitrate 的单位是 bps很容易把 400kbps 写成 400结果就是弱网时码率反而被放开一定要带三个零。5.3 降级策略表与 chrome://webrtc-internals 验证把采样和限速组合起来按连续采样的结果做阶梯降级比一次降到最低更稳连续 3 次采样条件动作参数取值fps 15 且 packetsLost 上升锁上行 600kbpsmaxBitrate 600_0005 秒后仍 fps 12续降 300kbpsmaxBitrate 300_000rtt 连续 300ms暂停视频轨道保音频sender.replaceTrack(null)指标恢复正常 10 秒逐级放开600_000 → 1_000_000replaceTrack(null)是暂停发送视频但不关摄像头的标准做法比 stop track 温和恢复时replaceTrack(原track)即可把画面找回来。调这套参数时Chrome 的chrome://webrtc-internals比 vue devtools 更有用Vue 侧只能看到响应式的 Map 变化而 peer 的 signalingState、iceConnectionState、inbound-rtp 这些指标都活在 Vue 之外。打开 chrome://webrtc-internals找到对应连接的inbound-rtp区块把framesPerSecond、jitter、packetsLost这几个字段名和上面采样代码里的 key 对照一遍哪个字段在哪个浏览器版本里改名或废弃一眼就能看出来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价