24、屏幕共享React组件:封装useScreenShare Hook、状态管理、UI组件设计
屏幕共享功能,说白了就是把用户的屏幕内容实时抓取并推送到远端。在React项目中,我习惯把这块逻辑封装成自定义Hook,再配合状态管理和UI组件,形成一个完整的解决方案。今天我们就来一步步拆解这个过程。
为什么需要封装Hook?
你想想看,屏幕共享涉及的东西不少:获取媒体流、监听状态变化、处理错误、清理资源……如果把这些逻辑散落在各个组件里,维护起来会非常痛苦。我在项目中遇到过好几次,因为忘记清理事件监听导致内存泄漏,排查了半天才发现问题。
封装成useScreenShare Hook,核心好处就三个:
- 逻辑复用:多个组件可以共享同一套屏幕共享逻辑
- 关注点分离:UI只负责展示,Hook负责业务逻辑
- 测试方便:纯逻辑的Hook比组件更容易写单元测试
useScreenShare Hook 设计
我们先来看核心的Hook实现。嗯,这里要注意,屏幕共享的API是getDisplayMedia,它返回一个MediaStream。但不同浏览器对这个API的支持程度不一样,需要做兼容处理。
import { useState, useCallback, useEffect, useRef } from 'react';
interface ScreenShareState {
stream: MediaStream | null;
isSharing: boolean;
error: string | null;
isLoading: boolean;
}
interface UseScreenShareOptions {
onError?: (error: string) => void;
onStart?: () => void;
onStop?: () => void;
}
export function useScreenShare(options: UseScreenShareOptions = {}) {
const [state, setState] = useState<ScreenShareState>({
stream: null,
isSharing: false,
error: null,
isLoading: false,
});
const streamRef = useRef<MediaStream | null>(null);
const startScreenShare = useCallback(async () => {
// 检查浏览器支持
if (!navigator.mediaDevices?.getDisplayMedia) {
const errorMsg = '当前浏览器不支持屏幕共享';
setState(prev => ({ ...prev, error: errorMsg }));
options.onError?.(errorMsg);
return;
}
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always', // 显示鼠标指针
displaySurface: 'monitor', // 默认显示整个屏幕
},
audio: false, // 屏幕共享一般不包含音频
});
streamRef.current = mediaStream;
setState({
stream: mediaStream,
isSharing: true,
error: null,
isLoading: false,
});
options.onStart?.();
// 监听用户手动停止共享(点击浏览器自带的停止按钮)
mediaStream.getVideoTracks()[0]?.addEventListener('ended', () => {
stopScreenShare();
});
} catch (err: any) {
// 用户取消选择屏幕不算错误
if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
setState(prev => ({ ...prev, isLoading: false }));
return;
}
const errorMsg = `屏幕共享启动失败: ${err.message}`;
setState(prev => ({ ...prev, error: errorMsg, isLoading: false }));
options.onError?.(errorMsg);
}
}, [options]);
const stopScreenShare = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setState({
stream: null,
isSharing: false,
error: null,
isLoading: false,
});
options.onStop?.();
}, [options]);
// 组件卸载时自动清理
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return {
...state,
startScreenShare,
stopScreenShare,
};
}
核心要点:getDisplayMedia返回的流,一定要在不需要时手动停止每个track。否则摄像头/屏幕指示灯会一直亮着,用户会以为你在偷窥他。
状态管理:用Context还是Zustand?
我个人习惯,如果屏幕共享状态只在少数几个组件间传递,用React Context就够了。但如果整个应用都需要知道共享状态(比如导航栏显示共享中、页面路由跳转时保持共享),我会选择轻量级的状态管理库,比如Zustand。
这里我展示一个基于Context的方案,因为它零依赖,适合大多数场景:
import React, { createContext, useContext, ReactNode } from 'react';
import { useScreenShare } from './useScreenShare';
interface ScreenShareContextType {
stream: MediaStream | null;
isSharing: boolean;
error: string | null;
isLoading: boolean;
startScreenShare: () => Promise<void>;
stopScreenShare: () => void;
}
const ScreenShareContext = createContext<ScreenShareContextType | null>(null);
export function ScreenShareProvider({ children }: { children: ReactNode }) {
const screenShare = useScreenShare({
onError: (msg) => console.error('屏幕共享错误:', msg),
onStart: () => console.log('屏幕共享已开始'),
onStop: () => console.log('屏幕共享已停止'),
});
return (
<ScreenShareContext.Provider value={screenShare}>
{children}
</ScreenShareContext.Provider>
);
}
export function useScreenShareContext() {
const context = useContext(ScreenShareContext);
if (!context) {
throw new Error('useScreenShareContext 必须在 ScreenShareProvider 内使用');
}
return context;
}
小技巧:Context的value不要直接传对象字面量,否则每次渲染都会创建新引用,导致所有消费者组件都重新渲染。上面代码中,screenShare对象本身是稳定的,因为useScreenShare返回的引用不会变。
UI组件设计:让用户看得明白
UI组件我一般拆成三个部分:
- 控制按钮:开始/停止共享
- 状态指示器:显示当前共享状态
- 预览区域:显示共享内容的缩略图(可选)
来看一个完整的控制组件:
import React from 'react';
import { useScreenShareContext } from './ScreenShareProvider';
export function ScreenShareControl() {
const { isSharing, isLoading, error, startScreenShare, stopScreenShare } =
useScreenShareContext();
const handleToggle = () => {
if (isSharing) {
stopScreenShare();
} else {
startScreenShare();
}
};
return (
<div className="screen-share-control">
<button
onClick={handleToggle}
disabled={isLoading}
className={`share-btn ${isSharing ? 'sharing' : ''}`}
>
{isLoading ? '正在启动...' : isSharing ? '停止共享' : '开始共享'}
</button>
{isSharing && (
<span className="sharing-indicator">
● 屏幕共享中
</span>
)}
{error && (
<div className="error-message">
⚠️ {error}
</div>
)}
</div>
);
}
我曾经犯过一个错误:在共享过程中,用户点击了浏览器的「停止共享」按钮,但我的UI没有同步更新。后来我通过监听ended事件解决了这个问题。上面Hook代码里已经包含了这个处理。
屏幕共享状态流转图
下面这张图展示了屏幕共享从开始到结束的完整状态变化:
避坑指南:我踩过的几个坑
做屏幕共享功能,有几个坑我印象特别深:
- 音频问题:
getDisplayMedia的audio选项,Chrome支持,但Firefox和Safari不支持。如果你需要同时共享系统音频,得做降级处理。 - 分辨率选择:用户可能选择共享整个屏幕,也可能只共享一个窗口。不同选择下,视频流的分辨率差异很大。我建议在
getDisplayMedia的约束里设置width: { ideal: 1920 },而不是固定值。 - 权限问题:有些浏览器在HTTPS环境下才允许屏幕共享。我在本地开发时经常遇到这个问题,后来统一用
localhost或配置自签名证书解决。
重要提醒:屏幕共享涉及用户隐私,一定要在UI上明确告知用户「正在共享中」。最好在共享期间显示一个醒目的红色指示器,并且提供一键停止的入口。
性能优化:别让共享拖垮页面
屏幕共享的流通常分辨率很高,直接渲染到<video>标签没问题,但如果要做预览缩略图,建议降低分辨率。我一般这样处理:
// 创建低分辨率预览
const createPreviewStream = (originalStream: MediaStream) => {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
const ctx = canvas.getContext('2d');
const video = document.createElement('video');
video.srcObject = originalStream;
video.play();
// 定时从video帧抓取到canvas
const interval = setInterval(() => {
ctx?.drawImage(video, 0, 0, 320, 180);
}, 1000); // 每秒更新一次就够了
return { canvas, cleanup: () => clearInterval(interval) };
};
这样做的好处是,预览不会占用太多CPU,而且用户看到的是静态缩略图,不需要实时刷新。
总结一下
封装useScreenShare Hook,核心就是处理好三个生命周期:启动、运行、清理。状态管理用Context还是Zustand,取决于你的应用规模。UI组件要清晰展示状态,给用户足够的反馈。
我个人觉得,屏幕共享这个功能,技术实现并不复杂,真正考验的是对细节的把控——错误处理、状态同步、性能优化、用户体验。把这些都做好了,用户用起来才会觉得「嗯,这个功能很稳」。
最后一个小建议:上线前一定要在Chrome、Edge、Firefox、Safari四个浏览器上测试一遍。不同浏览器对getDisplayMedia的实现有细微差别,尤其是Safari,限制比较多。