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)

  // ... 后续逻辑
}

这里有个小细节:streamisSharingref包裹,但对外暴露时我用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图,方便你理解状态流转:

屏幕共享状态流转图 空闲状态 共享中 错误状态 startShare() 成功 stopShare() / 用户停止 track.onended 异常 重置状态 / 重新尝试 💡 关键点: • 用户通过浏览器控制条停止共享时,会触发 track.onended 事件 • 每次 startShare() 都会创建新的 MediaStream,旧的必须手动释放

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事件、组件卸载时记得清理资源。把这三点拿捏住,你的屏幕共享功能基本就稳了。


公众号:蓝海资料掘金营,微信deep3321