看到 video-use 这个名字,我第一反应是:这不就是把视频功能常用的那段代码抽出来吗?等真在项目里跑了一圈,才发现这个“用”字特别容易翻车——摄像头权限、编码容器、移动端回放,哪一环没捋清楚,线上就是一片黑屏加转圈。
简单交代一下背景:我在做内部工具类产品时,频繁接到“做个视频预览”“录个屏幕”“把这个录制的视频播出来”这类需求。需求描述越简单,落地越痛苦,因为浏览器的多媒体 API 跨度极大,而且每个平台的行为都不一样。后来我把这套逻辑沉淀成一组可复用的组合式函数,命名为 video-use,专门覆盖三个场景:实时预览、本地录制、回放优化。
这篇文章的价值在于:如果你接下来要在 Web 项目里加摄像头预览、录屏或视频回放能力,可以直接参考我提供的方案与参数;如果你想少踩点多媒体 API 的坑,也可以把它当一份踩坑实录读。我会把设计思路、关键代码、实测翻车点全部摊开讲,句句都是真跑出来的经验。
1. 为什么我会专门把“视频使用”做成一套工具
1.1 一个简单需求让我改了三次方案
背景是给内部团队做一个远程巡课工具,需求浓缩成一句话:“打开摄像头,能看到画面,能录下来,还能回放。”
我一开始的假设有三个,后来全被推翻:
- 假设一:摄像头画面不就是
<video>标签挂个 src?错。浏览器不允许你把摄像头流直接当 URL 用,必须通过getUserMedia拿到MediaStream对象,再赋给video.srcObject。 - 假设二:录屏就是把画面截成图片或直接调用系统录屏。错。Web 端没有“一键录屏”的官方 API,最现实的路径是
getDisplayMedia()拿到屏幕流,再用MediaRecorder编码成文件。 - 假设三:视频播放只要给
<video>填个 MP4 地址就行。错。移动端自动播放策略、预加载策略、编码兼容性,每一样都能让一个看似正常的页面翻车。
三次方案调整下来,我发现这类需求本质上不是“某个 API”的问题,而是一条从采集、编码、存储到播放的完整链路。任何一环缺失,用户看到的都是同一个结果:黑屏或者转圈。
1.2 video-use 实际覆盖的三个能力
我不想把事情搞成一个重型框架,所以 video-use 定位为“一组面向视频场景的组合式函数集合”。它只解决三个高频问题:
| 场景 | 核心 API | video-use 提供的封装能力 |
|---|---|---|
| 实时预览 | getUserMedia/getDisplayMedia | 流获取、约束处理、设备枚举、生命周期清理 |
| 本地录制 | MediaRecorder | 编码探测、数据分片、Blob 合并、文件下载 |
| 回放优化 | <video>/IntersectionObserver | 预加载策略、静音策略、兼容性降级、性能监控 |
它不碰 UI 层,不定义组件,不绑定框架。无论是 Vue、React 还是原生 JS,都能直接调用。这样做的原因后面我会专门讲,核心是为了让“视频使用”这件事不被 UI 状态绑架,同时也能在不同框架之间复用。
1.3 适合谁看,能省下哪些弯路
如果你是前端开发者或者全栈工程师,打算在项目里接入视频通话、录屏、课程回放等功能,这篇文章可以直接当参考手册用。我会给到完整的constraints参数、录制编码参数、防坑检查点。
如果你是非专业出身,比如产品经理或者刚转行做富交互页面,也能通过这篇文章理解一个关键结论:浏览器的视频功能从来不是“贴一个标签”那么简单,它受制于权限策略、编码支持和宿主环境的性能边界。理解这个底层逻辑之后,你至少能在提需求时避开那些“技术上不可能但听起来很简单”的雷区。
2. 摄像头预览:从 getUserMedia 到真正稳定的画面
2.1 getUserMedia 只是起点,真正的复杂度在约束与生命周期
第一版代码非常简单:
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); video.srcObject = stream;这段代码在 Chrome 桌面端可能直接就跑通了,但这只是起点。实际项目里马上会遇到这么几个问题:
- 用户没插摄像头,或者摄像头被别的应用占用;
- 用户拒绝了权限,而且手滑勾选了“不再询问”;
- 笔记本内置摄像头只能输出 640x480,你却想拿 1080p 做画面分析;
- 页面关闭后,摄像头指示灯还亮着,因为流没有被停止。
第一版的问题在于把video: true当成一个布尔值,而不是一组可调校约束。正确做法是把约束当成“谈判参数”传给浏览器:
const constraints = { audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 48000 }, video: { width: { ideal: 1280, min: 640, max: 1920 }, height: { ideal: 720, min: 480, max: 1080 }, frameRate: { ideal: 30, max: 60 }, facingMode: 'user' } };这里的ideal表示期望值,min和max是硬边界。浏览器会在合法范围内寻找最接近期望的组合。实测下来,ideal + min + max三件套比单独写一个固定值更稳妥,因为有些设备固定值会直接导致OverconstrainedError,而带理想值的写法可以让浏览器自动降级。
2.2 设备选择不能靠猜,要借助枚举能力
很多场景下你需要让用户切换摄像头。比如一台笔记本外接了 1080p 摄像头,但内置摄像头的质量明显更差,用户希望指定设备。
枚举设备的正确流程是:
async function listDevices() { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(d => d.kind === 'videoinput'); }但这个东西有一个隐蔽行为:在用户没有授权摄像头权限之前,enumerateDevices()返回的label和deviceId是不可用的——label是空字符串,deviceId也不稳定。所以我的建议是:先发起一次getUserMedia({ video: true, audio: false })拿到权限,再调用enumerateDevices()填充设备列表。
拿到设备 ID 之后,把它写进下一次调用的约束里:
const constraints = { video: { deviceId: { exact: selectedDeviceId }, width: { ideal: 1280 } } };exact表示严格执行,找不到这个设备就直接失败。这个字段在前端尤其好用,因为设备列表更新后,用户选中的那个设备 ID 可能是旧的,exact会让错误暴露得更明确,而不是静默切回默认摄像头。
2.3 生命周期管理:不清理流的后果很严重
在组件化开发里,最容易被忽略的问题是生命周期。只写“打开”不写“关闭”的话,会出现一个现象:组件都卸载了,浏览器的地址栏右侧依然亮着摄像头图标,甚至麦克风的小红点也一直闪。
正确的清理方式不是stream.stop(),而是停止每条 track:
stream.getTracks().forEach(track => track.stop()); video.srcObject = null;为什么一定要停 track?因为MediaStream只是一个逻辑容器,真正占用硬件的是里面的MediaStreamTrack。你用stopTracks()停掉所有轨道后,摄像头指示灯会立刻熄灭,系统状态栏的“正在使用摄像头”提示也会消失。
如果你想在 Vue 组件里做一个可复用的预览能力,大致的封装思路是这样的:
import { ref, onBeforeUnmount } from 'vue'; export function useCamera() { const videoElement = ref(null); let stream = null; async function start(constraints) { stop(); stream = await navigator.mediaDevices.getUserMedia(constraints); if (videoElement.value) { videoElement.value.srcObject = stream; await videoElement.value.play(); } return stream; } function stop() { if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; } if (videoElement.value) { videoElement.value.srcObject = null; } } onBeforeUnmount(stop); return { videoElement, start, stop }; }这段代码我实际用了很久,几乎没有遇到跑不通的情况。关键点在于start()内部先调用了一次stop(),这样即使你反复切换前后摄像头,也不会出现两条流同时占用设备的问题。
3. MediaRecorder 录制:把实时流变成文件的关键几步
3.1 录制前的容器与编码选择,决定文件能不能播
MediaRecorder是浏览器端把音视频流编码成文件的官方方案。它本身不挑编码,但浏览器支持什么编码,直接影响你最终产出的是什么格式。
我的经验是先探测,而不是硬写video/webm:
const candidates = [ 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm;codecs=avc1', 'video/mp4' ]; function pickMimeType() { return candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''; }不同浏览器对容器和编码的支持差异非常明显:
| 浏览器 | 推荐容器 | 说明 |
|---|---|---|
| Chrome / Edge | WebM + VP9 | 默认组合,文件体积比 VP8 更小,画质更高 |
| Firefox | WebM + VP8 | 更稳定的兼容选择 |
| Safari | MP4 + H.264 | iOS / macOS 能直接播放 WebM 桌面端产物,但在移动端 Safari 上支持很弱,建议转码 |
如果你目标平台是 Chrome 为主,用 WebM + VP9 是最省钱的选择;如果产品必须覆盖 iOS Safari,前端录制完之后大概率要交给后端用 ffmpeg 转成 H.264 MP4。这是我在真实项目里最常用的组合路径:前端录 WebM,后端转 MP4,播放层我只展示后端转码结果。
3.2 数据收集与文件生成:不要等到停止才开始处理
MediaRecorder不是录制结束时一次性给你完整文件的。它通过ondataavailable事件不断吐数据块。如果什么都不做,这些数据块会堆积在内存里,录制时间越长,崩溃概率越高。
常规做法是每 1 到 3 秒切分一次数据,边录边收集,最后一次性合成 Blob:
const recorder = new MediaRecorder(stream, { mimeType: pickMimeType(), videoBitsPerSecond: 2500000 }); const chunks = []; recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { chunks.push(event.data); } }; recorder.onstop = () => { const blob = new Blob(chunks, { type: recorder.mimeType }); downloadBlob(blob, 'recording.webm'); }; recorder.start(1000);start(1000)里的 1000 表示每 1000 毫秒触发一次ondataavailable。这样即使录制过程中页面崩溃,已经吐出来的数据也有机会在崩溃前被上报到远端,而不是全部丢失。
downloadBlob的标准姿势如下:
function downloadBlob(blob, fileName) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }注意URL.revokeObjectURL(url)一定要在下载触发之后执行,否则下载链接会提前失效。我见过不少人把revokeObjectURL放在click()同一行,导致下载文件变成 0 字节,这个顺序问题很隐蔽。
3.3 实测下来的录制参数建议
根据我记录的真实数据,同样一段 10 分钟的屏幕录制,在相同分辨率下:
| 编码方案 | 1080p 视频码率 | 10 分钟文件大小 | 观感 |
|---|---|---|---|
| VP9 @ 2.5 Mbps | 1280x720 画面 | 约 180 MB | 干净无马赛克 |
| VP8 @ 4 Mbps | 1280x720 画面 | 约 300 MB | 边缘略糊 |
| H.264 @ 2 Mbps | 1280x720 画面 | 约 150 MB | 最通用,压缩效率高 |
我的经验值:如果前端录制只是临时存储,videoBitsPerSecond: 2500000配合 720p 是一个性价比非常高的点。继续拉高码率,人眼很难看出差异,但内存和 CPU 压力会明显上升。
还有一个常被忽略的细节:录屏时如果要同时录入麦克风声音,getDisplayMedia()的audio: true在 Chrome 上默认采集的是系统声音,不一定包含麦克风。如果你希望录到“远程会议里对方说话的声音”,通常需要把麦克风流和屏幕流用AudioContext合并后再交给MediaRecorder。这个话题展开又是一篇长文,这里先提个醒:别默认系统声音等于你想录的声音。
4. 视频回放优化:播放器不能只填一个 src
4.1 预加载策略:不要让页面一次性把所有视频都拉下来
很多团队做视频列表页时,直接把几十个<video>标签的src给上,结果页面打开瞬间,浏览器疯狂请求多个视频资源,页面卡顿、流量爆炸。
正确的策略是让浏览器只加载元数据:
<video preload="metadata" playsinline muted controls ></video>preload="metadata"的意思是:只下载封面帧、时长、编码信息等元数据,不加载视频主体数据。这对缩略图、时长展示、列表页都非常友好。等用户真的点击播放,再通过 JS 把src或者srcObject填进去。
如果视频很多,我还会加上IntersectionObserver做进视口才加载:
const io = new IntersectionObserver((entries) => { entries.forEach(entry => { const video = entry.target; if (entry.isIntersecting && !video.src) { video.src = video.dataset.src; video.load(); } }); }, { rootMargin: '200px' });这个模式我称之为“视口加载”,对长列表视频页的收益非常明显。实测一个包含 50 条视频的页面,只加载用户看得见的两三条,首屏渲染时间从约 3 秒降到 800 毫秒以下。
4.2 自动播放限制:移动端不是你说了算
移动端浏览器默认禁止带声音的视频自动播放,这是平台策略,不能通过前端代码绕过。但我发现一个非常实用的组合:muted属性加上video.play()的 Promise 化处理。
video.muted = true; const playPromise = video.play(); if (playPromise !== undefined) { playPromise.catch(() => { // 自动播放被拒绝时的降级:显示一个播放按钮,等待用户点击 showPlayButton(); }); }需要强调一点:如果把muted属性放在 HTML 标签里,并且playsinline也写上,iOS Safari 上大多数情况都能静音自动播放。但如果你在用户已经交互之后调用了play(),即使没有muted也可以成功,因为“用户手势”解除了自动播放限制。
4.3 视频源兼容处理:一个 src 走天下不现实
同一个视频,桌面 Chrome 能播 WebM,iOS Safari 可能直接黑屏。所以我更推荐的做法是准备两套源,用<source>标签让浏览器自己挑:
<video controls playsinline preload="metadata"> <source src="/videos/demo.mp4" type="video/mp4"> <source src="/videos/demo.webm" type="video/webm"> </video>浏览器会按顺序找第一个能支持的格式播放。这个方案我在 H5 活动页上用了很多次,至今没有收到过“视频不能看”的反馈。
但如果视频时长较长(比如 10 分钟以上的课程),我不建议直接塞一个 MP4 到<video>里。长视频最好切成 HLS,播放端用hls.js拉流。HLS 的优势是边下边播,拖动进度条也不用等整个文件加载完。可以这么说:短视频用双格式文件,长视频必须做流媒体切片,这应该成为一个默认认知。
4.4 视频性能与内存:不要忽略解码器的压力
视频播放本身是很吃资源的操作,尤其在高分屏上播放多个视频时,GPU 和内存压力都很大。这里有几个我实际用过的优化手段:
- 非可视区域的视频,暂停播放并清空
src,而不是让它继续在后台解码; - 视频的宽高不要超过实际渲染尺寸太多,比如列表里播放 720p 就够了,没必要加载 4K 原片;
- 开启
playsinline,在移动端避免全屏播放的切换开销; - 不要对多个视频同时调用
play(),浏览器会明显掉帧。
还有一个很实用的 API:requestVideoFrameCallback()。它可以拿到每一帧的精确时间戳,用来做画面同步分析、帧间隔统计、卡顿检测。比如你怀疑用户播放卡顿,可以用它采集真实帧率,而不是靠用户口头反馈“有点卡”。
5. 三个真实项目里的隐蔽问题:排查过程与修复记录
5.1 问题一:录制的视频只有音轨没有画面
现象是:用 MediaRecorder 录制了一个 canvas 实时画面合成的视频,播放时声音正常,画面全黑。
排查过程是这样走的:
- 第一步,检查
MediaRecorder的mimeType,换成 VP8、VP9、MP4 都还是黑屏,排除编码容器问题; - 第二步,检查 stream 的 track。打印
stream.getVideoTracks().length,结果是 1,视频轨道确实存在; - 第三步,把 canvas 的渲染内容单独截图保存,图片一切正常;
- 第四步,怀疑是 canvas 画面没有“持续变化”导致流里没有关键帧。
根因定位到了:canvas.captureStream(30)返回的流并不是每时每刻都输出画面,它只在画布内容发生改变时才会推帧。如果我的绘制逻辑只画了一次,或者画面内容长时间不变,MediaRecorder拿到的视频轨道里就没有足够的关键帧,播放器解码后就是黑屏。
修复方式是在开始录制前强制绘制一帧:
const canvasStream = canvas.captureStream(30); const ctx = canvas.getContext('2d'); ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); canvasStream.getVideoTracks()[0].requestFrame();requestFrame()这个方法是解决问题的关键,它告诉 captureStream 立刻捕获当前画面作为一帧。之后再启动MediaRecorder,录出来的视频就有了第一帧关键帧。
5.2 问题二:iOS 上摄像头画面旋转了九十度
现象是:移动端竖屏打开摄像头,预览画面横过来了,录出来的视频也是横的。
排查过程:
- 第一步,先在 Android Chrome 上测试,画面方向正常,于是问题集中在 iOS Safari;
- 第二步,检查 CSS 是否设置了
transform: rotate(90deg),发现一旦设置,视觉方向对了,但录制出来的文件依然是横的; - 第三步,打印
video.videoWidth和video.videoHeight,发现 iOS 上摄像头输出的原生分辨率就是横屏的 1280x720,并不是页面视口的竖屏方向。
这是我目前遇到最接近“浏览器 bug”的行为:iOS 摄像头流不会跟随设备方向自动旋转,视频轨道始终以设备自然方向输出。当时的处理方案是监听方向变化,手动调整 CSS 旋转:
function handleOrientation() { const angle = window.orientation || 0; if (angle === 90) { video.style.transform = 'rotate(90deg)'; } else if (angle === -90) { video.style.transform = 'rotate(-90deg)'; } else { video.style.transform = 'none'; } } window.addEventListener('orientationchange', handleOrientation); handleOrientation();但这里有个坑:CSS transform 只影响预览显示,不影响录制文件的存储方向。后来我的做法是尽量引导用户横屏使用摄像头录制,同时在 UI 上给一个明显的“横向放置设备”提示,产品侧也接受了这个折中。
如果你真的需要竖屏录制的 MP4,最稳妥的方案是把视频帧绘到 canvas 上,旋转后再用canvas.captureStream()录制,而不是直接录摄像头流。
5.3 问题三:长时间录屏导致内存和文件尺寸不可控
现象是:一次巡课录了约 40 分钟,页面在 25 分钟左右崩溃;侥幸录完的,最终下载文件接近 1.2 GB。
排查过程:
- 第一步,先看
MediaRecorder的ondataavailable事件是否正常触发。打印日志显示所有数据块都正常 push 到了一个数组里; - 第二步,监控
performance.memory.usedJSHeapSize,发现内存从录制开始就持续线性增长,30 分钟后达到 600 MB 以上; - 第三步,检查
chunks数组的数量和总大小,40 分钟的数据块全部保存在页面内存中,合成 Blob 时又产生了一次大内存拷贝,直接击穿内存。
根因很清楚:我把所有数据块都留在前端内存里,等待最后一次性合成。修复策略是改成分段上传,每 10 秒把已生成的数据块通过fetch传到服务器。
recorder.ondataavailable = async (event) => { if (event.data && event.data.size > 0) { await uploadChunk(event.data); } };这样前端内存里永远只保留当前 10 秒的数据,不存在累积爆炸的可能。后端把分片按顺序写到一个临时文件里,等onstop再统一合并。
同时我也调低了录制参数,把分辨率从 1080p 降到 720p、码率从 4 Mbps 降到 2.5 Mbps。实测同一段内容,文件大小从 1.2 GB 降到了约 450 MB,内存曲线稳定在 150 MB 以内,不再有页面崩溃的问题。
6. video-use 的设计取舍与后续扩展方向
6.1 为什么选择组合式函数而不是写一个类
我最初也考虑过把整个视频能力封装成一个VideoManager类,通过实例方法控制一切。但实际用下来发现,类的方式在跨框架复用和状态绑定上有两个明显问题:
- 类的内部状态和 UI 状态难以同步。比如预览结束时要清空页面上的
<video>,你必须在类外部额外写监听,时间一久代码很容易失控; - 类的扩展点固定,测试和调试都要通过实例方法,链路长。
组合式函数的好处是“状态就是返回值,生命周期由宿主框架管理”。比如 Vue 里你可以在onBeforeUnmount里自动清理,React 里可以放进useEffect的 cleanup。这样一来,调用方拿到的是一个{ start, stop, stream, error, isRecording }这样的普通对象,理解成本很低。
6.2 统一的错误处理与降级策略
video-use 里我最看重的一层不是功能代码,而是错误分类。浏览器多媒体 API 的错误信息千奇百怪,但归纳起来就这么几类:
| 错误名 | 含义 | 我的处理建议 |
|---|---|---|
NotAllowedError | 用户拒绝权限 | 引导用户检查地址栏权限设置,提供“重新请求”按钮 |
NotFoundError | 找不到摄像头/麦克风 | 隐藏“开始录制”入口,显示缺设备提示 |
NotReadableError | 设备被其他程序占用 | 提示关闭其他视频会议软件再重试 |
OverconstrainedError | 约束无法满足 | 自动降级到基础约束,重新调用一次 |
| 安全上下文错误 | 页面不是 HTTPS | 本地开发用 localhost 可绕过,线上必须 HTTPS |
降级策略上,我的原则是:宁可降低清晰度,也不要让用户完全用不了。比如请求 1080p 失败,就自动退到 720p 再请求一次;请求麦克风失败,就只录系统声音。这比直接弹一个红色错误框要友好得多。
6.3 可以继续扩展的方向
video-use 目前的形态只是一个基础能力集合,我自己在项目里已经在扩展这几个方向:
- 画面叠加:把摄像头流绘制到 canvas,叠加时间戳、水印、成绩弹幕后再用
captureStream()输出,适合在线考试、课堂录制场景; - 帧级分析:结合
requestVideoFrameCallback()做帧率统计和黑屏检测,可以在用户投诉前提前发现问题; - 远程传输:
MediaStream不用非得录制成本地文件,可以直接塞进RTCPeerConnection推给远端,做低延迟直播或一对一互动; - 后端转码:前端录制 WebM,后端用 ffmpeg 转成 H.264 MP4,再切片成 HLS,这是覆盖全平台播放的最稳路径。
最后分享一个小技巧:enumerateDevices()在未授权前返回的label是空的,所以我总是先调一次getUserMedia({ video: true, audio: true })再枚举设备。这个顺序很多人不知道,结果做设备列表时拿到一堆空 label,还以为是 API 问题。把权限请求放在设备枚举前面,这个问题就从根上消失了。