十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 3实战:用getUserMedia和MediaRecorder实现浏览器视频录制

Vue 3实战:用getUserMedia和MediaRecorder实现浏览器视频录制 看到这个标题我想起自己刚接触前端录视频功能时的状态明明网上代码一抓一大把但照着抄完总是各种问题要么没画面要么录出来是黑屏要么声音和画面对不上。后来把底层原理啃了一遍再把代码在 Vue 3 的组合式 API 里重新组织了一遍才发现这事儿的难点根本不在 API 本身而在于对整个数据流的理解。这篇文章不从“复制粘贴”开始而是从“为什么能录”讲起再把 Vue 3 的完整实现拆给你看。你要是零基础跟着走也能跑通要是已经有项目经验重点看第三、第四部分的工程化处理和坑点排查这部分是文档里查不到的。整个实现用到的核心 API 就三个getUserMedia、MediaRecorder、URL.createObjectURL但把三者串起来的方式决定了你的录制功能是 demo 级别还是能上生产。1. 为什么前端能录视频底层原理一次讲透很多教程一上来就贴代码但遇到问题就抓瞎。我的建议是先花十分钟搞懂浏览器到底是怎么把摄像头画面变成视频文件的这比多写十行代码都有用。1.1 从摄像头到浏览器getUserMedia 做了什么在浏览器里调起摄像头靠的是 Web API 里的navigator.mediaDevices.getUserMedia()。这个方法接收一个约束对象比如你要不要视频、要不要音频、分辨率要多少浏览器拿到这个请求后会弹出一个授权框用户点了允许系统就把摄像头和麦克风的访问权限交给当前页面。注意一个关键细节getUserMedia返回的不是视频文件也不是图片流而是一个MediaStream对象。你可以把它理解成一条“数据管道”管道的源头是摄像头硬件管道的另一端可以接到很多地方比如video元素用来预览或者交给MediaRecorder用来编码存储。这里有个新手容易忽略的点getUserMedia必须在安全上下文里才能调用。所谓安全上下文最简单的理解就是 https 协议或者 localhost。你如果直接在 file:// 协议下双击打开 HTML 文件或者在局域网 IP 的 http 页面里调试浏览器会直接拒绝授权请求报错信息通常是getUserMedia() is not allowed或者Only secure origins are allowed。所以本地开发老老实实用 localhost生产环境必须上 https。再补充一个约束参数的细节。常见的写法是const stream await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: user }, audio: true })这里facingMode: user表示前置摄像头environment表示后置摄像头主要用在移动端。width和height用ideal而不是硬编码是因为浏览器会根据硬件能力自动匹配最接近的分辨率。你要是直接写width: 1920在某些低端设备的摄像头上可能直接失败用ideal则会在不支持的设备上自动降级。1.2 真正干活的 MediaRecordergetUserMedia拿到的是原始媒体流但原始媒体流不等于视频文件。就好比你从水龙头接了一桶水但水还是水不是装在瓶子里贴着标签的商品。要把“水流”变成“瓶装水”需要另一套机制去编码、封装这个机制就是MediaRecorder。MediaRecorder是浏览器内置的媒体录制器它接收一个MediaStream然后按照你指定的 MIME 类型比如video/webm把流编码成一个二进制数据块。整个录制过程是事件驱动的核心事件就两个ondataavailable录制过程中数据会不断“吐”出来每次吐出的数据通过这个事件回调交给 JavaScript常见做法是把这些数据块chunk收集到一个数组里。onstop录制结束后触发此时把所有收集到的 chunk 合并起来就能得到一个完整的 Blob二进制大对象。这里有个容易误解的地方MediaRecorder并不是录完才给你数据而是一边录一边给。你在start()方法里传入一个时间片参数比如start(1000)表示每隔 1000 毫秒触发一次ondataavailable。这样做有两个明显的好处。第一个是防丢数据如果录到一半浏览器崩溃至少已经吐出来的数据块还能抢救一下第二个是支持边录边传你可以在每个时间片回调里直接把这一小块数据上传到服务器这样录完即传完用户不需要等。这个技巧在录长视频时尤其实用。1.3 Blob、对象URL与最终的视频文件录制结束之后我们手里有一堆数据块chunk它们是 Blob 对象。Blob 可以理解成一个“文件内容”的容器但它还不具备 URL不能直接塞进video的src属性播放。所以需要一步转换const blob new Blob(chunks, { type: video/webm }) const url URL.createObjectURL(blob)URL.createObjectURL()会在浏览器内存里生成一个临时的 URL 地址这个地址可以直接赋给video src用来预览。注意它是以blob:开头的不是 http 也不是 https只在当前页面会话里有效页面关闭就失效。所以如果需要持久保存必须把 Blob 上传到服务器或者转成 File 对象触发下载。到这里整个视频录制链路就清晰了摄像头 麦克风 -getUserMedia产出MediaStream-MediaRecorder消费流并产出 chunk - 合并 chunk 得到 Blob - 生成对象 URL 预览或上传。后面所有代码都是围绕这条链路去写的。2. 搭一个 Vue 3 录制项目环境准备与整体设计原理搞清楚了动手写代码之前先把环境搭好再把组件结构想清楚。这一步看着啰嗦但能省掉后面一大半 Debug 时间。2.1 用 Vite 快速创建 Vue 3 项目我用的是 Vite创建命令如下npm create vitelatest video-record-demo -- --template vue-ts cd video-record-demo npm install npm run dev这里选vue-ts模板是为了用 TypeScript 写逻辑类型提示对初学者找 bug 很有帮助。如果你用纯 JavaScript 也没问题把script setup langts改成script setup即可。选 Vite 的原因是启动速度和热更新都比 Webpack 时代快太多而且 Vue 3 官方也是默认推荐 Vite。项目跑起来后删掉src/components/HelloWorld.vue和src/assets里用不到的样式文件然后新建一个组件名字随意比如VideoRecorder.vue。后面所有核心代码都写在这个组件里。2.2 组件的职责划分与状态设计在动手写代码之前先想清楚这个录制组件需要管理哪些状态。我常用的划分方式是分成四组第一组是媒体流状态。一个MediaStream类型的变量用来保存getUserMedia返回的媒体流预览、录制、停止都离不开它。第二组是录制器状态。一个MediaRecorder类型的变量保存当前正在使用的录制器实例。再配一个布尔值isRecording用来控制按钮的文字切换和禁用状态。第三组是数据收集状态。一个数组chunks用来存放录制过程中ondataavailable回调吐出的 Blob 数据块。录制结束后清空下一次录制重新开始。第四组是结果状态。录制完成后生成的videoUrl字符串用来绑定video的src属性实现录制回放。用 TypeScript 写出来就是这样const stream refMediaStream | null(null) const mediaRecorder refMediaRecorder | null(null) const isRecording ref(false) const chunks refBlob[]([]) const videoUrl refstring()为什么把这些状态全部放进ref因为在 Vue 3 的组合式 API 里ref可以把普通变量变成响应式数据。isRecording.value true之后界面上绑定这个变量的按钮文字、样式都会自动更新不需要手动操作 DOM。这就比 jQuery 时代用text()去改按钮文案要舒服得多。组件的整体界面结构分成三块上面是实时预览区用video显示摄像头画面中间是操作按钮区放开始、停止、重录三个按钮下面是结果回放区录完之后显示录制结果并提供下载入口。这个结构很常规但也是经过实际项目检验的合理布局。3. 核心实现用组合式 API 把录制链路跑起来下面进入正题我把完整代码拆成几个步骤每一步都解释为什么这么写。你照着敲的时候如果遇到问题直接跳到第五部分查排查表。3.1 获取摄像头画面并实时预览获取摄像头预览核心代码如下async function startCamera() { if (stream.value) { stopTrack() } stream.value await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true }) nextTick(() { const video document.getElementById(preview) as HTMLVideoElement if (video) { video.srcObject stream.value video.muted true video.playsInline true await video.play().catch(() {}) } }) }几个关键点解释一下。先检查stream.value是否存在并做清理是为了防止用户重复点击“开启摄像头”导致多个媒体流并存。如果不清理旧流的轨道还活着摄像头指示灯会一直亮着那是很尴尬的一件事。video.srcObject stream.value是把媒体流绑定到视频元素的正确方式。很多人刚开始会写成video.src stream.value这绝对是错的src接收的是 URL 字符串srcObject接收的是MediaStream对象。用错之后视频不会报错但就是黑屏。给video设置muted true有两层考虑。一是避免回声啸叫尤其在使用外放时麦克风会录到扬声器发出的声音形成尖锐的反馈音二是浏览器自动播放策略的限制带声音的视频流在很多浏览器里不允许自动播放但静音视频可以。注意这里只是预览静音不影响录制时的收音。playsInline是移动端 Safari 的兼容属性不然 iPhone 上视频播放会自动全屏体验很割裂。最后一点为什么要用nextTick因为document.getElementById要在模板渲染完成之后才能拿到真实 DOM 元素。如果代码写在了setup函数体里直接执行模板还没挂载拿到的就是null。你也可以用 Vue 3 的ref绑定模板的方式代码会更优雅video refpreviewRef autoplay muted playsinline/videoconst previewRef refHTMLVideoElement | null(null) // 之后直接 previewRef.value.srcObject stream这只是一种写法效果一样但用ref更符合 Vue 的响应式习惯我推荐你用方式。3.2 开始录制MediaRecorder 的创建与启动摄像头预览已经通了接下来是录制逻辑。把MediaRecorder的创建单独抽成一个函数结构更清晰function startRecording() { if (!stream.value) { ElMessage.warning(请先开启摄像头) return } chunks.value [] const mimeTypes [ video/webm;codecsvp9,opus, video/webm;codecsvp8,opus, video/webm, video/mp4 ] const supportedType mimeTypes.find(type MediaRecorder.isTypeSupported(type)) const options: MediaRecorderOptions { videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 } if (supportedType) { options.mimeType supportedType } mediaRecorder.value new MediaRecorder(stream.value, options) mediaRecorder.value.ondataavailable (event: BlobEvent) { if (event.data.size 0) { chunks.value.push(event.data) } } mediaRecorder.value.onstop () { const blob new Blob(chunks.value, { type: supportedType || video/webm }) if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) } videoUrl.value URL.createObjectURL(blob) } mediaRecorder.value.start(1000) isRecording.value true }我说一下这段代码里几个容易被忽略的设计细节。mimeTypes数组的声明顺序不是随便写的。视频编码格式里VP9 压缩率高但编码慢VP8 兼容性更均衡MP4 是通用性最好的封装格式。所以我把优先顺序排成 VP9 - VP8 - webm - mp4优先选当前浏览器支持且质量较好的格式。MediaRecorder.isTypeSupported用来探测当前浏览器支持哪种格式避免抛NotSupportedError。videoBitsPerSecond和audioBitsPerSecond这两个参数可能很多人没见过但实际录制体验影响很大。videoBitsPerSecond是视频编码的码率单位是比特每秒我这里写的是 2500000也就是 2.5 Mbps。给得太低画面会出现明显的马赛克和模糊给得太高录制出的文件体积会暴涨上传也慢。720p 的视频用 2.5 Mbps 是个比较均衡的档位。audioBitsPerSecond128000即 128 kbps这是音频编码的常见码率人声清晰度足够文件也不会太大。mediaRecorder.value.start(1000)里的1000意思就是前面提到的 timeslice单位是毫秒。这里设置为 1000 毫秒就是一秒钟产出一个数据块。为什么要显式传这个参数因为如果不传ondataavailable只会在stop()的时候触发一次那数据收集就没有中间过程了。传了之后录制期间的每一秒都会有数据吐出来一方面防止长时间录制中途浏览器崩溃导致全丢另一方面也为后续实现录制进度条、实时数据上传留了口子。3.3 停止录制与数据合并停止录制的逻辑相对简单但也有一个细节不能漏function stopRecording() { if (mediaRecorder.value mediaRecorder.value.state ! inactive) { mediaRecorder.value.stop() isRecording.value false } if (stream.value) { stream.value.getTracks().forEach(track track.stop()) } }一个值得注意的点mediaRecorder.stop()之后onstop回调并不是立即执行的而会在底下异步触发。所以不要把“生成视频 URL”的逻辑放在stopRecording函数里而是要依赖上面注册过的onstop回调否则拿到的chunks数组可能还没拼完整。另一个点是state ! inactive的判断。MediaRecorder有三种状态inactive未录制、recording录制中、paused已暂停。直接调用stop()一个已经 inactive 的录制器会抛InvalidStateError异常。做一层状态判断就是为了防止用户连点两次停止按钮。关于stopTrack()也就是stream.getTracks().forEach(track track.stop())这行是把摄像头和麦克风全部关闭。不关的话摄像头指示灯会一直亮着本地环境也就罢了线上环境用户会觉得你的页面“偷窥”了他。这是必须处理的资源释放逻辑不能偷懒。3.4 录制回放与重复使用停止之后onstop回调里已经生成了videoUrl模板里可以直接绑定回放video v-ifvideoUrl :srcvideoUrl controls classresult-video /video回放视频要加controls属性这样用户才能控制播放、暂停、音量。录制预览时的视频不需要controls因为你不是要让用户看录像而是看实时的摄像头画面加了反而容易造成困扰。“重录”的逻辑也很直白把videoUrl清空撤掉已生成的对象 URL让界面回到初始状态function reset() { if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) videoUrl.value } chunks.value [] isRecording.value false mediaRecorder.value null }这里调用了URL.revokeObjectURL释放 Blob URL 占用的内存。对象 URL 本质上是保存在浏览器内部的一个引用如果不主动撤销它关联的 Blob 数据会一直留在内存里。录制次数多了内存占用会肉眼可见地涨上去页面最终可能卡死。这是很容易被忽略的内存管理细节。4. 进阶细节数据流、事件顺序与工程化处理基础链路跑通了但离“上生产”还差几步工程化的处理。这部分是决定项目体验的分水岭。4.1 数据块与数据上传策略很多刚开始写ondataavailable的人都遇到过一个问题为什么录制结束后的 Blob 是空的或者视频时长不对这通常是因为对数据块的理解不到位。在上面的代码里chunks.value.push(event.data)是把每个时间片的数据推到数组里录制结束后new Blob(chunks.value)按顺序合并。Blob 合并是严格按照数据块在数组里的顺序来的所以数组的 push 顺序必须和录制时间顺序一致这个天然满足。但有一个容易踩的坑event.data.size可能为 0。在一些浏览器里如果某个时间片内没有产生新的数据比如画面完全静止且音频静音回调吐出的数据块可能是空的。push 一个空 Blob 到数组里不影响合并结果但如果你在ondataavailable里直接把event.data拿去上传就会传一个空文件上去。所以一定要加if (event.data.size 0)这个判断。如果你的场景需要边录边传也就是流式上传可以在ondataavailable里直接把event.data放进FormData发送到服务器。这样用户停止录制的瞬间最后一块数据也已经在网络传输途中了。但要注意分片的命名规则服务器需要知道当前是第几片以及总共有多少片才能按顺序合并。常见的做法是文件名附带时间戳比如video_20250101153000_001.webm。4.2 文件格式与最后的下载步骤录制完成后让用户下载文件可以这样实现function downloadVideo() { if (!videoUrl.value) return const a document.createElement(a) a.href videoUrl.value a.download recording_${Date.now()}.webm a.click() }这里的关键是a.download属性它指定了下载文件的默认文件名。但要注意一个限制a.download只在同源 URL 或 blob URL 上生效。如果你的视频已经上传到 CDN拿到的是一个https://cdn.example.com/xxx.webm的地址再点下载就会变成打开新页面播放视频而是不能触发下载。所以下载操作尽量在拿到本地 Blob URL 时执行一旦上传完成下载逻辑就要改成请求服务器生成下载链接。还有一个格式兼容性问题Chrome 和 Firefox 默认录制出来的格式是video/webm但 Safari 有一些版本不支持录制 webm会直接不支持MediaRecorder或者编码失败。遇到这种情况比较省事的降级方案是用video/mp4新的 Safari 版本已经支持录制 MP4但兼容性边界比较模糊。我的建议是统一转 MP4 的工作尽量交给后端做前端只负责采集原始数据。如果一定要纯前端处理可以考虑 ffmpeg.wasm 这种方案但它的体积很大加载时间长要权衡利弊。4.3 组件卸载前的清理Vue 3 组合式 API 里有一个函数onBeforeUnmount这是组件销毁前钩子。录制功能必须在组件销毁前做一次完整的清理否则可能导致两个问题摄像头指示灯不灭、录音权限一直被占用。onBeforeUnmount(() { if (mediaRecorder.value mediaRecorder.value.state ! inactive) { mediaRecorder.value.stop() } if (stream.value) { stream.value.getTracks().forEach(track track.stop()) } if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) } })这个清理动作是生产环境必须写的不然用户从页面 A 跳到页面 BA 页面的摄像头还在工作浏览器底部的录音录像提示图标一直不消失很容易引发用户隐私方面的不满。4.4 移动端适配的几个细节如果你需要支持手机端前端的 H5 页面里有几点差异要特别注意。第一点是相机方向。桌面端摄像头通常是横屏的移动端竖屏居多但相机的传感器方向是固定的拍出来的视频可能旋转不正确或者被拉伸。处理方式可以结合 CSS 的object-fit和transform: rotate()或者在后端用元数据修正旋转角度。纯前端做旋转处理比较麻烦需要把每一帧绘制到 Canvas 上再转回来性能开销大不建议在录制阶段处理。第二点是分辨率控制。手机摄像头动辄支持 4K如果直接取最大分辨率H5 页面的内存占用会暴涨而且视频编码速度跟不上画面会卡顿。建议在上面的getUserMedia参数里把分辨率限制在 720p 甚至 480p优先保证流畅性。实测下来对大多数业务场景720p 完全够用。第三点是前后摄像头切换。切换就是重新调用一次getUserMedia并把facingMode参数换掉。切换时要注意先关掉旧流的所有轨道等新流的getUserMediaresolve 之后再更新srcObject顺序反了会出现黑屏或闪屏。5. 常见问题与排查技巧实录我踩过的那些坑这部分是本文的核心价值所在。每一个问题都是我实际开发中遇到过的有些查了很久才定位到原因。整理成速查表方便你遇到问题时快速对号入座。问题表现一摄像头预览黑屏但没有报错这个问题的典型原因有三个。第一src和srcObject用混了检查有没有给video.src直接赋了MediaStream第二页面是 http 协议且不是 localhostgetUserMedia被浏览器策略拒绝但错误没被捕获第三video元素的autoplay属性失效手动调用video.play()被浏览器的自动播放策略拦截了。排查顺序建议是先看 console 有没有未捕获的异常再看 Network 面板有没有权限报错最后手动调用video.play()并查看返回的 Promise 状态。问题表现二录出来的视频只有画面没有声音这个问题通常是因为getUserMedia里没传audio: true或者传了但被用户拒绝。另外要检查一下MediaRecorder的mimeType如果你手动指定了video/webm;codecsvp8这种不带音频编码的格式录出来的文件就没有音轨。最稳妥的做法是不手动指定mimeType让浏览器自动选择默认的音频编码格式。还有一点前面说过预览时给 video 元素设置了muted true这会导致某些新手产生旁边“我浏览器明明静音了为什么录出来没声音”的疑虑。其实muted只影响本地播放不影响录制。问题表现三录制较长的视频页面越来越卡原因大概率是chunks数组不断膨胀页面内存占用持续升高以及把大量 Blob 对象持续放在内存里没有释放。解决办法有几个方向如果业务允许录制过程中定时把已收集的数据上传并清空数组如果不允许流式上传也要在录制结束后主动把引用置空。另外检查一下是不是有其他代码对每个 chunk 做了额外的处理比如给每个 chunk 都生成了 Blob URL。问题表现四Mac 上 Chrome 录制正常Windows 上不能录制不同操作系统上的浏览器对 MediaRecorder 的 MIME 类型支持存在差异。Windows 版本 Chrome 对 VP9 的硬件编码支持较好但某些老旧版本或者特定设备驱动下VP9 编码会失败。遇到这种情况用MediaRecorder.isTypeSupported做格式探测后可以优先尝试video/webm;codecsvp8,opus作为降级方案。如果所有 webm 格式都不支持那就要考虑更换浏览器或者通知用户了。目前还没有一个全平台绝对兼容的纯前端录制方案这也是为什么很多商业产品最终选择用原生客户端做录制。问题表现五授权被拒绝一次之后再次点“开启摄像头”永远报错浏览器记住用户的授权选择后页面端无法通过代码强制弹窗重新询问。只能引导用户去浏览器设置里面把当前站点的摄像头/麦克风权限重新调整为允许。在 Chrome 里用户点击地址栏左侧的锁形图标就能看到权限管理入口。如果刷新页面之后每次都在授权弹窗出现前就报错可以去浏览器的站点设置里检查是否被标记为拒绝。问题表现六录制视频的时间戳和数据块对应不上如果你在ondataavailable里做实时预览或者上传要注意event.data和实际录制时间的对应关系并不严格精确。MediaRecorder的时间片只是“大致时间”具体数据块的大小和间隔受编码器输出波动影响。尤其画面剧烈变化时某个时间片的数据量会突然变大。所以不要根据数据块的个数去估算视频时长而要用Math相关方法或者读取 Blob 的时长信息来统计。问题表现七移动端 Safari 录制直接报不支持这可能是所有问题里最让人头疼的。旧版本 Safari 完全不支持MediaRecorder新版本支持但不支持 webm 编码。如果你的目标是兼容 iOS Safari判断逻辑要写成如果navigator.mediaDevices都不存在或者window.MediaRecorder不存在直接提示用户升级浏览器或者换 Chrome。如果存在但MediaRecorder.isTypeSupported(video/mp4)为 false可以把mimeType留空让浏览器选默认。真不行就只能走后端推流方案了。6. 一段可直接运行的完整示例完整代码组合起来大概长这样。你可以直接复制到 Vue 3 项目里跑注意样式部分我用的比较朴素你自己调就行。script setup langts import { ref, onBeforeUnmount, nextTick } from vue const previewRef refHTMLVideoElement | null(null) const resultRef refHTMLVideoElement | null(null) const stream refMediaStream | null(null) const mediaRecorder refMediaRecorder | null(null) const isRecording ref(false) const chunks refBlob[]([]) const videoUrl refstring() async function startCamera() { if (stream.value) { stream.value.getTracks().forEach(track track.stop()) } stream.value await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true }) await nextTick() if (previewRef.value) { previewRef.value.srcObject stream.value previewRef.value.muted true previewRef.value.playsInline true await previewRef.value.play() } } function startRecording() { if (!stream.value) { alert(请先开启摄像头) return } chunks.value [] const mimeTypes [ video/webm;codecsvp9,opus, video/webm;codecsvp8,opus, video/webm, video/mp4 ] const supportedType mimeTypes.find(type MediaRecorder.isTypeSupported(type)) const options: MediaRecorderOptions { videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 } if (supportedType) { options.mimeType supportedType } mediaRecorder.value new MediaRecorder(stream.value, options) mediaRecorder.value.ondataavailable (event: BlobEvent) { if (event.data.size 0) { chunks.value.push(event.data) } } mediaRecorder.value.onstop () { const blob new Blob(chunks.value, { type: supportedType || video/webm }) if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) } videoUrl.value URL.createObjectURL(blob) } mediaRecorder.value.start(1000) isRecording.value true } function stopRecording() { if (mediaRecorder.value mediaRecorder.value.state ! inactive) { mediaRecorder.value.stop() isRecording.value false } if (stream.value) { stream.value.getTracks().forEach(track track.stop()) } } function reset() { if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) videoUrl.value } chunks.value [] isRecording.value false mediaRecorder.value null } function downloadVideo() { if (!videoUrl.value) return const a document.createElement(a) a.href videoUrl.value a.download recording_${Date.now()}.webm a.click() } onBeforeUnmount(() { if (mediaRecorder.value mediaRecorder.value.state ! inactive) { mediaRecorder.value.stop() } if (stream.value) { stream.value.getTracks().forEach(track track.stop()) } if (videoUrl.value) { URL.revokeObjectURL(videoUrl.value) } }) /script template div classrecorder-container div classpreview-section video refpreviewRef classvideo-box muted playsinline/video /div div classactions button clickstartCamera :disabledisRecording开启摄像头/button button clickstartRecording :disabled!stream || isRecording开始录制/button button clickstopRecording :disabled!isRecording停止录制/button button clickreset重置/button /div div v-ifvideoUrl classresult-section video refresultRef :srcvideoUrl controls classvideo-box/video button clickdownloadVideo下载视频/button /div /div /template style scoped .recorder-container { max-width: 720px; margin: 0 auto; padding: 20px; } .video-box { width: 100%; border-radius: 8px; background: #000; } .actions { display: flex; gap: 12px; margin: 16px 0; } .actions button { padding: 8px 16px; } .result-section { margin-top: 16px; } /style这段代码里有几个交互上的细节值得说一下。开启摄像头和开始录制是两个按钮分开的这样设计是为了让用户先确认画面正常再动手录制。如果你把两步合并成一个按钮用户快速点击时可能在摄像头还没就绪的状态下就启动了录制容易出现只有声音没有画面的问题。把stopRecording里的轨道清理放在mediaRecorder.stop()之后也是经过思考的先让录制器进入停止流程再关闭轨道资源保证onstop回调能拿到完整的音视频数据。顺序反了的话某些浏览器下会出现录出的视频时长正常但音轨被截断的情况。样式部分我用得比较克制只写了必要的布局按钮和视频区域可以用自己的设计系统换掉。如果你要在正式项目中使用建议把按钮的 disable 逻辑做成更细的粒度比如“没授权摄像头时不允许点开始”以免用户误操作后不知道发生了什么。最后再分享一点个人体会前端视频录制这块难点不在于代码量而在于不同浏览器、不同设备上的表现差异极其离散。一台 Windows 台式机和一台 iPhone跑同一套代码体验可能天差地别。所以如果你打算在生产环境使用这个功能务必在目标用户的设备和浏览器上都实测一遍再决定是否全量开放。我自己的做法是上线前做了一个小范围的 Beta 测试列表把主流浏览器和操作系统排列组合跑一遍很多隐蔽问题都是在这个阶段暴露出来的。磨刀不误砍柴工这个功夫花得值。
返回列表