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 处理录制数据
- 限制最大录制时长
进阶学习资源
- MDN 文档:MediaRecorder API
- Web.dev 教程:MediaRecorder API: Recording Audio and Video
- W3C 规范:MediaStream Recording
- GitHub 示例:MediaRecorder API Samples
- Vue 3 组合式 API 文档:Composition API
实战练习
练习:构建一个视频录制应用
目标:使用 Vue 3 和 MediaRecorder API 构建一个功能完整的视频录制应用
功能要求:
视频录制:
- 从摄像头获取视频流
- 支持开始、暂停、恢复和停止录制
- 显示录制时长和状态
视频预览:
- 实时预览摄像头画面
- 播放录制完成的视频
- 支持全屏预览
视频管理:
- 保存录制的视频
- 下载录制的视频
- 删除录制的视频
用户体验:
- 响应式设计,适配不同屏幕尺寸
- 清晰的状态指示和错误提示
- 支持键盘快捷键
实现步骤:
- 创建一个 Vue 3 项目
- 实现
useMediaRecorder组合式函数 - 构建视频录制界面
- 实现视频录制和控制功能
- 添加视频预览和管理功能
- 测试不同浏览器兼容性
示例代码结构:
<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>进阶学习资源
MDN Web Docs:
Web.dev 文章:
GitHub 仓库:
视频教程:
规范文档:
总结
MediaRecorder API 为 web 应用提供了强大的媒体录制能力,结合 Vue 3 的组合式 API,可以构建出功能丰富、用户体验良好的媒体录制应用。在使用过程中,需要注意浏览器兼容性、性能优化和用户体验,始终以用户为中心,提供清晰的反馈和友好的错误处理。
通过本教程的学习,你应该能够:
- 理解 MediaRecorder API 的核心概念和基本用法
- 实现 Vue 3 组合式函数封装 API
- 构建功能完整的视频录制应用
- 遵循最佳实践,确保应用的兼容性和性能
- 处理常见问题,提供良好的用户体验
MediaRecorder API 是 web 应用向多媒体领域扩展的重要一步,它为开发者提供了更多可能性,可以构建出更加强大和灵活的 web 应用。