Vue 3 与 MediaRecorder API

概述

MediaRecorder API 是现代浏览器提供的一组 API,允许 web 应用直接录制音频和视频内容。它提供了一种简单的方式来捕获来自用户摄像头、麦克风或屏幕的媒体流,并将其编码为可播放的媒体文件。

与传统的服务器端录制相比,MediaRecorder API 具有以下优势:

  • 更低的延迟:录制和处理在客户端完成
  • 减少服务器负载:无需将原始媒体数据传输到服务器
  • 更好的隐私保护:用户可以控制哪些内容被录制
  • 更丰富的功能:支持实时处理和编辑录制内容

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

核心知识点

1. API 基本概念

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

  • MediaRecorder:用于录制媒体流的主要接口
  • MediaStream:表示媒体内容的流(音频或视频)
  • MediaStreamTrack:表示媒体流中的单个轨道(如音频轨道或视频轨道)
  • Blob:表示二进制数据块,用于存储录制的媒体数据

2. 录制音频

获取麦克风权限

async function getMicrophoneStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ 
      audio: true, 
      video: false 
    });
    return stream;
  } catch (error) {
    console.error('Error accessing microphone:', error);
  }
}

创建音频录制器

function createAudioRecorder(stream) {
  const recorder = new MediaRecorder(stream, {
    mimeType: 'audio/webm;codecs=opus'
  });
  
  const chunks = [];
  
  recorder.ondataavailable = (event) => {
    if (event.data.size > 0) {
      chunks.push(event.data);
    }
  };
  
  recorder.onstop = () => {
    const audioBlob = new Blob(chunks, { type: 'audio/webm' });
    const audioUrl = URL.createObjectURL(audioBlob);
    // 使用 audioUrl 播放或下载音频
  };
  
  return recorder;
}

3. 录制视频

获取摄像头权限

async function getCameraStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ 
      video: { width: 1280, height: 720 }, 
      audio: true 
    });
    return stream;
  } catch (error) {
    console.error('Error accessing camera:', error);
  }
}

创建视频录制器

function createVideoRecorder(stream) {
  const recorder = new MediaRecorder(stream, {
    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 播放或下载视频
  };
  
  return recorder;
}

4. 录制控制

开始录制

recorder.start(1000); // 每1秒触发一次dataavailable事件

暂停录制

if (recorder.state === 'recording') {
  recorder.pause();
}

恢复录制

if (recorder.state === 'paused') {
  recorder.resume();
}

停止录制

if (recorder.state !== 'inactive') {
  recorder.stop();
}

5. Vue 3 组合式 API 封装

创建一个 useMediaRecorder 组合式函数来封装 MediaRecorder API:

import { ref, onUnmounted } from 'vue';

export function useMediaRecorder() {
  const stream = ref(null);
  const recorder = ref(null);
  const mediaChunks = ref([]);
  const recordedMedia = ref(null);
  const isRecording = ref(false);
  const isPaused = ref(false);
  const duration = ref(0);
  const error = ref(null);
  let timer = null;

  // 获取媒体流
  const getMediaStream = async (constraints) => {
    try {
      error.value = null;
      stream.value = await navigator.mediaDevices.getUserMedia(constraints);
      return stream.value;
    } catch (err) {
      error.value = `无法访问媒体设备: ${err.message}`;
      console.error('Error getting media stream:', err);
      throw err;
    }
  };

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

  // 开始录制
  const startRecording = (timeslice = 1000) => {
    try {
      error.value = null;
      if (!recorder.value) {
        throw new Error('Recorder not created');
      }
      recorder.value.start(timeslice);
    } catch (err) {
      error.value = `开始录制失败: ${err.message}`;
      console.error('Error starting recording:', err);
      throw err;
    }
  };

  // 暂停录制
  const pauseRecording = () => {
    try {
      error.value = null;
      if (recorder.value && recorder.value.state === 'recording') {
        recorder.value.pause();
      }
    } catch (err) {
      error.value = `暂停录制失败: ${err.message}`;
      console.error('Error pausing recording:', err);
      throw err;
    }
  };

  // 恢复录制
  const resumeRecording = () => {
    try {
      error.value = null;
      if (recorder.value && recorder.value.state === 'paused') {
        recorder.value.resume();
      }
    } catch (err) {
      error.value = `恢复录制失败: ${err.message}`;
      console.error('Error resuming 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;
      isPaused.value = false;
    }
  };

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

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

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

  // 关闭媒体流
  const closeStream = () => {
    if (stream.value) {
      stream.value.getTracks().forEach(track => track.stop());
      stream.value = null;
    }
  };

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

  return {
    stream,
    recorder,
    recordedMedia,
    isRecording,
    isPaused,
    duration,
    error,
    getMediaStream,
    createRecorder,
    startRecording,
    pauseRecording,
    resumeRecording,
    stopRecording,
    resetRecorder,
    closeStream
  };
}

最佳实践

1. 浏览器兼容性

  • 检查 API 支持:在使用前检查浏览器是否支持 MediaRecorder API
  • 提供降级方案:对于不支持的浏览器,提供替代方案(如服务器端录制)
  • 测试不同浏览器:在主流浏览器中测试录制功能,确保兼容性

2. 性能优化

  • 限制录制质量:根据实际需求调整视频分辨率和比特率
  • 使用适当的 timeslice:合理设置 start() 方法的 timeslice 参数,平衡实时性和性能
  • 及时释放资源:录制完成后及时关闭媒体流和释放录制器
  • 使用 Web Workers:对于复杂的处理,考虑使用 Web Workers 避免阻塞主线程

3. 用户体验

  • 清晰的状态指示:向用户显示当前录制状态(录制中、暂停、已停止)
  • 时长显示:实时显示录制时长
  • 进度反馈:显示录制进度和文件大小
  • 错误提示:向用户显示友好的错误信息
  • 快捷键支持:支持常用的录制快捷键(如空格键暂停/恢复,ESC停止)

4. 安全考虑

  • 明确的权限请求:在请求媒体设备权限前,向用户解释为什么需要这些权限
  • 最小权限原则:只请求必要的媒体设备权限
  • 数据保护:妥善处理和存储录制的媒体数据
  • 用户控制:允许用户随时停止录制和删除录制内容

常见问题与解决方案

1. 浏览器不支持 MediaRecorder API

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

解决方案

if (!('MediaRecorder' in window)) {
  console.error('MediaRecorder API is not supported in this browser');
  // 提供降级方案
}

2. 无法访问媒体设备

问题:用户拒绝了媒体设备权限,或设备被其他应用占用

解决方案

try {
  const stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (error) {
  if (error.name === 'NotAllowedError') {
    alert('请允许访问媒体设备');
  } else if (error.name === 'NotFoundError') {
    alert('未找到指定的媒体设备');
  } else if (error.name === 'NotReadableError') {
    alert('媒体设备被其他应用占用');
  } else {
    alert(`访问媒体设备失败: ${error.message}`);
  }
}

3. 录制的视频没有声音

问题:录制的视频文件没有声音

解决方案

  • 确保在 constraints 中包含 audio: true
  • 检查麦克风是否正常工作
  • 检查浏览器是否支持同时录制音频和视频

4. 录制的文件太大

问题:录制的媒体文件体积过大

解决方案

  • 降低视频分辨率:video: { width: 640, height: 360 }
  • 降低视频比特率:使用支持的编解码器和参数
  • 缩短录制时长:添加自动停止机制
  • 压缩录制文件:使用第三方库压缩录制的媒体文件

5. 录制过程中浏览器崩溃

问题:长时间录制导致浏览器内存占用过高,最终崩溃

解决方案

  • 增加 timeslice 值,减少数据处理频率
  • 定期清理录制缓存:分段录制并处理
  • 使用 Web Workers 处理录制数据
  • 限制最大录制时长

进阶学习资源

  1. MDN 文档MediaRecorder API
  2. Web.dev 教程MediaRecorder API: Recording Audio and Video
  3. W3C 规范MediaStream Recording
  4. GitHub 示例MediaRecorder API Samples
  5. Vue 3 组合式 API 文档Composition API

实战练习

练习:构建一个视频录制应用

目标:使用 Vue 3 和 MediaRecorder API 构建一个功能完整的视频录制应用

功能要求

  1. 视频录制

    • 从摄像头获取视频流
    • 支持开始、暂停、恢复和停止录制
    • 显示录制时长和状态
  2. 视频预览

    • 实时预览摄像头画面
    • 播放录制完成的视频
    • 支持全屏预览
  3. 视频管理

    • 保存录制的视频
    • 下载录制的视频
    • 删除录制的视频
  4. 用户体验

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

实现步骤

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

示例代码结构

<template>
  <div class="video-recorder">
    <div class="header">
      <h1>视频录制器</h1>
    </div>
    
    <div class="main">
      <div class="preview-container">
        <video 
          ref="videoElement" 
          class="preview" 
          :src="recordedMedia || stream"
          :autoplay="true" 
          :muted="!recordedMedia" 
          :controls="recordedMedia"
        ></video>
        
        <div class="recording-indicator" v-if="isRecording">
          <div class="recording-dot"></div>
          <span>录制中</span>
        </div>
        
        <div class="duration" v-if="isRecording || isPaused">
          {{ formatDuration(duration) }}
        </div>
      </div>
      
      <div class="controls">
        <div class="status" v-if="error">
          {{ error }}
        </div>
        
        <div class="buttons">
          <button 
            @click="handleStart" 
            :disabled="isRecording || isPaused || recordedMedia"
          >
            开始录制
          </button>
          
          <button 
            @click="handlePauseResume" 
            :disabled="!isRecording && !isPaused"
          >
            {{ isPaused ? '恢复' : '暂停' }}
          </button>
          
          <button 
            @click="handleStop" 
            :disabled="!isRecording && !isPaused"
          >
            停止
          </button>
          
          <button 
            @click="handleSave" 
            :disabled="!recordedMedia"
          >
            保存
          </button>
          
          <button 
            @click="handleReset" 
            :disabled="!recordedMedia && !isRecording && !isPaused"
          >
            重置
          </button>
        </div>
        
        <div class="info" v-if="recordedMedia">
          <p>录制完成!</p>
          <a :href="recordedMedia" :download="`recording-${Date.now()}.webm`">
            下载视频
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

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

const videoElement = ref(null);

const {
  stream,
  recordedMedia,
  isRecording,
  isPaused,
  duration,
  error,
  getMediaStream,
  createRecorder,
  startRecording,
  pauseRecording,
  resumeRecording,
  stopRecording,
  resetRecorder,
  closeStream
} = useMediaRecorder();

// 格式化时长
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 initMediaStream = async () => {
  try {
    const mediaStream = await getMediaStream({
      video: { width: 1280, height: 720 },
      audio: true
    });
    createRecorder(mediaStream);
    
    // 将流赋值给 video 元素
    if (videoElement.value) {
      videoElement.value.srcObject = mediaStream;
    }
  } catch (err) {
    console.error('Error initializing media stream:', err);
  }
};

// 开始录制
const handleStart = () => {
  startRecording(1000);
};

// 暂停/恢复录制
const handlePauseResume = () => {
  if (isPaused.value) {
    resumeRecording();
  } else {
    pauseRecording();
  }
};

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

// 保存录制
const handleSave = () => {
  // 录制完成后,recordedMedia 会自动生成,用户可以通过下载链接保存
  console.log('录制已保存');
};

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

// 组件挂载时初始化
onMounted(() => {
  initMediaStream();
});

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

<style scoped>
.video-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);
}

.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. 规范文档

总结

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

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

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

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

« 上一篇 Vue 3 与 File System Access API 下一篇 » Vue 3 与 Screen Capture API