25、屏幕共享Vue组件:Vue3 Composition API封装、响应式屏幕流、事件处理
屏幕共享功能在WebRTC里算是个硬骨头。你想想看,用户点一下「共享屏幕」,浏览器弹出选择窗口,然后整个桌面或者某个应用窗口的流就进来了。这个流程看着简单,但要在Vue3里封装得优雅、可复用,其实有不少门道。
我个人习惯用Composition API来封装这类功能。为什么?因为屏幕共享的逻辑是纯状态管理,跟UI解耦得越干净,后面维护起来就越舒服。今天我就带你一步步把这个组件搭起来。
25.1 核心思路:把屏幕流变成响应式数据
说白了,屏幕共享的本质就是获取一个MediaStream对象。但这个对象不是静态的——用户可能中途停止共享,浏览器可能因为权限问题拒绝,甚至用户切换了共享窗口。所以我们需要把这个流变成Vue的响应式数据。
我一般这样设计状态:
// useScreenShare.ts
import { ref, readonly, onUnmounted } from 'vue'
export function useScreenShare() {
const stream = ref<MediaStream | null>(null)
const isSharing = ref(false)
const error = ref<string | null>(null)
// ... 后续逻辑
}
这里有个小细节:stream和isSharing用ref包裹,但对外暴露时我用readonly。为什么?因为外部组件只能读取状态,不能直接修改。所有状态变更必须通过我们暴露的方法来触发,这样数据流才可控。
25.2 启动共享:处理浏览器差异
启动屏幕共享的核心API是navigator.mediaDevices.getDisplayMedia()。但不同浏览器对这个API的支持程度不一样。我在项目中遇到过Safari用户无法共享的情况,后来发现是权限策略问题。
来看代码:
async function startShare(options?: DisplayMediaStreamOptions) {
try {
// 先检查浏览器是否支持
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error('当前浏览器不支持屏幕共享')
}
// 获取屏幕流
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always', // 显示鼠标指针
displaySurface: 'monitor', // 默认共享整个屏幕
},
audio: false, // 屏幕共享一般不包含音频
...options,
})
// 监听用户手动停止共享(浏览器自带的停止按钮)
const track = mediaStream.getVideoTracks()[0]
track.onended = () => {
stopShare()
}
stream.value = mediaStream
isSharing.value = true
error.value = null
} catch (err: any) {
// 用户取消选择也会走到这里
if (err.name === 'NotAllowedError') {
error.value = '用户取消了屏幕共享'
} else {
error.value = err.message || '启动屏幕共享失败'
}
console.error('[useScreenShare]', err)
}
}
嗯,这里要注意track.onended。浏览器自带的共享控制条上有个「停止共享」按钮,用户点那个按钮时,track会触发ended事件。如果我们不监听这个事件,isSharing状态就永远为true,UI上会显示还在共享,但实际上流已经断了。我曾经因为这个bug被测试同学追着打……
25.3 停止共享:清理要彻底
停止共享不只是把状态改回false那么简单。每个track都要手动停止,否则摄像头灯可能还亮着,或者麦克风还在录音。
function stopShare() {
if (stream.value) {
stream.value.getTracks().forEach(track => {
track.stop() // 关键:释放所有轨道
})
stream.value = null
}
isSharing.value = false
error.value = null
}
我见过有些同学只调了stream.value = null,结果浏览器标签页上那个共享图标一直不消失。说白了,MediaStream对象虽然被置空了,但底层的媒体轨道还在运行。必须显式调用track.stop()才能彻底释放资源。
25.4 事件处理:让组件感知用户操作
光有状态还不够,组件需要知道什么时候共享开始了、什么时候出错了。我习惯用Vue3的emit来抛出事件,但Composition API里没有emit,怎么办?
我的做法是返回一个事件回调注册函数:
// 在 useScreenShare 内部
const callbacks = {
onStart: null as (() => void) | null,
onStop: null as (() => void) | null,
onError: null as ((err: string) => void) | null,
}
function on(event: 'start' | 'stop' | 'error', handler: Function) {
if (event === 'start') callbacks.onStart = handler as () => void
if (event === 'stop') callbacks.onStop = handler as () => void
if (event === 'error') callbacks.onError = handler as (err: string) => void
}
// 在 startShare 成功时
callbacks.onStart?.()
// 在 stopShare 时
callbacks.onStop?.()
// 在捕获错误时
callbacks.onError?.(error.value!)
这样外部组件就可以这样用:
const { startShare, stopShare, stream, isSharing, on } = useScreenShare()
on('start', () => console.log('共享开始了'))
on('error', (msg) => showToast(msg))
25.5 组件封装:把逻辑挂到模板上
现在Composition API的逻辑写好了,接下来就是把它塞进Vue组件里。我一般会做一个ScreenShareButton.vue,只负责UI交互,所有逻辑都从useScreenShare里拿。
<template>
<div class="screen-share">
<button
:class="['share-btn', { active: isSharing }]"
:disabled="loading"
@click="handleClick"
>
{{ isSharing ? '停止共享' : '共享屏幕' }}
</button>
<p v-if="error" class="error-msg">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { useScreenShare } from './useScreenShare'
const { startShare, stopShare, stream, isSharing, error, on } = useScreenShare()
// 注册事件
on('start', () => {
// 可以在这里做埋点或者UI反馈
})
on('error', (msg) => {
console.warn('共享出错:', msg)
})
async function handleClick() {
if (isSharing.value) {
stopShare()
} else {
await startShare()
}
}
</script>
你看,组件里几乎没有业务逻辑,全是UI绑定。这就是Composition API的魅力——逻辑可以单独测试,组件只负责渲染。
25.6 响应式屏幕流:在视频元素中实时显示
拿到stream之后,怎么让用户看到自己共享的内容?用<video>元素,把srcObject绑定到响应式流上。
<template>
<video
ref="videoRef"
autoplay
muted
playsinline
:style="{ width: '100%', maxHeight: '400px' }"
/>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{
stream: MediaStream | null
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
watch(() => props.stream, async (newStream) => {
await nextTick()
if (videoRef.value && newStream) {
videoRef.value.srcObject = newStream
}
})
</script>
这里有个坑:srcObject赋值之后,如果流变了(比如用户切换了共享窗口),Vue的响应式系统不会自动更新视频元素。所以必须用watch监听stream的变化,每次变化都重新赋值。我刚开始做的时候没加这个watch,结果用户切换窗口后视频还是黑屏,排查了半天……
25.7 完整流程图
我把整个屏幕共享的流程画成了SVG图,方便你理解状态流转:
25.8 避坑指南:我踩过的几个坑
做屏幕共享组件,有几个坑是绕不过去的。我列出来,你遇到的时候心里有个数。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 用户取消选择后状态未重置 | 点击共享后弹出选择窗口,用户点了取消,但isSharing还是true |
在catch中判断NotAllowedError,重置所有状态 |
| 切换共享窗口后视频黑屏 | 用户从共享桌面切换到共享应用窗口,视频元素不更新 | 用watch监听stream变化,重新赋值srcObject |
| 组件卸载后流未释放 | 离开页面后,浏览器标签页上还显示共享图标 | 在onUnmounted中调用stopShare() |
| Safari 不支持某些选项 | getDisplayMedia抛异常 |
降级处理,去掉displaySurface等高级选项 |
⚠️ 特别注意: 不要在startShare中直接修改stream.value而不处理旧流。如果用户连续点击两次共享按钮,第一次的流必须手动停止,否则浏览器会同时保留两个共享会话,导致性能问题。
💡 我的小技巧: 在开发环境调试屏幕共享时,可以用console.log(stream.value.getVideoTracks()[0].getSettings())查看当前共享的详细信息,比如分辨率、帧率等。这在排查画质问题时特别有用。
25.9 完整代码结构
最后,我把整个组件的文件结构整理一下,方便你直接参考:
src/
├── composables/
│ └── useScreenShare.ts # 核心逻辑:状态管理、启动/停止、事件
├── components/
│ ├── ScreenShareButton.vue # 共享/停止按钮
│ └── ScreenSharePreview.vue # 视频预览组件
└── types/
└── screenShare.ts # 类型定义
这种分层方式,逻辑层(composables)可以单独写单元测试,UI层(components)只负责渲染和交互。你想想看,如果哪天产品经理说「按钮样式要改」,你只需要改ScreenShareButton.vue,完全不影响useScreenShare里的逻辑。这就是Composition API带给我们的好处。
好了,屏幕共享的Vue组件封装就讲到这里。核心就三点:把MediaStream变成响应式数据、处理好track.onended事件、组件卸载时记得清理资源。把这三点拿捏住,你的屏幕共享功能基本就稳了。