Vue2+Node WebRTC视频会议最小可行架构
简介:这是一套基于 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。重构不是为了炫技,而是解决三个实际问题:
- 多组件复用 :当前
App.vue承担全部逻辑,若需在MeetingRoom.vue和ScreenShare.vue中分别管理不同连接,代码将严重重复; - 测试友好 :Options API 的
this上下文难以单元测试,而useWebRTC返回纯函数,可直接 Jest Mocknavigator.mediaDevices; - 状态隔离 :多个会议实例需独立的
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)
}
}
更多推荐
所有评论(0)