远程会议里点开屏幕共享,大家都能看见你桌面——但下一秒想圈出某个按钮说“这里点一下”,就卡住了。没人真在用鼠标画箭头,更没人手写 SVG 覆盖层去同步坐标。不是没工具,是工具太重:Electron 封装、iframe 嵌套第三方标注 SDK、或者直接扔给 WebRTC 原生 API 自己啃 —— 一个 navigator.mediaDevices.getDisplayMedia() 调用后, 配置还没写完,ontrack 回调里连流都没接稳。
CSDN 那篇文章(2025-06-13)也提了一嘴:状态响应、资源回收、错误降级,三件事缺一不可。但没人告诉你,onUnmounted 里漏掉 getTracks().forEach(t => t.stop()),下次 getDisplayMedia() 就会静默失败。更现实的是:Chrome 124+ 支持 displaySurface: "browser",Firefox 还卡在 "monitor"。Vue 组合式 API 的优势,恰恰在于能把这些碎片逻辑收进一个 useScreenShare() 里,而不是每次新开组件都重写一遍 addTrack + createOffer + 。
别碰原生 WebRTC——至少别在组件里直接调
静默失败这种事,八成就栽在没加 try/catch 的调用上。
我们让 useScreenShare() 返回 stream: ref<MediaStream | null>(null)、isSharing: ref<boolean>(false)、error: ref<string | null>(null)。不是为了好看,是 Vue Devtools 里能一眼揪出“为什么 isSharing 是 true 但画面黑着”——上次调试发现是 Chrome 125 在 iframe 里拒绝了 displaySurface: "browser",却只抛 ,不吐具体原因。
onUnmounted(() => { if (stream.value) { stream.value.getTracks().forEach(t => t.stop()); stream.value = null; } }) 这段必须有。漏掉?下次 getDisplayMedia() 调用直接 resolve 空流——连错误都不报,DevTools 里连 track 都看不到。
封装里第一件事:if (!navigator.mediaDevices?.getDisplayMedia) { error.value = "当前浏览器不支持屏幕共享"; return; }。Firefox 126 还不认 "window",只吃 "monitor";Safari?直接 undefined。降级提示不能藏在 catch 里。
光标飘在别人屏幕上乱晃,不是炫技
是协作崩溃的前兆。DataChannel 传坐标?别直接塞 MouseEvent.clientX —— 那得先算相对共享画布的 offset,还得处理缩放比、滚动偏移、iframe 嵌套。我们用 useRemoteControl() 把这事钉死在组合式 API 里。
共享流本身不动,另起一层 <canvas> 绝对定位盖在 <video> 上。标注数据走 DataChannel,结构长这样:
interface AnnotationEvent {
id: string;
type: 'pen' | 'erase' | 'clear';
x: number; // 归一化 [0,1]
y: number;
color?: string;
timestamp: number;
}
响应式更新靠 ref<AnnotationEvent[]>([]) + watch 触发 canvas 重绘。别用 ctx.clearRect(0,0,w,h) 全清——擦除操作要逐点比对 ID。
remote peer 的 mousemove 每秒发 60 次?DataChannel 会积压。我们在发送端加 throttle(30fps),接收端用 插值补帧。关键不是“准”,是“不跳”。CSS 里给光标层加 ,不然会误触底层 video。
两人同时划同一块区域?不锁状态,不发 undo。用 LWW(Last-Write-Wins)按 timestamp 合并操作队列,再批量重绘。实测 Chrome 125 + Firefox 126 下,3 用户并发标注,延迟稳定在 180ms 内。Canvas 层和共享流解耦后,哪怕 DataChannel 断了,视频照播,只是笔停了——这比整个连接闪退体面得多。
把 useScreenShare 和 useRemoteControl 焊在一起
前两章拆出来的组合式函数,单独跑都没问题。可一旦塞进真实组件,事情就变复杂了——你点了“停止共享”,标注层得跟着清掉;远端新用户一加入,光标层要自动绑定他的 DataChannel。写死逻辑当然也行,但耦合太紧,换个场景就得重写。孤立地封装,跟没封装一个样。
来看看 怎么把这两兄弟焊在一起。
模板看起来平平无奇,但背后 canvas 层和 cursor 层的状态都来自前两章封装的 composable。关键在 setup 里怎么把流、标注、光标三件事串成一条线。
<template>
<div class="screen-share-board" ref="boardRef">
<video ref="videoRef" autoplay muted playsinline></video>
<canvas ref="canvasRef" class="annotation-layer"></canvas>
<div class="remote-cursors">
<div v-for="cursor in remoteCursors" :key="cursor.peerId"
class="cursor-dot"
:style="{ left: cursor.x + '%', top: cursor.y + '%' }">
<span>{{ cursor.name }}</span>
</div>
</div>
<div class="toolbar">
<button @click="toggleShare">{{ isSharing ? '停止共享' : '开始共享' }}</button>
<button v-show="isSharing" @click="activeTool = 'pen'" :class="{ active: activeTool === 'pen' }">画笔</button>
<button v-show="isSharing" @click="activeTool = 'rect'" :class="{ active: activeTool === 'rect' }">矩形</button>
<button v-show="isSharing" @click="activeTool = 'text'" :class="{ active: activeTool === 'text' }">文本</button>
<button v-show="isSharing" @click="clearAnnotations">清空</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useScreenShare } from './useScreenShare'
import { useRemoteControl } from './useRemoteControl'
const boardRef = ref<HTMLElement | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const activeTool = ref<'pen' | 'rect' | 'text'>('pen')
// 1. 屏幕共享 composable
const { stream, isSharing, startShare, stopShare, error } = useScreenShare()
// 2. 远程控制 composable(依赖 stream 和 canvas)
const { annotations, remoteCursors, addAnnotation, clearAnnotations, connectPeer } = useRemoteControl()
// 把流挂到 video 上
watch(stream, (newStream) => {
if (videoRef.value && newStream) {
videoRef.value.srcObject = newStream
}
})
// 3. 标注工具逻辑:canvas 鼠标事件绑定
function handleCanvasMouseDown(e: MouseEvent) {
if (!canvasRef.value) return
const rect = canvasRef.value.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = (e.clientY - rect.top) / rect.height
addAnnotation({ type: activeTool.value, x, y, color: '#ff0000' })
}
// 4. 开始/停止共享
async function toggleShare() {
if (isSharing.value) {
stopShare()
} else {
await startShare()
// 启动后自动建立 DataChannel 连接(模拟)
connectPeer('default-room')
}
}
onMounted(() => {
canvasRef.value?.addEventListener('mousedown', handleCanvasMouseDown)
})
onUnmounted(() => {
canvasRef.value?.removeEventListener('mousedown', handleCanvasMouseDown)
stopShare()
})
</script>
两个 composable 之间没有直接依赖,但它们共享同一个 canvasRef?不对—— 只管流, 只管标注数据和光标位置。真正的“焊接”发生在父级 setup 里:用 watch 把 stream 的变化传给 video,用事件绑定把用户的鼠标操作传给 。
最容易踩的坑是生命周期。如果用户在停止共享后不清空 annotations,下一轮共享时那些旧坐标还留在 canvas 上。我们的解法是在 stopShare 内部自动调用 ,并在 onUnmounted 里二次保证。别指望 GC 帮你擦屁股。
TypeScript 在这类组合场景下不是摆设。把前两章的接口统一收在一个 types.ts 里:
// types.ts
export interface CursorPosition {
peerId: string
name: string
x: number // 归一化 [0, 1]
y: number
}
export interface Annotation {
id: string
type: 'pen' | 'rect' | 'text'
x: number
y: number
color: string
timestamp: number
}
export interface ScreenShareState {
stream: MediaStream | null
isSharing: boolean
error: string | null
}
这样 的返回值 类型就是 Ref<CursorPosition[]>,模板里 v-for 遍历时 IDE 会自动补全 peerId 和 name。如果你团队里有人习惯写 any,趁早拉黑。
整个组件跑起来后,你会看到:开始共享 → 视频流出现在 video 标签 → 远端用户的鼠标光标浮在 canvas 上(用 CSS 保证不挡操作)→ 你选画笔在屏幕上画个圈,DataChannel 把坐标广播出去,别人那边同步重绘。
这一整套下来,代码量不到 200 行,但把三个核心问题都钉死了:流管理、标注同步、光标渲染。剩下的全是业务定制——比如加个颜色选择器、权限校验、录制回放。组合式 API 的好处就在这里:你不用重写底层,只需要像搭积木一样 import 你需要的块。写这种组件最爽的时刻不是跑通 Demo,而是三个月后回来改需求,发现只用改一个 composable 的参数,其他地方纹丝不动。
封装 WebRTC 时我犯过的三个错误
代码跑通只是开始,真正让人头大的永远是那些你以为没问题的地方。三个坑,每个都花了我至少半天定位。写出来,你别再踩一遍。
开发时全程 Chrome,一切正常。随手在 Firefox 上点了一下共享,控制台直接甩了个 。愣了一下才反应过来,这玩意在部分旧版本 Firefox 里默认没开——需要用户在 里手动启用 。用户不可能去翻配置页。
解决方案其实简单:封装 时,在调用 外层加一个能力检测,不支持的浏览器直接抛一个可读的错误提示,而不是让程序静默崩溃。代码大概长这样:
export function useScreenShare() {
const canScreenShare = ref(false)
onMounted(() => {
const mediaDevices = navigator.mediaDevices as any
canScreenShare.value = typeof mediaDevices?.getDisplayMedia === 'function'
})
async function startShare() {
if (!canScreenShare.value) {
throw new Error('当前浏览器不支持屏幕共享,请使用 Chrome 或 Firefox 最新版')
}
// ...
}
}
另外,移动端 Safari 压根不支持 ,只能在 PC 上用。这些边界条件必须在 UI 层就给用户提示,不能等到点了共享按钮才弹出报错。
标注功能跑起来后,我画了个矩形,远端收到的数据莫名其妙变成了 [object ArrayBuffer]。查了半天才发现问题出在 send() 方法上——我把坐标数据序列化成 JSON 字符串后,顺手转成了 Uint8Array 发送,但接收端没有做二进制到字符串的还原。
DataChannel 本身支持 DOMString、Blob 和 ArrayBuffer 三种格式。混用不同类型时,接收端必须根据 binaryType 属性做对应处理。我们最终统一约定:所有标注数据都走文本格式,不做二进制转换。接收端监听 onmessage 时直接 JSON.parse(event.data),简单粗暴,不会有歧义。
// 发送端
channel.send(JSON.stringify(annotation))
// 接收端
channel.onmessage = (event) => {
const data = JSON.parse(event.data)
// data 就是 Annotation 类型
}
如果你非要传二进制(比如压缩大量坐标点),记得保持 binaryType 一致,并在两端都做显式转换。别学我,一半字符串一半二进制来回试。
这个坑最隐蔽。用户点了"结束共享",我看 stream.getTracks().forEach(t => t.stop()) 执行了,就以为万事大吉。结果打开 DevTools 的 WebRTC 面板,发现 的连接状态还是 connected,而且页面切走后 FPS 一直在掉——显然后台还在跑视频编解码。
根源在于:停止媒体流并不会自动关闭 。你必须显式调用 pc.close(),并把它置为 null。而且需要在 onUnmounted 里做一次兜底,防止用户直接关页面或路由跳转时没触发停止流程。
onUnmounted(() => {
if (peerConnection.value) {
peerConnection.value.close()
peerConnection.value = null
}
})
别指望垃圾回收机制给你擦屁股。WebRTC 那些 PeerConnection、MediaStream 全靠引用计数撑着,你代码里不主动调用 close() 或者把引用置 null,它就死皮赖脸挂在后台,CPU 和内存照吃不误。
三个坑聊完了,全是那种“知道了就觉得简单,不知道就得耗掉大半天”的玩意儿。搞 WebRTC 就这么刺激——你永远猜不到下一个 bug 是浏览器兼容性挖的坑、协议层设计埋的雷,还是自己手滑写出来的。好在 Vue 3 组合式 API 至少把生命周期和状态管理那摊子事收拾得明明白白,剩下的,专心跟 WebRTC 本身的毛病死磕就行。
从屏幕共享到白板协作,还能怎么玩?
写完基础共享和标注,我顺手把信令层从 mock 换成了真实的 WebSocket 服务—— 就是这么个东西,它不碰媒体流,只管房间列表、加入/离开广播、ICE 候选中继。多房间靠 room-id 路由参数隔离,没做 JWT 鉴权,但加了 maxPeers: 8 硬限制,防住测试时自己开十个标签页把本地机器拖垮。
接 stream 流,不是所有浏览器都支持 video/webm;codecs=vp9,Chrome 124+ 可以,Firefox 125 得切回 video/webm(无 codecs)。录制文件用 Blob + 直接预览,上传交给后端,前端不碰分片逻辑。
共享时顺带采集音频流,传给 的 continuous 模式(注意:仅 Chromium 系),文本流按时间戳打点,和标注坐标对齐。没上 Whisper WASM——太重,首屏加载多 800ms,够呛。
仓库已开源:。README 里写了怎么起信令服务、怎么改 WebSocket 地址,也留了 TODO.md:比如 动态降码率、Canvas 标注导出 SVG、 适配缩放后的画布坐标映射……你要是补上了,PR 我收。





评论