Vue 3 与 Screen Capture API

概述

Screen Capture API 是现代浏览器提供的一组 API,允许 web 应用捕获用户设备的屏幕内容。它提供了一种简单的方式来捕获整个屏幕、单个窗口或浏览器标签页,并将其转换为可处理的媒体流。

与传统的屏幕捕获方法相比,Screen Capture API 具有以下优势:

  • 无需额外插件或扩展
  • 更好的性能和更低的延迟
  • 更安全的权限模型
  • 与其他 Web API 良好集成
  • 支持多种捕获模式

本教程将介绍如何在 Vue 3 应用中集成 Screen Capture API,构建一个功能完整的屏幕录制应用。

核心知识点

1. API 基本概念

Screen Capture API 主要包含以下核心接口:

  • **MediaDevices.getDisplayMedia()**:用于请求用户授权并捕获屏幕内容
  • MediaStream:表示捕获的媒体流
  • MediaStreamTrack:表示媒体流中的单个轨道
  • DisplayMediaStreamConstraints:用于配置捕获参数

2. 捕获屏幕内容

基本屏幕捕获

async function captureScreen() {
  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: 'always', // 显示鼠标光标
        displaySurface: 'monitor' // 捕获整个屏幕
      },
      audio: false // 不捕获音频
    });
    return stream;
  } catch (error) {
    console.error('Error capturing screen:', error);
  }
}

高级配置选项

async function captureScreenWithOptions() {
  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: 'motion', // 仅当鼠标移动时显示光标
        displaySurface: 'browser', // 优先捕获浏览器标签页
        logicalSurface: true // 捕获逻辑表面(如虚拟桌面)
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true
      } // 捕获系统音频
    });
    return stream;
  } catch (error) {
    console.error('Error capturing screen:', error);
  }
}

3. 监听捕获停止事件

当用户停止屏幕捕获时,可以通过监听 ended 事件来处理:

function setupScreenCapture() {
  navigator.mediaDevices.getDisplayMedia({ video: true })
    .then(stream => {
      // 使用媒体流
      
      // 监听流结束事件
      stream.getVideoTracks()[0].onended = () => {
        console.log('Screen capture ended');
        // 处理捕获结束逻辑
      };
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

4. 结合 MediaRecorder 录制屏幕

将 Screen Capture API 与 MediaRecorder API 结合,可以实现屏幕录制功能:

async function startScreenRecording() {
  try {
    // 捕获屏幕流
    const screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: { cursor: 'always' },
      audio: true
    });
    
    // 创建录制器
    const recorder = new MediaRecorder(screenStream, {
      mimeType: 'video/webm;codecs=vp9,opus'
    });
    
    const chunks = [];
    
    recorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        chunks.push(event.data);
      }
    };
    
    recorder.onstop = () => {
      const videoBlob = new Blob(chunks, { type: 'video/webm' });
      const videoUrl = URL.createObjectURL(videoBlob);
      // 使用 videoUrl 播放或下载视频
    };
    
    recorder.start(1000);
    
    return { recorder, stream: screenStream };
  } catch (error) {
    console.error('Error starting screen recording:', error);
  }
}

5. Vue 3 组合式 API 封装

创建一个 useScreenCapture 组合式函数来封装 Screen Capture API:

import { ref, onUnmounted } from 'vue';

export function useScreenCapture() {
  const screenStream = ref(null);
  const isCapturing = ref(false);
  const error = ref(null);

  // 捕获屏幕
  const captureScreen = async (constraints = {}) => {
    try {
      error.value = null;
      
      // 默认配置
      const defaultConstraints = {
        video: {
          cursor: 'always'
        },
        audio: false
      };
      
      // 合并配置
      const captureConstraints = {
        ...defaultConstraints,
        ...constraints
      };
      
      // 请求屏幕捕获
      screenStream.value = await navigator.mediaDevices.getDisplayMedia(captureConstraints);
      isCapturing.value = true;
      
      // 监听捕获结束事件
      const videoTrack = screenStream.value.getVideoTracks()[0];
      if (videoTrack) {
        videoTrack.onended = () => {
          isCapturing.value = false;
          screenStream.value = null;
        };
      }
      
      return screenStream.value;
    } catch (err) {
      error.value = `无法捕获屏幕: ${err.message}`;
      console.error('Error capturing screen:', err);
      throw err;
    }
  };

  // 停止捕获
  const stopCapture = () => {
    if (screenStream.value) {
      screenStream.value.getTracks().forEach(track => track.stop());
      screenStream.value = null;
      isCapturing.value = false;
    }
  };

  // 组件卸载时清理资源
  onUnmounted(() => {
    stopCapture();
  });

  return {
    screenStream,
    isCapturing,
    error,
    captureScreen,
    stopCapture
  };
}

6. 结合 MediaRecorder 实现屏幕录制

创建一个更完整的组合式函数,结合 Screen Capture API 和 MediaRecorder API:

import { ref, onUnmounted } from 'vue';

export function useScreenRecorder() {
  const screenStream = ref(null);
  const recorder = ref(null);
  const recordedChunks = ref([]);
  const recordedVideo = ref(null);
  const isCapturing = ref(false);
  const isRecording = ref(false);
  const duration = ref(0);
  const error = ref(null);
  let timer = null;

  // 捕获屏幕
  const captureScreen = async (captureConstraints = {}) => {
    try {
      error.value = null;
      
      const defaultCaptureConstraints = {
        video: {
          cursor: 'always'
        },
        audio: true
      };
      
      const constraints = {
        ...defaultCaptureConstraints,
        ...captureConstraints
      };
      
      screenStream.value = await navigator.mediaDevices.getDisplayMedia(constraints);
      isCapturing.value = true;
      
      // 监听捕获结束事件
      const videoTrack = screenStream.value.getVideoTracks()[0];
      if (videoTrack) {
        videoTrack.onended = () => {
          stopRecording();
          isCapturing.value = false;
          screenStream.value = null;
        };
      }
      
      return screenStream.value;
    } catch (err) {
      error.value = `无法捕获屏幕: ${err.message}`;
      console.error('Error capturing screen:', err);
      throw err;
    }
  };

  // 创建录制器
  const createRecorder = (stream, recorderOptions = {}) => {
    try {
      error.value = null;
      
      // 检查浏览器支持的 MIME 类型
      const supportedMimeTypes = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus'];
      const mimeType = recorderOptions.mimeType || supportedMimeTypes.find(type => MediaRecorder.isTypeSupported(type));
      
      if (!mimeType) {
        throw new Error('No supported MIME type found');
      }
      
      recorder.value = new MediaRecorder(stream, { mimeType });
      recordedChunks.value = [];
      
      // 处理录制数据
      recorder.value.ondataavailable = (event) => {
        if (event.data.size > 0) {
          recordedChunks.value.push(event.data);
        }
      };
      
      // 录制结束处理
      recorder.value.onstop = () => {
        const videoBlob = new Blob(recordedChunks.value, { type: mimeType });
        recordedVideo.value = URL.createObjectURL(videoBlob);
        stopDurationTimer();
        isRecording.value = false;
      };
      
      // 录制开始处理
      recorder.value.onstart = () => {
        isRecording.value = true;
        startDurationTimer();
      };
      
      return recorder.value;
    } catch (err) {
      error.value = `创建录制器失败: ${err.message}`;
      console.error('Error creating recorder:', err);
      throw err;
    }
  };

  // 开始录制
  const startRecording = async (captureConstraints = {}) => {
    try {
      error.value = null;
      
      // 如果没有屏幕流,先捕获屏幕
      if (!screenStream.value) {
        await captureScreen(captureConstraints);
      }
      
      // 创建录制器
      if (!recorder.value) {
        createRecorder(screenStream.value);
      }
      
      // 开始录制
      recorder.value.start(1000);
      
      return recorder.value;
    } catch (err) {
      error.value = `开始录制失败: ${err.message}`;
      console.error('Error starting recording:', err);
      throw err;
    }
  };

  // 停止录制
  const stopRecording = () => {
    try {
      error.value = null;
      
      if (recorder.value && recorder.value.state !== 'inactive') {
        recorder.value.stop();
      }
    } catch (err) {
      error.value = `停止录制失败: ${err.message}`;
      console.error('Error stopping recording:', err);
      throw err;
    } finally {
      isRecording.value = false;
      stopDurationTimer();
    }
  };

  // 停止捕获
  const stopCapture = () => {
    stopRecording();
    
    if (screenStream.value) {
      screenStream.value.getTracks().forEach(track => track.stop());
      screenStream.value = null;
      isCapturing.value = false;
    }
  };

  // 重置录制器
  const resetRecorder = () => {
    stopCapture();
    
    if (recordedVideo.value) {
      URL.revokeObjectURL(recordedVideo.value);
      recordedVideo.value = null;
    }
    
    if (recorder.value) {
      recorder.value = null;
    }
    
    recordedChunks.value = [];
    duration.value = 0;
    error.value = null;
  };

  // 开始时长计时器
  const startDurationTimer = () => {
    if (timer) {
      clearInterval(timer);
    }
    timer = setInterval(() => {
      duration.value += 1;
    }, 1000);
  };

  // 停止时长计时器
  const stopDurationTimer = () => {
    if (timer) {
      clearInterval(timer);
      timer = null;
    }
  };

  // 组件卸载时清理资源
  onUnmounted(() => {
    resetRecorder();
  });

  return {
    screenStream,
    recordedVideo,
    isCapturing,
    isRecording,
    duration,
    error,
    captureScreen,
    startRecording,
    stopRecording,
    stopCapture,
    resetRecorder
  };
}

最佳实践

1. 浏览器兼容性

  • 检查 API 支持:在使用前检查浏览器是否支持 Screen Capture API
  • 提供降级方案:对于不支持的浏览器,提供替代方案
  • 测试不同浏览器:在主流浏览器中测试捕获功能

2. 权限处理

  • 明确的权限请求:在请求权限前,向用户解释为什么需要屏幕捕获权限
  • 处理权限拒绝:优雅处理用户拒绝权限的情况
  • 显示权限状态:向用户显示当前的权限状态

3. 性能优化

  • 限制捕获质量:根据实际需求调整视频分辨率和帧率
  • 关闭不必要的轨道:仅捕获必要的视频和音频轨道
  • 及时释放资源:捕获完成后及时关闭媒体流
  • 使用硬件加速:利用浏览器的硬件加速功能

4. 用户体验

  • 清晰的状态指示:向用户显示当前捕获状态
  • 提供停止按钮:允许用户随时停止捕获
  • 显示捕获预览:提供实时预览捕获内容
  • 支持快捷键:实现常用的捕获快捷键

5. 安全考虑

  • 最小权限原则:仅请求必要的捕获权限
  • 保护用户隐私:避免捕获敏感信息
  • 安全存储录制内容:妥善处理和存储录制的屏幕内容
  • 定期清理数据:定期清理临时录制文件

常见问题与解决方案

1. 浏览器不支持 Screen Capture API

问题:在某些旧浏览器中,Screen Capture API 不被支持

解决方案

if (!('getDisplayMedia' in navigator.mediaDevices)) {
  console.error('Screen Capture API is not supported in this browser');
  // 提供降级方案
}

2. 用户拒绝屏幕捕获权限

问题:用户拒绝了屏幕捕获权限

解决方案

try {
  const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
} catch (error) {
  if (error.name === 'NotAllowedError') {
    alert('请允许屏幕捕获权限,否则无法录制屏幕');
  } else {
    alert(`屏幕捕获失败: ${error.message}`);
  }
}

3. 捕获的视频质量不佳

问题:捕获的屏幕视频质量不佳,出现模糊或卡顿

解决方案

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: {
    cursor: 'always',
    width: { ideal: 1920 },
    height: { ideal: 1080 },
    frameRate: { ideal: 60 }
  }
});

4. 录制的视频没有声音

问题:录制的屏幕视频没有声音

解决方案

  • 确保在 constraints 中包含 audio: true
  • 检查系统音频设置
  • 测试不同的浏览器

5. 捕获的屏幕比例不正确

问题:捕获的屏幕视频比例不正确

解决方案

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: {
    cursor: 'always',
    aspectRatio: 16/9
  }
});

进阶学习资源

  1. MDN 文档Screen Capture API
  2. Web.dev 教程Screen Capture API: Capture Screen Content
  3. W3C 规范Screen Capture
  4. GitHub 示例Screen Capture API Samples
  5. Vue 3 组合式 API 文档Composition API

实战练习

练习:构建一个屏幕录制应用

目标:使用 Vue 3 和 Screen Capture API 构建一个功能完整的屏幕录制应用

功能要求

  1. 屏幕捕获

    • 支持捕获整个屏幕、单个窗口或浏览器标签页
    • 提供捕获选项配置
    • 显示捕获预览
  2. 屏幕录制

    • 支持开始、暂停和停止录制
    • 显示录制时长
    • 支持录制系统音频
  3. 视频管理

    • 播放录制完成的视频
    • 下载录制的视频
    • 删除录制的视频
  4. 用户体验

    • 响应式设计,适配不同屏幕尺寸
    • 清晰的状态指示和错误提示
    • 支持键盘快捷键

实现步骤

  1. 创建一个 Vue 3 项目
  2. 实现 useScreenRecorder 组合式函数
  3. 构建屏幕录制界面
  4. 实现屏幕捕获和录制功能
  5. 添加视频预览和管理功能
  6. 测试不同浏览器兼容性

示例代码结构

<template>
  <div class="screen-recorder">
    <div class="header">
      <h1>屏幕录制器</h1>
    </div>
    
    <div class="main">
      <div class="preview-container">
        <video 
          ref="videoElement" 
          class="preview" 
          :src="recordedVideo || screenStream"
          :autoplay="true" 
          :muted="!recordedVideo" 
          :controls="recordedVideo"
        ></video>
        
        <div class="recording-indicator" v-if="isRecording">
          <div class="recording-dot"></div>
          <span>录制中</span>
        </div>
        
        <div class="duration" v-if="isRecording">
          {{ formatDuration(duration) }}
        </div>
      </div>
      
      <div class="controls">
        <div class="status" v-if="error">
          {{ error }}
        </div>
        
        <div class="buttons">
          <button 
            @click="handleStartRecording" 
            :disabled="isRecording"
          >
            开始录制
          </button>
          
          <button 
            @click="handleStopRecording" 
            :disabled="!isRecording"
          >
            停止录制
          </button>
          
          <button 
            @click="handleSaveVideo" 
            :disabled="!recordedVideo"
          >
            保存视频
          </button>
          
          <button 
            @click="handleReset" 
            :disabled="!isRecording && !recordedVideo"
          >
            重置
          </button>
        </div>
        
        <div class="info" v-if="recordedVideo">
          <p>录制完成!</p>
          <a :href="recordedVideo" :download="`screen-recording-${Date.now()}.webm`">
            下载视频
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue';
import { useScreenRecorder } from './composables/useScreenRecorder';

const videoElement = ref(null);

const {
  screenStream,
  recordedVideo,
  isCapturing,
  isRecording,
  duration,
  error,
  startRecording,
  stopRecording,
  resetRecorder
} = useScreenRecorder();

// 格式化时长
const formatDuration = (seconds) => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};

// 开始录制
const handleStartRecording = async () => {
  try {
    await startRecording({
      video: {
        cursor: 'always',
        width: { ideal: 1920 },
        height: { ideal: 1080 }
      },
      audio: true
    });
  } catch (err) {
    console.error('Error starting recording:', err);
  }
};

// 停止录制
const handleStopRecording = () => {
  stopRecording();
};

// 保存视频
const handleSaveVideo = () => {
  console.log('Video saved');
  // 录制完成后,recordedVideo 会自动生成,用户可以通过下载链接保存
};

// 重置录制器
const handleReset = () => {
  resetRecorder();
};

// 组件挂载时初始化
onMounted(() => {
  console.log('Screen Recorder initialized');
});

// 监听录制状态变化
watch(isRecording, (newVal) => {
  if (newVal) {
    console.log('Recording started');
  } else {
    console.log('Recording stopped');
  }
});
</script>

<style scoped>
.screen-recorder {
  display: flex;
  flex-direction: column;
  height: 100vh;
  font-family: Arial, sans-serif;
  background-color: #f5f5f5;
}

.header {
  padding: 1rem;
  background-color: #333;
  color: white;
  text-align: center;
}

.main {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 2rem;
}

.preview-container {
  position: relative;
  width: 100%;
  max-width: 800px;
  margin-bottom: 2rem;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  background-color: #000;
}

.preview {
  width: 100%;
  height: auto;
  display: block;
}

.recording-indicator {
  position: absolute;
  top: 1rem;
  left: 1rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  background-color: rgba(255, 0, 0, 0.8);
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  font-size: 0.9rem;
}

.recording-dot {
  width: 8px;
  height: 8px;
  background-color: white;
  border-radius: 50%;
  animation: blink 1s infinite;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.duration {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background-color: rgba(0, 0, 0, 0.8);
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  font-size: 0.9rem;
  font-weight: bold;
}

.controls {
  width: 100%;
  max-width: 800px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
}

.status {
  color: red;
  font-size: 0.9rem;
  text-align: center;
}

.buttons {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  justify-content: center;
}

.buttons button {
  padding: 0.8rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.2s;
  background-color: #42b883;
  color: white;
}

.buttons button:hover:not(:disabled) {
  background-color: #35495e;
  transform: translateY(-2px);
}

.buttons button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
  transform: none;
}

.info {
  text-align: center;
  color: #666;
}

.info a {
  color: #42b883;
  text-decoration: none;
  font-weight: bold;
}

.info a:hover {
  text-decoration: underline;
}
</style>

进阶学习资源

  1. MDN Web Docs

  2. Web.dev 文章

  3. GitHub 仓库

  4. 视频教程

  5. 规范文档

总结

Screen Capture API 为 web 应用提供了强大的屏幕捕获能力,结合 Vue 3 的组合式 API,可以构建出功能丰富、用户体验良好的屏幕录制应用。在使用过程中,需要注意浏览器兼容性、权限处理和用户体验,始终以用户为中心,提供清晰的反馈和友好的错误处理。

通过本教程的学习,你应该能够:

  • 理解 Screen Capture API 的核心概念和基本用法
  • 实现 Vue 3 组合式函数封装 API
  • 构建功能完整的屏幕录制应用
  • 遵循最佳实践,确保应用的兼容性和性能
  • 处理常见问题,提供良好的用户体验

Screen Capture API 是 web 应用向多媒体领域扩展的重要一步,它为开发者提供了更多可能性,可以构建出更加强大和灵活的 web 应用。

« 上一篇 Vue 3 与 MediaRecorder API 下一篇 » Vue 3 与 Resize Observer API