Twilio Video结合Vue2开发多人会议:参与者断开致音视频轨道失效
解决方案
1. 拆分独立参与者子组件
将每个参与者的UI与逻辑拆分为单独的子组件(如ParticipantItem.vue),实现状态与DOM的隔离。父组件列表更新时,只有状态变化的子组件会触发更新,其他子组件因key匹配会被复用,不会销毁重建,从而保留已绑定的音视频轨道。
子组件示例:
<template> <div class="twilio-attendant-col" v-show="participant.connected"> <div class="twilio-attendant"> <div v-if="!participant.video || !videoEnabled" class="twilio-avatar">{{ participant.name }}</div> <div v-show="participant.video || !videoEnabled" class="twilio-video" ref="mediaContainer"></div> <div class="twilio-attendant-controls"> <button @click="$emit('toggle-audio', participant.identity)" class="btn"> <i v-if="participant.audio" class="fa fa-microphone"></i> <i v-else class="fa fa-microphone-slash"></i> </button> <button v-if="videoEnabled" @click="$emit('toggle-video', participant.identity)" class="btn"> <i class="fa fa-camera position-relative"> <div v-if="!participant.video" class="icon-slash"></div> </i> </button> <button @click="$emit('toggle-pinned', participant.identity)" class="btn"> <i class="fa fa-thumb-tack"></i> </button> </div> </div> </div> </template> <script> export default { name: 'ParticipantItem', props: { participant: { type: Object, required: true }, videoEnabled: { type: Boolean, default: true } }, mounted() { this.attachExistingTracks() }, watch: { 'participant.video'(newVal) { const videoEl = this.$refs.mediaContainer?.querySelector('video') if (videoEl) videoEl.style.display = newVal ? 'block' : 'none' }, 'participant.audio'(newVal) { const audioEl = this.$refs.mediaContainer?.querySelector('audio') if (audioEl) audioEl.muted = !newVal } }, methods: { attachExistingTracks() { const tracks = this.$parent.tracksMap[this.participant.identity] || [] tracks.forEach(track => this.attachTrack(track)) }, attachTrack(track) { if (!this.$refs.mediaContainer) return const existingEl = this.$refs.mediaContainer.querySelector(`[data-track-id="${track.id}"]`) if (!existingEl) { const mediaEl = track.attach() mediaEl.dataset.trackId = track.id this.$refs.mediaContainer.appendChild(mediaEl) } } }, beforeDestroy() { const mediaEls = this.$refs.mediaContainer?.querySelectorAll('video, audio') || [] mediaEls.forEach(el => { const trackId = el.dataset.trackId if (trackId) { const track = Array.from(this.$parent.room.participants.values()) .flatMap(p => Array.from(p.tracks.values())) .find(t => t.track.id === trackId)?.track track?.detach(el) } }) } } </script>
2. 父组件改造
在父组件中引入子组件,同时维护稳定的参与者对象引用与轨道映射,避免组件不必要的销毁重建:
<template> <div class="attendants-window"> <ParticipantItem v-for="item in participants" :key="item.identity" :participant="item" :video-enabled="videoEnabled" @toggle-audio="onToggleAudio" @toggle-video="onToggleVideo" @toggle-pinned="onTogglePinned" ref="participantItems" /> </div> </template> <script> import ParticipantItem from './ParticipantItem.vue' export default { components: { ParticipantItem }, data() { return { participants: [], tracksMap: {}, // 存储每个参与者的轨道集合:{ identity: [track1, track2] } videoEnabled: true, room: null // Twilio Room实例 } }, methods: { handleParticipantJoined(participant) { // 复用已有参与者对象,避免新建 const existing = this.participants.find(p => p.identity === participant.identity) if (existing) { existing.connected = true } else { this.participants.push({ identity: participant.identity, name: participant.identity, connected: true, audio: true, video: true, pinned: false }) } // 监听轨道订阅 participant.on('trackSubscribed', track => { this.tracksMap[participant.identity] = this.tracksMap[participant.identity] || [] this.tracksMap[participant.identity].push(track) const targetItem = this.$refs.participantItems.find(item => item.participant.identity === participant.identity) targetItem?.attachTrack(track) this.$set(this.participants.find(p => p.identity === participant.identity), track.kind, true) }) // 监听轨道取消订阅 participant.on('trackUnsubscribed', track => { const targetItem = this.$refs.participantItems.find(item => item.participant.identity === participant.identity) const el = targetItem?.$refs.mediaContainer?.querySelector(`[data-track-id="${track.id}"]`) if (el) { track.detach(el) el.remove() } this.$set(this.participants.find(p => p.identity === participant.identity), track.kind, false) this.tracksMap[participant.identity] = this.tracksMap[participant.identity].filter(t => t.id !== track.id) }) }, handleParticipantDisconnected(participant) { // 修改connected状态而非删除对象,保留组件实例 const target = this.participants.find(p => p.identity === participant.identity) if (target) target.connected = false }, onToggleAudio(identity) { // 原有音频切换逻辑 }, onToggleVideo(identity) { // 原有视频切换逻辑 }, onTogglePinned(identity) { // 固定功能实现:修改pinned属性后排序数组 const target = this.participants.find(p => p.identity === identity) if (target) { target.pinned = !target.pinned this.participants = [...this.participants.sort((a, b) => b.pinned - a.pinned)] } } } } </script>
3. 核心优化要点
- 稳定对象引用:更新参与者状态时直接修改现有对象属性(如
connected、pinned),而非删除后重新添加,避免组件销毁重建。 - 子组件隔离:每个参与者的DOM与轨道绑定逻辑独立,父组件列表变化时仅更新状态变更的子组件,复用其他子组件以保留音视频轨道。
- 轨道映射管理:通过
tracksMap维护轨道引用,确保组件挂载或复用时能重新绑定轨道,避免丢失。 - 响应式合规:使用
$set确保对象属性的响应式更新,通过子组件生命周期与watch处理DOM操作,替代直接的jQuery DOM操作。
内容的提问来源于stack exchange,提问作者andrejmatulik
相关产品推荐
相关产品推荐

