意项
39.91M · 2026-03-23
事情是这样的。
自己闲着没事:开发一个多人视频会议系统。作为一个有追求的程序员,我决定用最新的技术栈:Vue3 + mediasoup + Spring Boot。
前两周的开发简直太顺利了:
"这玩意儿也不过如此嘛!"我甚至有点飘了。
然后,真正的噩梦开始了。
在踩坑之前,先简单介绍一下项目架构:
前端:Vue3 + mediasoup-client
信令服务:Node.js + protoo-server
桥接层:Spring Boot(Java <-> Node.js 双向通信)
媒体服务:mediasoup(SFU 架构)
核心功能:
项目结构:
pro-neoview/
├── neoview-web/ # Vue3 前端
│ └── src/
│ ├── App.vue # 主逻辑(WebRTC 状态管理)
│ ├── components/
│ │ └── MeetingRoom.vue # 会议界面组件
│ └── services/
│ ├── mediasoupSession.js # mediasoup 客户端封装
│ └── signaling.js # 信令通信
├── neoview-signal-server/ # Node.js 信令服务
└── neoview-signal-bridge/ # Spring Boot 桥接层
开源地址:gitee.com/yespi/neovi…
这是第一个让我头秃的问题。
场景:
我疯了?这不对啊!如果摄像头真的没开启,为什么对方能看到我?
我开始了漫长的调试之旅:
// App.vue - 检查 localStream
console.log('localStream:', localStream.value);
console.log('video tracks:', localStream.value?.getVideoTracks());
结果:
localStream.value 存在 getVideoTracks() 返回数组有内容 track.readyState === 'live' 啥都正常,但界面就是黑屏!
我又检查了 <video> 元素:
const videoElement = document.querySelector('#local-video');
console.log('video.srcObject:', videoElement.srcObject);
结果:srcObject 是 null!
破案了!localStream.value 有值,但 video.srcObject 没绑定上。
问题的根源在于:Vue 的响应式系统无法检测 MediaStream 内部 track 的变化。
我最初是这样的代码:
// 错误做法
function updateLocalStream(newStream) {
if (!localStream.value) {
localStream.value = newStream;
return;
}
// 直接往现有的 stream 添加 track
newStream.getTracks().forEach(track => {
localStream.value.addTrack(track); // Vue 无法检测这个变化!
});
}
为什么会这样?
这就像你在一个盒子里放了一个苹果(创建 MediaStream),Vue 能看到"盒子变化了"。
但如果你往盒子里的苹果上贴了一个标签(添加 track),Vue 根本不知道——因为它只"盒子"本身,不"盒子里的东西"。
修复方法很简单:创建新的 MediaStream 对象,而不是修改现有的。
// 正确做法:创建新对象触发 Vue 响应式更新
function updateLocalStream(newStream, options = {}) {
if (!newStream) return;
const { keepVideoTrack = false } = options;
if (!localStream.value) {
localStream.value = newStream;
return;
}
// 收集所有需要保留的 tracks
const tracksToKeep = [];
// 1. 保留旧的 audio track(如果没有新的)
const existingTracks = localStream.value.getTracks();
const newTracks = newStream.getTracks();
existingTracks.forEach(oldTrack => {
const sameKindNewTrack = newTracks.find(t => t.kind === oldTrack.kind);
if (!sameKindNewTrack) {
tracksToKeep.push(oldTrack); // 保留旧 track
} else if (oldTrack.kind === 'video' && keepVideoTrack) {
tracksToKeep.push(oldTrack); // 保留视频 track
} else {
oldTrack.stop(); // 停止旧的,使用新的
}
});
// 2. 添加新的 tracks
newTracks.forEach(newTrack => {
if (!tracksToKeep.includes(newTrack)) {
tracksToKeep.push(newTrack);
}
});
// 3. 【关键】创建新的 MediaStream 对象
const combinedStream = new MediaStream(tracksToKeep);
localStream.value = combinedStream; // Vue 检测到引用变化,触发更新!
}
这个修复的核心思想:
localStream.value 的引用变化这个 bug 更诡异。
场景:
但如果是用户 A 先共享,用户 B 后加入,就能正常看到。
我开始怀疑是不是 mediasoup 的 consumer 问题。
// App.vue - 处理 consumer
if (source === 'screensharing') {
screenShareStream.value = stream;
screenShareActive.value = true;
console.log('screenShareStream:', screenShareStream.value);
}
看起来 stream 是正常的,但为什么 video 元素绑定不上?
我又去 MeetingRoom 组件检查:
<!-- MeetingRoom.vue -->
<video
v-if="screenShareActive"
ref="screenShareVideo"
:srcObject.prop="screenShareStream"
autoplay
playsinline
/>
突然意识到一个问题:watch screenShareStream 时,video 元素可能还没渲染!
问题的根本原因是一个经典的时序问题:
sequenceDiagram
participant User as 新用户
participant App as App.vue
participant MeetingRoom as MeetingRoom 组件
participant DOM as video 元素
User->>App: 加入会议
App->>App: 收到 screenShare consumer
App->>App: screenShareStream.value = stream
App->>App: screenShareActive.value = true
Note over App: 问题:此时 screenShareActive 还是 false!
App->>MeetingRoom: props 更新
MeetingRoom->>DOM: 渲染 video 元素
Note over DOM: 但 watch 已经触发过了!
App->>MeetingRoom: screenShareStream 更新
MeetingRoom->>MeetingRoom: watch 触发
MeetingRoom->>DOM: 尝试绑定 srcObject
Note over DOM: video 元素还不存在!绑定失败
简单说:
screenShareStream.value = streamscreenShareActive.value = truevideo 元素还没渲染(因为 v-if="screenShareActive" 还是 false)watch 触发时找不到 video 元素,绑定失败修复方法:调整时序,确保 DOM 先渲染,再绑定流。
// App.vue - 处理屏幕共享 consumer
if (source === 'screensharing') {
const shareTrack = stream?.getVideoTracks?.()?.[0] || null;
// 检查 track 有效性(这个后面会讲,是第三个坑)
if (!shareTrack || shareTrack.readyState === 'ended') {
console.warn('[Share] 收到已失效的屏幕共享 consumer,忽略');
return;
}
// 记录共享信息
screenShareConsumerId = consumer.id;
screenShareProducerId = consumer.producerId;
screenShareOwner.value = { peerId, displayName };
screenShareDisabled.value = false;
// 【关键修复】先激活 screenShareActive,确保 video 元素已渲染
screenShareActive.value = true;
// 使用 setTimeout(0) 确保 DOM 已更新,再设置 stream
// 这样 MeetingRoom 的 watch 触发时,video 元素已存在
setTimeout(() => {
screenShareStream.value = stream;
console.log('[Share] 远端共享 stream 已绑定');
}, 0);
}
为什么用 setTimeout(0)?
这是一个经典技巧:
screenShareActive.value = true 触发 Vue 的 DOM 更新(异步)setTimeout(0) 把绑定操作放到下一个事件循环video 元素已存在这个 bug 最诡异,像幽灵一样。
场景:
我甚至怀疑是不是时空穿越了!
我首先检查服务端:
// SignalBridge - 是否还在广播共享状态?
@OnEvent("producerClosed")
public void handleProducerClosed(Event event) {
// 确实通知了所有用户 producer 关闭
}
服务端没问题。
我又检查客户端:
// App.vue - 是否正确处理关闭?
if (notification.method === 'producerClosed') {
// 找到 consumer 并关闭
consumer.close();
screenShareActive.value = false;
}
客户端也没问题。
那问题出在哪?
问题在于:mediasoup 的 consumer 可能会"延迟"到达。
sequenceDiagram
participant UserA as 用户A
participant Server as mediasoup 服务
participant UserB as 用户B(新加入)
participant App as App.vue
UserA->>Server: 开始共享屏幕
Server->>Server: 创建 producer
UserA->>Server: 停止共享
Server->>Server: 关闭 producer
Note over Server: producer 已关闭,但 consumer 可能还在队列中
UserB->>Server: 加入会议
Server->>App: 发送 late consumer(producer 已关闭)
App->>App: 创建 MediaStream
App->>App: screenShareActive = true
Note over App: 渲染黑屏框
App->>App: track ended 事件触发
App->>App: screenShareActive = false
Note over App: 1秒后框消失
简单说:
ended修复方法:在处理 consumer 时,检查 track 的 readyState。
// App.vue - 处理 consumer
if (source === 'screensharing') {
const shareTrack = stream?.getVideoTracks?.()?.[0] || null;
// 【关键】检查 track 是否有效:如果 track 已经 ended,直接忽略
if (!shareTrack || shareTrack.readyState === 'ended') {
console.warn('[Share] 收到已失效的屏幕共享 consumer,忽略');
return; // 直接返回,不触发任何 UI 更新
}
// ... 后续正常处理
}
track.readyState 的可能值:
live:track 正常工作中ended:track 已结束(用户停止共享、设备断开等)这个修复就像"进门检查":只有 track 是"活的"才让它进来,已经"死了"的直接拒之门外。
踩完这三个坑,我总结了一些经验教训:
// 错误:Vue 无法检测
localStream.value.addTrack(track);
localStream.value.removeTrack(track);
// 正确:创建新对象
const newStream = new MediaStream([...tracks]);
localStream.value = newStream;
// 错误:可能绑定失败
stream.value = mediaStream;
active.value = true; // video 元素还没渲染
// 正确:先渲染,再绑定
active.value = true; // 先渲染 video 元素
await nextTick(); // 等待 DOM 更新
stream.value = mediaStream; // 再绑定
// 检查 track 状态
const track = stream.getVideoTracks()[0];
if (!track || track.readyState === 'ended') {
console.warn('Track 已失效');
return;
}
// 1. 检查 stream 状态
console.log('Stream:', {
id: stream.id,
tracks: stream.getTracks().map(t => ({
kind: t.kind,
id: t.id,
readyState: t.readyState,
enabled: t.enabled,
muted: t.muted,
}))
});
// 2. 检查 video 元素绑定
const video = document.querySelector('video');
console.log('Video srcObject:', video.srcObject);
// 3. track 事件
track.onended = () => console.log('Track ended');
track.onmute = () => console.log('Track muted');
这三个坑,每一个都让我怀疑人生,但每一个背后都是 Vue 响应式系统与 WebRTC API 的"相爱相杀"。
最终我学到了:
项目开源地址:
技术栈:
掘友们,咱们下期见!
技术关键词:Vue3 WebRTC mediasoup MediaStream 响应式系统 视频会议 屏幕共享 track生命周期