简介:面向前端开发者的 Vue 与 WebRTC 多人互动 Demo 源码,旨在帮助读者掌握浏览器实时音视频通信的核心实现与完整调试流程。项目以 Vue 组件组织界面,通过 RTCPeerConnection、getUserMedia 完成多端摄像头和麦克风采集、P2P 连接建立,并基于 Node.js + Socket.IO 搭建信令服务,完整覆盖 offer/answer、ICE 候选交换以及 STUN/TURN 穿透流程;不仅支持点对点通话,也提供房间内多人广播逻辑和 DataChannels 文本传输示例。配套内容还包含 Socket.IO 在 WebSocket 之外的兼容降级方式,以及 HTTPS 内网环境下手机调试的配置思路,便于开发者在真实移动设备上验证通话效果,对学习 NAT 穿透、信令机制和 Vue 集成都有直接帮助。压缩包共 18 个文件,以 js 逻辑脚本、vue 视图组件和 json 项目配置为主,整体仅 113KB,结构紧凑、目录清晰,适合快速通读和二次改造。当前已有 703 人学习下载,是理解 WebRTC 信令机制与 Vue 集成方式的实用参考。

1. 这个标题背后,是一次完整的 WebRTC 多人通话落地

搜「js webrtc多人互动【vue demo源码】」的人,通常不是要看 WebRTC 协议文档,而是想找一个能在 Vue 项目里直接跑起来的多人实时互动方案:视频会议、在线自习室、远程答辩、虚拟见面会都属于这类场景。WebRTC 解决的是浏览器之间点对点的音视频传输,Vue 负责把连接状态和媒体流渲染成界面,而「多人互动」这个限定词意味着不能只写一对一的 RTCPeerConnection ,还要处理房间、用户进出、N 路连接并存。这篇文会把一条最常用的落地路径拆开:先用 Vite 起 Vue 项目并拿到本地摄像头流,再写一个 Socket.IO 信令服务完成双端协商,最后把单连接扩展成 Mesh 多连接,并对几个最常见的坑给出验证手段。适合刚接手实时音视频需求的前端,也适合后端工程师想快速理解信令协议到底在传什么。

2. 在 Vue 工程里先跑通本地采集,再谈多人连接

多人互动的第一层不是网络,而是本机的音视频流。很多人一上来就写 RTCPeerConnection ,结果画面黑屏、麦克风没声音,最后发现问题出在媒体约束配置上。先把 Vue 环境和本地采集这段跑通,后面所有连接逻辑才有调试基础。

2.1 用 Vite 创建 Vue 项目并安装 WebRTC 相关依赖

WebRTC 本身是浏览器原生能力,不需要 npm 安装额外的 RTCPeerConnection 包,真正需要装的是信令客户端和服务端。创建项目的命令如下:

npm create vite@latest webrtc-vue-demo -- --template vue
cd webrtc-vue-demo
npm install
npm install socket.io-client
npm install -D socket.io

最后一条 socket.io 装成开发依赖,是因为 demo 阶段信令服务器放在项目根目录下一起维护,部署时前后端会拆开。如果你打算单独建 server 目录,也可以把 socket.io 放到独立 package.json 里。

npm create vite 用的是官方脚手架,生成的是 Vue 3 组合式 API 的项目结构; socket.io-client 给浏览器端提供连接信令服务器的能力。这里没有引入任何 UI 库,多人互动页面用原生 video 标签和按钮就够看效果,也方便你替换成自己的样式。

2.2 用 getUserMedia 绑定摄像头和麦克风

在 Vue 组件里获取本地媒体流,核心是一段 getUserMedia 调用:

// src/composables/useLocalMedia.js
export async function startLocalMedia(videoRef, audioEnabled = true) {
  if (!navigator.mediaDevices?.getUserMedia) {
    throw new Error('当前浏览器不支持 WebRTC 采集');
  }
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: audioEnabled,
    video: {
      width: { ideal: 1280 },
      height: { ideal: 720 },
      facingMode: 'user',
    },
  });
  if (videoRef.value) {
    videoRef.value.srcObject = stream;
    await videoRef.value.play();
  }
  return stream;
}

getUserMedia 返回的是一个 MediaStream 对象,里面包含音频轨和视频轨。绑定到 <video> 元素时,必须用 srcObject 属性而不是 src ,后者只能接受 URL 字符串,无法直接消费 MediaStream。 facingMode: 'user' 表示优先使用前置摄像头,在 PC 上这个参数通常没有影响,在移动端就很有用。调用后返回的 stream 要在组件 onUnmounted 时停止轨道,否则摄像头灯会一直亮着:

stream.getTracks().forEach(track => track.stop());

2.3 媒体约束参数与常见误用

getUserMedia 的约束对象是多人互动视频质量的第一道控制阀,参数不是越多越好。下面这张表列出最常用的几组:

参数 取值示例 作用 说明
audio.echoCancellation true 回声消除 免提场景必须开
audio.noiseSuppression true 噪声抑制 降低背景噪音
video.width { ideal: 1280 } 理想宽度 浏览器会选最接近的分辨率
video.frameRate { ideal: 24 } 理想帧率 多人场景建议 15-24,节省带宽
video.deviceId "default" 指定摄像头 切摄像头时用

不建议在 demo 里写死 width: 1280 这种强制值,因为你没法保证每台设备的摄像头都支持该分辨率。用 ideal 是协商,用数字是强制。多人互动场景还有一个容易被忽略的点:如果 10 个人同时上行 720p,每个客户端的上行带宽压力会指数级增长,所以 demo 阶段用 640x480 或 720p 就够,等画面清晰度确实不够时再调。

2.3.1 本地流预览的最小页面结构
<!-- src/App.vue -->
<template>
  <div class="room-page">
    <video ref="localVideo" autoplay muted playsinline></video>
    <button @click="startLocal">开启摄像头</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { startLocalMedia } from './composables/useLocalMedia';

const localVideo = ref(null);
const localStream = ref(null);

async function startLocal() {
  localStream.value = await startLocalMedia(localVideo);
}
</script>

muted 放在本地预览的 video 上很关键:如果不静音,自己说话的声音会从扬声器出来再被麦克风采进去,形成尖锐回授。逻辑说明放在这里:本地视频是一个自我确认的闭环,它证明摄像头、麦克风、权限、渲染链路都通了,后面的 RTCPeerConnection 只是把这份流复制一份发出去。

3. 双端协商:信令服务与 RTCPeerConnection 的配合

本地流就绪后,下一个问题是:两个浏览器之间怎么知道对方的地址、用什么编码格式、谁先开口。WebRTC 的媒体数据是点对点传输的,但建立连接前的元数据交换需要信令通道。这个信令通道可以自己实现,demo 里最常用的就是 Socket.IO。

3.1 WebRTC 协商流程中的四类信令消息

RTCPeerConnection 的协商本质上交换的是 SDP 和 ICE candidate。SDP 描述的是媒体能力:音视频编码、端口、传输协议;ICE candidate 描述的是网络候选地址。整个协商可以通过四类消息概括:

消息类型 发送方向 载荷内容 接收方处理
offer 呼叫方 → 被呼叫方 呼叫方的 SDP 接收方 setRemoteDescription 后返回 answer
answer 被呼叫方 → 呼叫方 被呼叫方的 SDP 呼叫方 setRemoteDescription
ice-candidate 双方互发 ICE 候选地址 对方 addIceCandidate
leave 任意一方 → 服务端 用户 ID 服务端广播给房间其他人

这四个动作拼起来就是一次完整的呼叫流程:A 创建 offer,B 收下 offer,B 生成 answer,A 收下 answer,双方在 ICE 探测过程中互相补充候选地址。

3.2 用 Socket.IO 实现最小信令服务器

// server/index.js
const { Server } = require('socket.io');
const http = require('http');

const server = http.createServer();
const io = new Server(server, {
  cors: { origin: 'http://localhost:5173' },
});

const rooms = new Map(); // roomId -> Set<socketId>

io.on('connection', (socket) => {
  socket.on('join-room', ({ roomId, userId }) => {
    socket.join(roomId);
    if (!rooms.has(roomId)) rooms.set(roomId, new Set());
    rooms.get(roomId).add(socket.id);

    // 告诉房间内已有成员,有新用户加入
    socket.to(roomId).emit('new-peer', { peerId: socket.id, userId });
  });

  socket.on('signal', (data) => {
    io.to(data.to).emit('signal', {
      from: socket.id,
      type: data.type,
      sdp: data.sdp,
      candidate: data.candidate,
    });
  });

  socket.on('disconnecting', () => {
    const rooms = Array.from(socket.rooms);
    rooms.forEach((roomId) => {
      socket.to(roomId).emit('peer-leave', { peerId: socket.id });
    });
  });
});

server.listen(3001, () => console.log('signaling server on 3001'));

代码逻辑说明: join-room 里用 socket.to(roomId) 而不是 io.to(roomId) ,这样消息只发给房间里除自己以外的其他人,避免自己收到自己的加入通知。 signal 是通用透传通道,offer、answer、ice-candidate 都走这一条消息,靠 data.type 区分,这样信令服务不需要维护复杂路由逻辑。 disconnecting 事件在 socket 断开前触发,此时 socket.rooms 还包含所在房间信息,能拿到后用来广播离开通知。

3.3 Vue 客户端里驱动 RTCPeerConnection

客户端的核心代码可以封装成一个 composable,职责是:监听信令事件、维护本地 RTCPeerConnection、把远程流渲染出来。

// src/composables/usePeerConnection.js
export function usePeerConnection({ socket, localStream, remoteVideoRef }) {
  const pcMap = new Map(); // peerId -> RTCPeerConnection

  function createPeerConnection(peerId) {
    const pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
    });

    localStream.value.getTracks().forEach((track) => {
      pc.addTrack(track, localStream.value);
    });

    pc.ontrack = (event) => {
      if (remoteVideoRef.value[peerId]) {
        remoteVideoRef.value[peerId].srcObject = event.streams[0];
      }
    };

    pc.onicecandidate = (event) => {
      if (event.candidate) {
        socket.emit('signal', {
          to: peerId,
          type: 'ice-candidate',
          candidate: event.candidate,
        });
      }
    };

    pcMap.set(peerId, pc);
    return pc;
  }

  return { createPeerConnection, pcMap };
}

这里有一个重要的顺序问题: ontrack 和 onicecandidate 必须在 createOffer 之前注册。 addTrack 必须在 createOffer 之前调用,否则 SDP 里不会包含媒体轨道信息。

3.4 ICE 候选交换的时序坑与缓存策略

多人互动 demo 最常见的问题是:offer 还没到对方手里,ice-candidate 已经先到了。WebRTC 的 addIceCandidate 如果早于 setRemoteDescription 执行,会直接报 InvalidStateError 。解决方式是在信令处理里加一个缓存队列:

const pendingCandidates = new Map(); // peerId -> RTCIceCandidate[]

async function handleSignal(data) {
  const pc = ensurePeerConnection(data.from);

  if (data.type === 'offer') {
    await pc.setRemoteDescription(data.sdp);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    socket.emit('signal', { to: data.from, type: 'answer', sdp: answer });

    // 补充处理早到的候选
    for (const candidate of pendingCandidates.get(data.from) || []) {
      await pc.addIceCandidate(candidate);
    }
    pendingCandidates.delete(data.from);
  } else if (data.type === 'ice-candidate') {
    if (pc.remoteDescription) {
      await pc.addIceCandidate(data.candidate);
    } else {
      if (!pendingCandidates.has(data.from)) {
        pendingCandidates.set(data.from, []);
      }
      pendingCandidates.get(data.from).push(data.candidate);
    }
  }
}

这段逻辑说明: ensurePeerConnection 负责按需创建连接, remoteDescription 存在与否作为候选是否可消费的判断条件。如果你在多人场景里发现有的人能互相看到、有的人看不到,且 console 里有 InvalidStateError ,基本都是候选早到或 SDP 交换顺序错乱。缓存队列不能无限增长,demo 里可以给每个用户最多缓存 100 个候选,超出后直接丢弃旧的,因为 ICE 候选本身有超时机制。

4. 多人互动的 Mesh 结构与房间化管理

双端连通后,多人互动不是简单地把双端代码复制 N 份,而是要在客户端维护一个动态的连接集合。这一章解决的是「当第 3 个人加入时,前面两个人怎么感知」的问题。

4.1 Mesh 架构与连接数量预算

最常见的多人 WebRTC 架构是 Mesh:每个客户端都和其他所有客户端建立一条独立的 RTCPeerConnection。N 个人就是 N*(N-1)/2 条连接,5 人房间 10 条,10 人房间 45 条。这个增长是平方级的,demo 阶段建议限制在 6 人以内。

房间人数 连接总数 每个客户端持有连接数 上行带宽压力
2 1 1 低
4 6 3 中
6 15 5 高
8 28 7 极高

Mesh 的好处是无需媒体服务器,成本为零,延迟最低;坏处是每个客户端都要编码多份视频流。如果是教学场景,可以只让教师端上行视频,学生端上行音频,这样 20 人也能跑得动。架构选型时不要一开始就上 SFU,先把 Mesh 跑通,再根据人数瓶颈决定是否引入 mediasoup 或 LiveKit。

4.2 房间成员索引与 Peer 管理数据结构

客户端需要维护两个映射表:一个是 pcMap 从 peerId 到 RTCPeerConnection,另一个是 remoteVideoRef 从 peerId 到 video 元素引用。加入房间时的完整流程可以归为三步:

// src/composables/useRoom.js
const peers = ref([]); // 当前房间成员列表

socket.on('new-peer', async ({ peerId }) => {
  peers.value.push(peerId);

  // 新成员是自己,需要向对方发起 offer
  const pc = createPeerConnection(peerId);
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  socket.emit('signal', {
    to: peerId,
    type: 'offer',
    sdp: offer,
  });
});

socket.on('signal', handleSignal);

new-peer 的语义是「有人在我的房间里」,无论收到这个事件的是老成员还是新成员,都要建立连接。但只有被加入时创建连接的一方主动发 offer,另一边在收到 offer 后回 answer。这样做的原因很简单:两端同时 createOffer 会引发 glare 冲突,即双方同时发起协商,谁也说服不了谁。常见的处理方式是约定用 socket.id 做排序,小的一方先发 offer,但 demo 里「后加入者主动发起 offer」更直观,也不需要额外状态。

4.3 处理用户离开与连接清理

离开动作的发生时机比较多样:用户主动关闭页面、刷新浏览器、网络断开。信令服务器的 disconnecting 事件会广播 peer-leave ,客户端收到后的清理逻辑要完整:

socket.on('peer-leave', ({ peerId }) => {
  const pc = pcMap.get(peerId);
  if (pc) {
    pc.getTransceivers().forEach((transceiver) => {
      transceiver.stop();
    });
    pc.close();
    pcMap.delete(peerId);
  }
  peers.value = peers.value.filter(p => p !== peerId);
  if (remoteVideoRef.value[peerId]) {
    remoteVideoRef.value[peerId].srcObject = null;
  }
});

getTransceivers().forEach(t => t.stop()) 的用途是立即停止该连接上所有收发器,这样可以释放摄像头编码资源。只调用 pc.close() 是不够的,close 只是断开网络层,不代表本地轨道停止采集。如果你在多人房间里进进出出多次后发现浏览器 CPU 占用越来越高,大概率是旧连接没有完整释放。

4.3.1 Vue 生命周期里处理组件卸载
onUnmounted(() => {
  pcMap.forEach((pc) => {
    pc.getTransceivers().forEach((t) => t.stop());
    pc.close();
  });
  localStream.value?.getTracks().forEach(track => track.stop());
  socket.disconnect();
});

这里把 socket.disconnect() 放在最后执行,是为了确保清理 RTCPeerConnection 期间产生的信令消息还能正常发出。很多人先断 socket 再关连接,导致 onicecandidate 消息发不出去,对面就会卡在连接中状态。

4.4 多人场景下的视频网格渲染

多人视频界面最常见的是宫格布局,动态遍历 peers 数组生成 video 标签即可:

<div class="video-grid">
  <video
    v-for="peerId in peers"
    :key="peerId"
    :ref="(el) => setRemoteVideoRef(el, peerId)"
    autoplay
    playsinline
  ></video>
</div>
function setRemoteVideoRef(el, peerId) {
  if (!el || !remoteVideoRef.value) return;
  remoteVideoRef.value[peerId] = el;
}

vue 的函数式 ref 回调绑定 remoteVideoRef ,比用 :ref 字符串更安全,因为 peerId 是动态的。注意 autoplay 策略:如果 video 元素没有 muted 属性,浏览器会禁止自动播放。远端视频不能设 muted,所以必须在 ontrack 里调用 video.play() ,这段逻辑在 usePeerConnection 的 ontrack 中已经包含。

5. 验证连接状态与分析 ICE 候选,定位连不通的原因

多人互动跑通后,判断「是否真的连上了」不能只看画面有没有出来。画面出来了说明媒体流通了,但用户的摄像头可能是黑屏、可能是花屏、也可能是单向通话。排查这些问题的核心工具不是 console.log,而是浏览器内置的 WebRTC 调试面板。

5.1 用 chrome://webrtc-internals 看连接细节

当页面运行后,打开 Chrome 地址栏输入 chrome://webrtc-internals ,选择一个页面标签,可以看到所有 RTCPeerConnection 的统计。重点看三个地方:

  • ICE Connection State :应稳定在 connected 或 completed
  • Candidate Pair :应按传输类型排序, srflx 优先于 relay , host 只在同一局域网内合理
  • Bitrate for outgoing video :如果人数多、带宽不够,这个数值会持续下降

如果 ICE Connection State 卡在 checking 超过 10 秒,说明 ICE 无法找到可用路径。优先检查 STUN 服务器是否可达:

curl https://stun.l.google.com:19302

如果 curl 输出为空或超时,说明网络对 STUN 不完全开放。注意:checking 状态不代表失败,ICE 会并发探测多个候选对,等待 10 秒以上再判断。

5.2 ICE 候选类型的优先级与取舍

ICE 候选有四种类型,按可靠性从高到低排列如下:

候选类型 来源 适用场景 优先级
host 本机网卡 IP 同一局域网内 最高
srflx STUN 反射地址 公网 IP 高
prflx 对端发来的 peer reflexive 对称 NAT 后 中
relay TURN 中继服务器 全锥形 NAT 失败后 低

多人互动 demo 中,如果你在同一个 wifi 下测试,host 候选足够。但要跨网络联调,STUN 必须配置;如果发现两方都配了 STUN 仍然无法连接,就到 TURN 兜底。TURN 需要单独部署 coturn,部署后把 iceServers 数组扩展成 STUN + TURN 两组:

iceServers: [
  { urls: 'stun:stun.l.google.com:19302' },
  {
    urls: 'turn:your-turn-server:3478',
    username: 'demo',
    credential: 'demo-pass',
  },
]

TURN 凭据不宜硬编码在 Vue 源码里,生产环境要通过 HTTP 接口鉴权后下发临时凭据。

5.3 让 WebRTC 连接状态可视化,及时发现问题

在开发阶段,把连接状态机展示在界面上能大幅缩短排查时间。RTCPeerConnection 的 connectionState 会依次经历 new → connecting → connected → disconnected → failed ,监听变化并渲染成标签:

pc.onconnectionstatechange = () => {
  console.log(`[peer ${peerId}] connection state:`, pc.connectionState);
};

更实用的做法是把这些日志统一收集到一个 debugLogs 数组中再渲染到页面角落,这样当你用手机访问页面进行联调时,也能看到另一端的日志摘要。WebRTC 多人互动最怕的是远程问题无法复现,把信令消息和连接状态都留下来,比事后猜原因有效得多。卸任童虎项目的蜂群链接时,优先检查 lastIceCandidateType 字段:如果一直是 relay 且线路是公网,说明 STUN 生效但 NAT 类型确实严格,这个结果可以指导你决定是否值得部署 TURN 服务器。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐