看到 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 定位为“一组面向视频场景的组合式函数集合”。它只解决三个高频问题场景核心 APIvideo-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()而是停止每条 trackstream.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/webmconst candidates [ video/webm;codecsvp9,opus, video/webm;codecsvp8,opus, video/webm;codecsavc1, video/mp4 ]; function pickMimeType() { return candidates.find(type MediaRecorder.isTypeSupported(type)) || ; }不同浏览器对容器和编码的支持差异非常明显浏览器推荐容器说明Chrome / EdgeWebM VP9默认组合文件体积比 VP8 更小画质更高FirefoxWebM VP8更稳定的兼容选择SafariMP4 H.264iOS / macOS 能直接播放 WebM 桌面端产物但在移动端 Safari 上支持很弱建议转码如果你目标平台是 Chrome 为主用 WebM VP9 是最省钱的选择如果产品必须覆盖 iOS Safari前端录制完之后大概率要交给后端用 ffmpeg 转成 H.264 MP4。这是我在真实项目里最常用的组合路径前端录 WebM后端转 MP4播放层我只展示后端转码结果。3.2 数据收集与文件生成不要等到停止才开始处理MediaRecorder不是录制结束时一次性给你完整文件的。它通过ondataavailable事件不断吐数据块。如果什么都不做这些数据块会堆积在内存里录制时间越长崩溃概率越高。常规做法是每 1 到 3 秒切分一次数据边录边收集最后一次性合成 Blobconst 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 Mbps1280x720 画面约 180 MB干净无马赛克VP8 4 Mbps1280x720 画面约 300 MB边缘略糊H.264 2 Mbps1280x720 画面约 150 MB最通用压缩效率高我的经验值如果前端录制只是临时存储videoBitsPerSecond: 2500000配合 720p 是一个性价比非常高的点。继续拉高码率人眼很难看出差异但内存和 CPU 压力会明显上升。还有一个常被忽略的细节录屏时如果要同时录入麦克风声音getDisplayMedia()的audio: true在 Chrome 上默认采集的是系统声音不一定包含麦克风。如果你希望录到“远程会议里对方说话的声音”通常需要把麦克风流和屏幕流用AudioContext合并后再交给MediaRecorder。这个话题展开又是一篇长文这里先提个醒别默认系统声音等于你想录的声音。4. 视频回放优化播放器不能只填一个 src4.1 预加载策略不要让页面一次性把所有视频都拉下来很多团队做视频列表页时直接把几十个video标签的src给上结果页面打开瞬间浏览器疯狂请求多个视频资源页面卡顿、流量爆炸。正确的策略是让浏览器只加载元数据video preloadmetadata playsinline muted controls /videopreloadmetadata的意思是只下载封面帧、时长、编码信息等元数据不加载视频主体数据。这对缩略图、时长展示、列表页都非常友好。等用户真的点击播放再通过 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 能播 WebMiOS Safari 可能直接黑屏。所以我更推荐的做法是准备两套源用source标签让浏览器自己挑video controls playsinline preloadmetadata source src/videos/demo.mp4 typevideo/mp4 source src/videos/demo.webm typevideo/webm /video浏览器会按顺序找第一个能支持的格式播放。这个方案我在 H5 活动页上用了很多次至今没有收到过“视频不能看”的反馈。但如果视频时长较长比如 10 分钟以上的课程我不建议直接塞一个 MP4 到video里。长视频最好切成 HLS播放端用hls.js拉流。HLS 的优势是边下边播拖动进度条也不用等整个文件加载完。可以这么说短视频用双格式文件长视频必须做流媒体切片这应该成为一个默认认知。4.4 视频性能与内存不要忽略解码器的压力视频播放本身是很吃资源的操作尤其在高分屏上播放多个视频时GPU 和内存压力都很大。这里有几个我实际用过的优化手段非可视区域的视频暂停播放并清空src而不是让它继续在后台解码视频的宽高不要超过实际渲染尺寸太多比如列表里播放 720p 就够了没必要加载 4K 原片开启playsinline在移动端避免全屏播放的切换开销不要对多个视频同时调用play()浏览器会明显掉帧。还有一个很实用的 APIrequestVideoFrameCallback()。它可以拿到每一帧的精确时间戳用来做画面同步分析、帧间隔统计、卡顿检测。比如你怀疑用户播放卡顿可以用它采集真实帧率而不是靠用户口头反馈“有点卡”。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 问题。把权限请求放在设备枚举前面这个问题就从根上消失了。