简介:这是一套基于 WebRTC 实现的轻量级视频会议应用完整源码,面向计算机相关专业学生(如计科、人工智能、物联网、通信等)及初入前端/音视频开发领域的学习者,解决实时音视频通信基础功能集成与项目落地实践问题,适用于课程设计、毕业设计原型开发或技术验证场景。压缩包共94个文件,以48个JavaScript核心逻辑文件为主,辅以7个Vue组件、4个HTML入口页、6个JSON配置与5个Markdown说明文档,涵盖从信令服务(AppRTC-node-server)、前端路由与状态管理到UI资源(PNG/SVG)的完整结构,整体仅299KB,便于快速导入与本地调试。目前已有48人学习下载,资源经实测可正常运行,包含详细的中文README说明、开发环境配置指南及多环境构建脚本(webpack.dev/prod.conf.js等),读者可直接复现双端视频通话、媒体流控制与基础信令交互全流程,并深入理解WebRTC在真实项目中的模块组织方式与工程化实践要点。

1. 这不是一个“能跑就行”的 WebRTC Demo,而是一套可调试、可拆解、可嵌入真实项目的视频会议最小可行架构

很多同学拿到 WebRTC 视频会议源码,第一反应是 npm install && npm run dev —— 页面弹出来,两个标签页互相看到对方,就以为“成了”。但真正卡住的从来不是“能不能连”,而是“为什么连不上”“为什么只有音频没视频”“为什么在 Chrome 里正常,Edge 里黑屏”“怎么加个屏幕共享按钮”“如何把这堆逻辑塞进我自己的 Vue 管理后台里”。这个 rtc-demo 项目不是玩具,它用 Vue 2 + Node.js(AppRTC-node-server)双端协同,完整实现了信令交换、SDP 协商、ICE 候选收集与交换、媒体流绑定、本地预览与远端渲染等核心链路。所有关键路径都做了日志打点(如 console.log('[RTC] offer created') ),错误分支有明确 fallback(比如 oniceconnectionstatechange 切到 failed 时触发重连提示),且服务端不依赖任何云厂商 SDK,纯原生 WebSocket + Express 实现信令中转。它适合两类人:一类是课程作业/毕设需要快速验证 WebRTC 端到端流程的同学;另一类是已有 Vue 项目想低成本集成实时音视频能力的前端工程师——你不需要重写整个 UI,只要理解 app.js 中 RTCPeerConnection 的生命周期钩子和 src/components/VideoView.vue 的流绑定方式,就能抽离出可复用的 useWebRTC 组合式函数。


2. 从信令服务器启动到本地媒体流获取:Vue 前端与 Node 后端的协作边界必须厘清

WebRTC 的本质是 P2P,但 P2P 前必须完成“破冰”:双方要知道彼此的网络地址(ICE Candidate)、媒体能力(SDP Offer/Answer)。这个“破冰”过程不能靠浏览器自己完成,必须由一个第三方服务(即信令服务器)中转消息。本项目中, bin/www 是 Express 启动入口, routes/index.js 定义了 /join 和 /room 两个基础路由,而真正的信令通道由 app.js 中的 io.on('connection') 处理。注意:它 没有使用 Socket.IO 的房间自动管理 ,而是手动维护 rooms 对象,每个房间存储当前连接的 socket ID 列表。这种设计看似原始,却极大降低了调试复杂度——你可以在 console.log('room', roomId, 'has', rooms[roomId].length, 'peers') 直接看到房间状态,而不是被 Socket.IO 的抽象层遮蔽。

2.1 启动并验证信令服务:确保 WebSocket 可达是后续一切的前提

项目根目录下执行:

cd rtc-demo && npm install
npm start

提示: npm start 实际调用 node ./bin/www ,监听 3000 端口。若端口被占用,需修改 bin/www 第 18 行 port = normalizePort(process.env.PORT || '3000'); 。启动后访问 http://localhost:3000 应返回 Express server running on port 3000 ,而非 404。此时打开浏览器开发者工具 Network 标签页,过滤 ws ,新建标签页访问 http://localhost:3000/room/test ,应能看到 WebSocket connection to 'ws://localhost:3000/socket.io/?EIO=4&transport=websocket' 成功建立。若显示 Failed to load resource ,说明 Express 未正确挂载 Socket.IO 中间件——检查 app.js 第 35 行是否为 const io = require('socket.io')(server); ,且第 42 行 io.on('connection', ...) 是否在 server.listen() 之后。

2.2 Vue 前端媒体初始化:getUserMedia 的权限、约束与降级策略

src/App.vue 的 mounted() 钩子中调用了 initLocalStream() ,其核心是 navigator.mediaDevices.getUserMedia({ video: true, audio: true }) 。但生产环境必须处理三类失败场景:

  • 权限拒绝 (用户点击“拒绝”): catch 中触发 this.$message.error('请允许摄像头和麦克风访问') ;
  • 设备不可用 (无摄像头): then 后需检查 stream.getVideoTracks().length === 0 ,此时应禁用视频按钮并提示“检测到无可用视频设备”;
  • HTTPS 强制要求 : getUserMedia 在非 HTTPS 或 localhost 外的 HTTP 页面会被浏览器直接拒绝。本项目 package.json 中 "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js" 启动的是 http://localhost:8080 ,符合要求;但若部署到 Nginx,必须配置 SSL,否则 getUserMedia 抛出 NotAllowedError 。
// src/utils.js 中 extractMediaConstraints 函数
export function extractMediaConstraints (options) {
  const constraints = {}
  if (options.video) {
    constraints.video = typeof options.video === 'object' 
      ? options.video // 允许传入 { width: { ideal: 1280 }, height: { ideal: 720 } }
      : true
  }
  if (options.audio) {
    constraints.audio = typeof options.audio === 'object' 
      ? options.audio // 如 { echoCancellation: true, noiseSuppression: true }
      : true
  }
  return constraints
}

参数说明: video: { width: { ideal: 1280 } } 表示“理想宽度为 1280px”,浏览器会尽力满足但不保证; audio: { echoCancellation: true } 启用回声消除,对会议质量至关重要,若不设置,多人会议时易出现啸叫。该函数被 app.js 中 getLocalStream() 调用,是约束参数统一入口。

2.3 信令交互协议设计:为什么用 JSON 而不用二进制?字段命名如何避免歧义?

app.js 中定义了 5 种信令消息类型: join , leave , offer , answer , candidate 。所有消息均为 JSON 格式,结构高度扁平:

{ "type": "offer", "roomId": "test", "sdp": "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1..." }

注意: sdp 字段值是原始 SDP 字符串, 未做 Base64 编码 。这是刻意为之——便于调试时直接在控制台 console.log(msg.sdp) 查看媒体行(m=video 1234 RTP/AVP 120),也方便用 sdp-transform 库解析修改(如动态禁用 H.264,强制 VP8)。若项目需传输大 candidate(如 IPv6 地址),才考虑 Base64。字段名全部小写+下划线( room_id 会被视为错误),与 WebSocket 消息规范一致,避免 Vue 组件中 msg.roomId 与后端 room_id 不匹配导致 join 失败。


3. SDP 协商与 ICE 连接:理解 offer/answer 流程中的状态机与超时控制

WebRTC 连接建立不是原子操作,而是跨越多个异步事件的状态跃迁。本项目将 RTCPeerConnection 实例挂载在 Vue 实例上( this.pc = new RTCPeerConnection(config) ),并通过 pc.oniceconnectionstatechange 、 pc.onsignalingstatechange 等事件驱动 UI 状态更新。关键在于: 必须等待 pc.signalingState === 'stable' 才能发送下一个 offer ,否则 Chrome 会抛出 InvalidStateError 。项目在 app.js 的 createOffer() 方法中加入了显式判断:

if (this.pc.signalingState !== 'stable') {
  console.warn('[RTC] Cannot create offer: signalingState is', this.pc.signalingState)
  return
}

逻辑说明: signalingState 有 stable / have-local-offer / have-remote-offer / have-local-pranswer / have-remote-pranswer / closed 六种状态。当 A 发送 offer 后,A 的状态变为 have-local-offer ,B 收到后调用 setRemoteDescription(offer) ,B 的状态变为 have-remote-offer ,此时 B 才能安全调用 createAnswer() 。若忽略此检查,连续点击“开始会议”按钮会导致状态错乱,连接永远无法建立。

3.1 ICE 候选收集的时机与去重:为什么 onicecandidate 可能触发多次?

RTCPeerConnection 在收集到一个 ICE 候选(如 candidate:1234567890 1 udp 2122260223 192.168.1.100 54321 typ host generation 0 )时触发 onicecandidate 事件。本项目在 app.js 的 setupPeerConnection() 中注册该事件:

this.pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 发送给信令服务器,由其广播给房间内其他成员
    this.socket.emit('candidate', {
      roomId: this.roomId,
      candidate: event.candidate
    })
  }
}

参数说明: event.candidate 是 RTCIceCandidate 对象, candidate 属性是字符串。注意 if (event.candidate) 判断——当候选收集结束时, event.candidate 为 null ,此时不应发送空消息。项目未实现候选去重,因 WebRTC 栈自身已保证同一 candidate 不会重复触发。但若网络抖动导致信令服务器重复投递,远端需在 pc.addIceCandidate() 前校验 candidate 是否已存在(可通过 pc.getConfiguration().iceServers 中的 urls 判断是否为同一 STUN/TURN 服务器生成)。

3.2 STUN/TURN 服务器配置:为什么默认只配 STUN?何时必须引入 TURN?

build/webpack.base.conf.js 中 const pcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] } 仅配置了 Google 公共 STUN 服务器。STUN 用于获取公网 IP 和端口,但 无法穿透对称型 NAT (企业防火墙常见)。此时必须部署 TURN 服务器(如 Coturn),并在 iceServers 中追加:

{
  urls: 'turn:your-turn-server.com:3478',
  username: 'user',
  credential: 'pass'
}

验证方法:在 Chrome 访问 chrome://webrtc-internals ,发起一次连接后,点击 GetUserMedia 下的 getStats() ,筛选 candidate-pair 类型,观察 state 字段。若长期为 checking 或 failed ,且 localCandidateType 为 relay (而非 host 或 srflx ),说明 STUN 失败,需 TURN。本项目未内置 TURN,因 Coturn 部署复杂度高,但 pcConfig 已预留扩展点——只需修改 build/webpack.base.conf.js 并重新打包,无需改业务逻辑。

3.3 远端流绑定与自动播放: <video> 标签的 autoplay 为何有时失效?

src/components/VideoView.vue 中,远端视频通过 :srcObject="remoteStream" 绑定:

<video 
  ref="remoteVideo" 
  :srcObject="remoteStream" 
  autoplay 
  muted 
  playsinline 
  class="video-remote"
/>

注意: autoplay 失效的主因是浏览器策略——若页面无用户手势(如 click), <video> 的 autoplay 会被静音阻止。本项目在 app.js 的 addRemoteStream() 中显式调用 video.play() :

if (this.$refs.remoteVideo) {
  this.$refs.remoteVideo.srcObject = stream
  this.$nextTick(() => {
    this.$refs.remoteVideo.play().catch(e => {
      console.error('[Video] Auto play failed:', e)
      // 触发用户点击提示
      this.showPlayHint = true
    })
  })
}

playsinline 属性确保 iOS Safari 在页面内播放而非全屏, muted 是绕过自动播放限制的必要条件(即使远端是纯视频流,也需加 muted )。 this.$nextTick() 确保 DOM 更新后再调用 play() ,避免 srcObject 未生效导致 play() 报错。


4. 将 WebRTC 逻辑解耦为 Vue Composition API:从 Options API 到 useWebRTC 的重构路径

Vue 2 项目虽未原生支持 Composition API,但可通过 @vue/composition-api 插件引入。本项目 src/utils.js 已预留 useWebRTC 函数骨架,其目标是将 app.js 中的 RTCPeerConnection 管理、信令收发、流绑定等逻辑封装为可复用的 Hook。重构不是为了炫技,而是解决三个实际问题:

  1. 多组件复用 :当前 App.vue 承担全部逻辑,若需在 MeetingRoom.vue 和 ScreenShare.vue 中分别管理不同连接,代码将严重重复;
  2. 测试友好 :Options API 的 this 上下文难以单元测试,而 useWebRTC 返回纯函数,可直接 Jest Mock navigator.mediaDevices ;
  3. 状态隔离 :多个会议实例需独立的 pc 实例和信令通道,Composition API 天然支持闭包隔离。

4.1 useWebRTC 的核心接口设计:暴露哪些方法?隐藏哪些细节?

// src/composables/useWebRTC.js
export function useWebRTC (roomId, socket) {
  const localStream = ref(null)
  const remoteStream = ref(null)
  const pc = ref(null)
  const isConnecting = ref(false)

  const init = async () => {
    localStream.value = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
  }

  const createOffer = async () => {
    if (!pc.value) throw new Error('PeerConnection not initialized')
    const offer = await pc.value.createOffer()
    await pc.value.setLocalDescription(offer)
    socket.emit('offer', { roomId, sdp: offer.sdp })
  }

  const addRemoteStream = (stream) => {
    remoteStream.value = stream
  }

  return {
    localStream,
    remoteStream,
    isConnecting,
    init,
    createOffer,
    addRemoteStream
  }
}

关键设计点: socket 作为参数注入,而非在 Hook 内部 import socket from '@/utils/socket' ,确保不同业务模块可传入各自配置的 Socket 实例(如带 token 认证的 io('wss://api.example.com', { auth: { token } }) )。 addRemoteStream 不直接操作 DOM,而是更新 ref ,由组件通过 v-bind:srcObject="remoteStream" 响应式绑定,符合 Vue 数据驱动思想。

4.2 在现有组件中迁移:三步完成 App.vue 的轻量化改造

第一步:安装插件(若未安装)

npm install @vue/composition-api

在 src/main.js 顶部添加:

import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)

第二步:在 App.vue 中替换逻辑

<script>
import { useWebRTC } from '@/composables/useWebRTC'
import io from 'socket.io-client'

export default {
  name: 'App',
  setup() {
    const socket = io('http://localhost:3000')
    const { localStream, remoteStream, init, createOffer } = useWebRTC('test', socket)

    const handleStart = async () => {
      await init()
      createOffer()
    }

    return {
      localStream,
      remoteStream,
      handleStart
    }
  }
}
</script>

第三步:同步更新模板

<!-- 移除原 <video> 的 :srcObject="localStream" -->
<video :srcObject="localStream" autoplay muted playsinline />
<video :srcObject="remoteStream" autoplay muted playsinline />
<button @click="handleStart">开始会议</button>

优势: App.vue 的 <script> 部分代码量减少 60%,所有 WebRTC 逻辑集中在 useWebRTC.js ,后续新增屏幕共享只需在 Hook 中扩展 createScreenShareOffer() 方法,不影响 UI 组件。


5. 排查连接失败的五大必查项:从信令日志到 Chrome WebRTC Internals 的逐层定位法

当两个标签页无法建立视频连接,不要急于重装依赖或怀疑源码有 bug。按以下顺序逐层验证,90% 的问题可在 5 分钟内定位:

5.1 信令层:确认 offer/answer/candidate 消息是否完整到达

在 app.js 的 socket.on('offer') 、 socket.on('answer') 、 socket.on('candidate') 回调开头添加日志:

socket.on('offer', (msg) => {
  console.log('[SIGNAL] Received OFFER for room', msg.roomId, 'from', socket.id)
  // 原有逻辑...
})

同时,在 Chrome 开发者工具 Console 中,筛选 SIGNAL ,观察:

  • A 标签页点击“开始”后,是否打印 [SIGNAL] Received OFFER... ?
  • B 标签页加入同一房间后,是否收到该 offer?
  • B 发送 answer 后,A 是否收到?
    若某条消息缺失,说明信令服务器未正确广播——检查 rooms[roomId] 数组是否包含双方 socket ID, io.to(roomId).emit() 是否被调用。

5.2 SDP 层:用 sdp-transform 解析 offer,确认编解码器是否协商成功

将 app.js 中收到的 msg.sdp 字符串复制到 https://sdp-parser.netlify.app/ 在线解析。重点关注:

  • m=video 行后的 a=rtpmap: 字段,如 a=rtpmap:120 VP8/90000 ,确认双方均支持 VP8(Chrome 默认)或 H.264(Safari 默认);
  • a=sendrecv 表示双向音视频,若为 a=sendonly ,则远端无法接收;
  • a=extmap: 中的 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 表示启用网络拥塞控制,若缺失可能影响弱网表现。

5.3 ICE 层:在 chrome://webrtc-internals 中查看 candidate-pair 状态

打开该页面,找到当前 RTCPeerConnection 实例,点击右侧 getStats() 。筛选 candidate-pair ,观察:

  • state 字段: succeeded 表示连接成功, failed 表示失败;
  • nominated 字段: true 表示该 candidate pair 已被选中;
  • localCandidateType 与 remoteCandidateType :若均为 host ,说明局域网直连;若一方为 relay ,说明经过 TURN;若长期为 checking ,可能是防火墙阻断 UDP。

5.4 媒体层:检查 getStats() 中的 inbound-rtp 流量

筛选 inbound-rtp ,观察 bytesReceived 是否随时间增长。若为 0,说明远端未发送媒体流——检查远端 pc.addStream(localStream) 是否被调用,或 pc.createOffer({ offerToReceiveVideo: true }) 是否设置了接收视频。

5.5 Vue 层:确认 srcObject 绑定是否触发

在 VideoView.vue 的 watch 中添加:

watch(() => props.srcObject, (newVal) => {
  console.log('[VIDEO] srcObject updated to', newVal?.id || 'null')
}, { immediate: true })

若日志中 newVal 为 null ,说明 remoteStream 未正确赋值——回溯 app.js 中 pc.onaddstream 或 pc.ontrack 是否被触发,以及 addRemoteStream() 是否被调用。

最后技巧:在 app.js 的 pc.oniceconnectionstatechange 中,当 pc.iceConnectionState === 'disconnected' 时,主动调用 pc.restartIce() 重试候选收集,比完全重建 RTCPeerConnection 更轻量。本项目未实现此逻辑,但你可在 useWebRTC.js 的 createOffer() 后追加:

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'disconnected') {
    setTimeout(() => pc.restartIce(), 1000)
  }
}

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

Logo

邀请您加入社区

更多推荐