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代码里已经包含了这个处理。

屏幕共享状态流转图

下面这张图展示了屏幕共享从开始到结束的完整状态变化:

屏幕共享状态流转图 空闲状态 加载中 共享中 错误状态 点击开始 获取流成功 获取流失败 手动停止 / 浏览器停止 重置 用户取消选择 用户取消 正常流转 异常路径 用户取消

避坑指南:我踩过的几个坑

做屏幕共享功能,有几个坑我印象特别深:

  • 音频问题getDisplayMediaaudio选项,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,限制比较多。

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