资讯动态

getUserMedia实战指南:音视频权限、约束控制与跨浏览器坑点

发布时间:2026/10/14 2:45:19 来源:尧图企业网站定制
1. 项目概述从“点一下就开摄像头”说起你有没有遇到过这样的场景在某个网页上点了个“开始视频通话”按钮浏览器突然弹出一个权限请求——“是否允许访问您的摄像头和麦克风”你点了“允许”画面立刻亮起自己正对着镜头眨眼睛。这个看似简单的动作背后真正干活的就是getUserMedia这个 API。它不是什么新潮框架也不是某个大厂私有方案而是原生 Web 标准的一部分自 Chrome 21、Firefox 17 起就已稳定支持如今在所有主流现代浏览器中包括 Safari 11、Edge 79都已默认可用。它解决的是一个最基础却最关键的前端问题如何让网页安全、可控、低延迟地拿到用户本地的音视频流。没有它就没有在线面试系统里的实时人脸核验没有教育平台上的双师课堂互动没有远程医疗中的医生端实时问诊画面也没有那些轻量级的 AR 滤镜小工具——它们全靠getUserMedia拿到原始流再交给 Canvas 处理、WebGL 渲染或 MediaRecorder 录制。我第一次在项目里用它是给某高校的线上实验平台加一个“学生操作过程录像”功能。当时团队想绕开第三方 SDK直接用原生能力实现“点击即录、自动上传”。结果发现光是调通getUserMedia就卡了两天Chrome 下一切正常Safari 却死活不触发回调同一台 Mac 上用 Safari 打开 localhost 正常换成局域网 IP 就报错NotAllowedError更离谱的是某次测试中明明用户点了“允许”但stream.getVideoTracks()[0].enabled居然为false画面黑屏控制台却没有任何报错。这些都不是文档里写的“标准流程”而是真实项目里每天都在发生的毛刺。所以这篇内容不讲 W3C 规范原文也不堆砌 MDN 示例代码而是聚焦于一个一线开发者真正需要知道的什么时候该用、怎么用得稳、为什么这么用、踩过哪些坑、以及如何一眼看出问题出在哪。适合刚学完 JavaScript 基础、正准备做音视频相关功能的前端同学也适合后端转全栈、需要快速理解前端媒体链路的技术负责人——只要你需要让网页“看见”或“听见”用户这篇就是你的实操手册。2. 核心设计思路与方案选型逻辑2.1 为什么非用getUserMedia不可替代方案为何行不通有人会问既然只是要个摄像头画面那用input typefile acceptimage/*不也能拍照上传吗或者用 Cordova/React Native 封装原生相机组件岂不是更可控这两种思路乍看合理但在 Web 场景下存在根本性缺陷。文件输入方案本质是“快照式交互”用户手动选择一张照片整个过程是离散的、单次的、无实时性的。而getUserMedia提供的是持续的 MediaStream 对象它是一个活的数据源可以被多个消费者同时订阅——你可以一边把流塞进video标签实时预览一边用CanvasRenderingContext2D.drawImage()抓帧做人脸识别一边用MediaRecorder录制成 MP4甚至还能把流传给RTCPeerConnection推到远端。这种“一源多用”的能力是任何文件选择器都无法模拟的。更重要的是getUserMedia的调用本身就是一个显式的用户授权信号。浏览器强制要求必须由用户手势如 click、tap触发且权限粒度精确到“仅摄像头”“仅麦克风”或“音视频都开”。这不仅是安全机制更是产品设计的约束它天然防止了页面静默偷拍也倒逼产品必须设计清晰的用户引导路径比如先显示一个大大的“开启摄像头”按钮而不是一进来就自动调用。至于原生封装方案问题在于部署成本与兼容性断层。一个纯 Web 应用如果为了调用摄像头就必须打包成 App那它的分发渠道、更新节奏、用户获取成本全部重构。而getUserMedia是标准 Web API只要用户用的是现代浏览器代码一次编写处处运行。我们曾对比过某跨平台系统用 WebView 加载 H5 页面时iOS 的 WKWebView 对getUserMedia的支持在 iOS 14.5 后才完全稳定早期版本需额外配置allowsInlineMediaPlayback true而 Android 端的 Chrome Custom Tabs 则基本无坑。这种差异虽然存在但远比维护两套原生代码iOS Swift Android Kotlin的成本低得多。所以当你的目标是“在浏览器里实现音视频实时交互”getUserMedia不是选项之一而是唯一符合 Web 原生精神的基础设施。2.2 方案选型的三个关键决策点在实际项目中getUserMedia的调用从来不是一行代码navigator.mediaDevices.getUserMedia({video: true})就能搞定的。它背后至少涉及三个必须提前明确的设计决策第一约束条件Constraints怎么写很多人以为{video: true}就够了但生产环境必须精细化控制。比如教育类应用需要高清板书识别就得指定width: {ideal: 1280}, height: {ideal: 720}而低带宽地区的远程问诊则应设frameRate: {max: 15}防止卡顿更关键的是facingMode: user前置和environment后置在移动端必须显式声明否则 iOS Safari 默认只返回前置摄像头流用户想用后置扫二维码就彻底失败。这些不是可有可无的参数而是直接影响用户体验的核心配置。第二错误处理策略怎么定getUserMedia的拒绝原因五花八门用户点“拒绝”、设备被占用、HTTPS 缺失、移动 Safari 的NotReadableError摄像头被其他 App 占用、甚至某些安卓厂商定制 ROM 的权限管理 Bug。如果只写一个.catch(err console.error(err))那线上监控里只会看到一堆NotAllowedError根本无法区分是用户主动拒绝还是系统拦截。我们必须按错误类型分级响应对NotAllowedError引导用户去设置页手动开启权限对NotFoundError提示“未检测到可用摄像头请检查硬件连接”对OverconstrainedError则要降级约束条件比如把1280x720降到640x480后重试。这决定了产品是“友好引导”还是“静默失败”。第三流的生命周期如何管理很多新手会忽略MediaStream对象一旦创建就会持续占用摄像头资源即使video元素已经从 DOM 中移除。在单页应用SPA里如果用户从视频页跳转到其他页面但忘记stream.getTracks().forEach(track track.stop())摄像头指示灯会一直亮着用户隐私风险陡增且可能影响后续调用设备被占用报错。因此必须建立严格的“申请-使用-释放”闭环在组件挂载时申请在卸载前主动停止所有轨道并监听visibilitychange事件——当页面切到后台时暂停流切回前台时恢复既省电又合规。这三个决策点不是技术细节而是架构层面的判断。它们共同决定了你的音视频功能是“能跑起来”还是“能长期稳定跑”。3. 核心细节解析与实操要点3.1 权限模型与 HTTPS 强制要求为什么 localhost 可以IP 就不行这是所有新手必撞的第一堵墙。当你在本地开发时用http://localhost:3000调用getUserMedia一切正常但一旦换成http://192.168.1.100:3000Chrome 就直接抛出SecurityError: getUserMedia() must be called from a secure context。这不是 Bug而是浏览器的硬性安全策略getUserMedia只允许在安全上下文Secure Context中运行。所谓安全上下文指协议为https://或地址为localhost含127.0.0.1——注意localhost是特例127.0.0.1也是但192.168.x.x、10.x.x.x等内网 IP 全部被排除。为什么这样设计因为摄像头和麦克风是高敏感设备如果 HTTP 页面也能随意调用中间人攻击者就能在用户不知情时静默采集音视频。localhost被豁免是因为它天然隔离于网络不存在中间人风险。而 HTTPS 则通过 TLS 加密保证传输链路安全防止脚本被篡改注入恶意调用。实操中有三个绕过方案但必须清楚各自适用场景开发阶段用localhost代替 IP。Webpack Dev Server 默认绑定localhost无需额外配置。如果必须用 IP比如真机调试可在启动时加--host 0.0.0.0然后在手机浏览器中访问http://你的电脑IP:3000—— 但这在 Chrome for Android 上仍会失败因为 Android Chrome 不将内网 IP 视为安全上下文。此时唯一解是启用 HTTPS 开发服务器。启用 HTTPS 开发环境。Webpack Dev Server 支持--https参数会自动生成证书Vite 则在vite.config.ts中配置server: { https: true }即可。生成的证书是自签名的首次访问会提示“不安全”但点击“高级”→“继续前往”即可。这是最推荐的方案因为它无限接近生产环境能提前暴露 HTTPS 相关问题比如混合内容警告。生产环境必须部署 HTTPS。任何面向公网的网站若想用getUserMediaHTTPS 是强制门槛。免费证书可从 Lets Encrypt 获取配合 Nginx 或 Caddy 自动续期。这里有个经验不要用 CDN 的 HTTPS 回源到 HTTP 源站因为浏览器校验的是最终 URL 的协议源站是否 HTTPS 无关紧要但若 CDN 配置错误导致部分资源走 HTTP可能触发混合内容阻断。提示Safari 的限制更严格。即使在localhost如果页面是通过file://协议打开比如双击 HTML 文件getUserMedia也会直接报NotAllowedError。必须通过本地服务器如 Python 的http.server或 Live Server 插件提供服务。3.2 约束条件Constraints的深度解析从true到精准控制{video: true}看似简单实则是最粗糙的调用方式。它等价于{video: {}}即让浏览器自由选择最优配置。但在真实项目中这往往导致不可控结果低端安卓机可能返回 320x240 的模糊流而高端 iPhone 可能坚持输出 4K造成带宽浪费。因此必须掌握约束条件的完整语法。约束条件分为三类必需mandatory、理想ideal、最大值/最小值min/max/exact。现代写法统一用ideal、min、maxmandatory已废弃。以视频为例一个生产级约束可能长这样const constraints: MediaStreamConstraints { video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 24, min: 15 }, facingMode: { exact: user }, // 必须前置不能妥协 aspectRatio: { ideal: 16 / 9 }, resizeMode: crop // Safari 特有确保画面不拉伸 }, audio: true // 麦克风默认开启 };逐项解释其原理与取舍逻辑width/heightideal表示“优先尝试这个分辨率”max是“绝不允许超过”。为什么设max: 1920因为某些安卓设备在ideal: 1280时会因驱动问题卡死但max设高些能让浏览器有 fallback 余地。实测发现ideal值设为常用分辨率如 640、1280、1920成功率最高设为 1366 这种非标值反而易失败。frameRatemin: 15是底线低于此帧率人眼会明显感知卡顿ideal: 24是平衡点高于 30 帧对 WebRTC 传输压力陡增且多数屏幕刷新率仅 60Hz24 帧已足够流畅。有趣的是frameRate在桌面端效果显著但在 iOS Safari 上几乎无效——苹果限制了 Web 端的帧率调控权实际输出由系统决定。facingMode: { exact: user }exact是最强约束表示“必须且只能是这个值否则报OverconstrainedError”。不用ideal是因为教育场景中用户必须面对前置摄像头若浏览器返回后置流某些安卓机默认行为整个功能就废了。此时应捕获错误提示用户手动切换。resizeMode: crop这是 Safari 16.4 新增的约束用于解决视频画面变形问题。当约束的宽高比与实际摄像头传感器不匹配时浏览器默认会“letterbox”加黑边而crop会裁剪边缘保持比例。Chrome 和 Firefox 尚不支持但加了无害不加则 Safari 用户看到的画面可能是压扁的。音频约束同理常见需求是降噪noiseSuppression: true、echoCancellation: true。但要注意这些是浏览器特性非所有设备都支持。可通过navigator.mediaDevices.getSupportedConstraints()检测当前环境是否支持if (navigator.mediaDevices.getSupportedConstraints().noiseSuppression) { constraints.audio { noiseSuppression: true, echoCancellation: true }; }3.3 错误类型详解与分级响应策略getUserMedia的 Promise 拒绝时err.name字段标识具体错误类型。MDN 文档列出了 7 种但实际项目中高频出现的只有 4 种每种都需要不同的用户引导策略错误名触发场景用户可见表现推荐响应方案NotAllowedError用户点击“拒绝”或系统级权限关闭如 iOS 设置中禁用浏览器权限弹窗消失Promise 立即 reject显示友好提示“请在浏览器地址栏点击锁形图标 → ‘网站设置’ → 将摄像头权限改为‘允许’”提供截图指引NotFoundError无可用摄像头/麦克风设备未插入、被占用、驱动异常无弹窗直接 reject提示“未检测到可用摄像头请检查设备连接或关闭正在使用摄像头的其他程序如 Zoom、Teams”NotReadableError设备被占用最常见于 iOS Safari微信内置浏览器无弹窗reject引导用户“请退出微信/企业微信用 Safari 或 Chrome 浏览器重新打开”技术上可尝试setTimeout(() getUserMedia(), 1000)延迟重试OverconstrainedError约束条件过于苛刻如要求 4K 但设备仅支持 720Prejecterr.constraint字段指出哪个约束失败立即降级重试将ideal分辨率减半1280→640frameRate从 24→15移除exact约束实操中我写了一个通用错误处理器嵌入所有getUserMedia调用点async function safeGetUserMedia(constraints: MediaStreamConstraints) { try { return await navigator.mediaDevices.getUserMedia(constraints); } catch (err: any) { const errorName err.name; console.warn([getUserMedia] ${errorName} occurred, err); if (errorName NotAllowedError) { showPermissionGuide(); // 弹出权限引导浮层 } else if (errorName NotFoundError) { showToast(未找到摄像头请检查硬件); } else if (errorName NotReadableError) { showToast(摄像头被占用请关闭其他应用); // 延迟1秒后重试避开瞬时占用 await new Promise(r setTimeout(r, 1000)); return safeGetUserMedia(constraints); } else if (errorName OverconstrainedError) { // 降级约束分辨率减半移除 exact const degraded degradeConstraints(constraints); return safeGetUserMedia(degraded); } else { showToast(未知错误${err.message}); } throw err; } }这个函数的价值在于它把“技术错误”翻译成了“用户可操作的动作”而不是让产品经理收到一句“调用失败”的工单。4. 实操过程与核心环节实现4.1 完整调用流程从按钮点击到画面渲染下面是一个生产环境可用的、带错误降级和生命周期管理的完整实现。我们以一个“视频预览组件”为例用 React Hooks 风格编写但逻辑适用于任何框架。import { useState, useEffect, useRef } from react; export default function VideoPreview() { const [stream, setStream] useStateMediaStream | null(null); const [isInitializing, setIsInitializing] useState(false); const [error, setError] useStatestring | null(null); const videoRef useRefHTMLVideoElement(null); // 清理函数停止流并重置状态 const cleanupStream () { if (stream) { stream.getTracks().forEach(track track.stop()); setStream(null); } }; // 初始化流 const initStream async () { if (isInitializing) return; setIsInitializing(true); setError(null); try { // 1. 构建约束条件 const constraints: MediaStreamConstraints { video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 24, min: 15 }, facingMode: { exact: user } }, audio: true }; // 2. 调用 getUserMedia带降级重试 const mediaStream await navigator.mediaDevices.getUserMedia(constraints); // 3. 设置到 video 元素 if (videoRef.current) { videoRef.current.srcObject mediaStream; // 确保自动播放需 muted否则 Safari 静音 videoRef.current.muted true; videoRef.current.play().catch(e { console.warn(Auto-play failed, user interaction needed, e); }); } setStream(mediaStream); } catch (err: any) { console.error(Failed to get media stream, err); setError(getUserMediaErrorMessage(err)); } finally { setIsInitializing(false); } }; // 组件挂载时初始化 useEffect(() { initStream(); // 组件卸载时清理 return () { cleanupStream(); }; }, []); // 页面可见性变化时暂停/恢复 useEffect(() { const handleVisibilityChange () { if (document.hidden stream) { stream.getVideoTracks().forEach(track track.enabled false); } else if (!document.hidden stream) { stream.getVideoTracks().forEach(track track.enabled true); } }; document.addEventListener(visibilitychange, handleVisibilityChange); return () { document.removeEventListener(visibilitychange, handleVisibilityChange); }; }, [stream]); return ( div button onClick{initStream} disabled{isInitializing} {isInitializing ? 正在启动摄像头... : 开启摄像头} /button {error div classNameerror{error}/div} video ref{videoRef} autoPlay playsInline classNamepreview-video / /div ); } // 错误信息映射函数 function getUserMediaErrorMessage(err: any): string { switch (err.name) { case NotAllowedError: return 请允许网站访问您的摄像头和麦克风; case NotFoundError: return 未检测到可用摄像头请检查设备连接; case NotReadableError: return 摄像头被其他程序占用请关闭后再试; case OverconstrainedError: return 设备不支持所选设置已自动降级; default: return 启动失败${err.message}; } }这段代码的关键实操细节video.muted true这是 Safari 的强制要求。iOS Safari 禁止自动播放有声音的视频必须显式设为静音才能触发play()。即使你不需要音频也要设muted否则play()会 Promise reject。playsInline属性iOS Safari 默认全屏播放video加此属性才能内联显示。Vue 或 React 中需用playsInlineReact或playsinlineVue 模板。visibilitychange事件监听这是省电和隐私的双重保障。当用户切到其他标签页或 App 时暂停视频轨道track.enabled false既降低 CPU 占用又避免后台偷偷采集。切回时再启用用户无感知。autoPlay与play().catch()现代浏览器要求autoplay必须配合muted且play()可能因策略变化而失败。catch里不报错是因为用户后续点击视频区域时浏览器会再次尝试播放这是预期行为。4.2 高级技巧获取设备列表、动态切换摄像头、音量监测getUserMedia只是入口真正的灵活性来自配套 API。以下是三个高频需求的实现1. 获取可用设备列表enumerateDevicesasync function listCameras() { try { // 必须先获得一次权限否则返回空列表 await navigator.mediaDevices.getUserMedia({ video: true }); const devices await navigator.mediaDevices.enumerateDevices(); const videoDevices devices.filter(d d.kind videoinput); console.log(可用摄像头, videoDevices); return videoDevices; } catch (err) { console.error(获取设备列表失败, err); } }注意enumerateDevices返回的deviceId是持久化的同一设备每次相同可用于后续getUserMedia中指定deviceId切换摄像头// 切换到后置摄像头 const backCamera videoDevices.find(d d.label.includes(back)); if (backCamera) { const stream await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: backCamera.deviceId } } }); }2. 动态切换摄像头无需重启流有些场景需要不中断预览的情况下切换前后置。MediaStreamTrack的applyConstraints方法可实现async function switchCamera(facingMode: user | environment) { const videoTrack stream?.getVideoTracks()[0]; if (!videoTrack) return; try { await videoTrack.applyConstraints({ facingMode: { exact: facingMode } }); } catch (err) { console.warn(切换摄像头失败尝试重新获取流, err); // 降级方案重新调用 getUserMedia } }3. 实时音量监测AnalyserNode判断用户是否在说话可用于“语音唤醒”或“静音检测”function setupAudioLevelMonitor(stream: MediaStream, onLevelChange: (level: number) void) { const audioContext new (window.AudioContext || (window as any).webkitAudioContext)(); const analyser audioContext.createAnalyser(); analyser.fftSize 256; const source audioContext.createMediaStreamSource(stream); source.connect(analyser); const bufferLength analyser.frequencyBinCount; const dataArray new Uint8Array(bufferLength); function updateLevel() { analyser.getByteFrequencyData(dataArray); // 计算平均音量0-255 const average dataArray.reduce((a, b) a b, 0) / bufferLength; onLevelChange(average); requestAnimationFrame(updateLevel); } updateLevel(); }调用setupAudioLevelMonitor(stream, level console.log(音量:, level));5. 常见问题与排查技巧实录5.1 典型问题速查表与根因分析在多个项目中我们累计记录了 12 类高频问题。以下是其中 6 类最具代表性的问题附带现场排查步骤和根治方案问题现象排查步骤根本原因解决方案Chrome 下黑屏控制台无报错1. 检查video.srcObject是否赋值2. 查看video.readyState是否为0HAVE_NOTHING3. 检查video.videoWidth是否为0srcObject赋值后未调用play()或play()被 Promise reject 静默吞掉在srcObject赋值后立即video.play().catch(console.warn)并在catch中触发用户手动点击播放Safari iOS 黑屏但stream有数据1. 检查video是否有muted属性2. 检查是否在DOMContentLoaded后才赋值srcObject3. 查看video.networkState是否为0NETWORK_EMPTYiOS Safari 要求muted且autoplay必须在用户手势后触发DOMContentLoaded时机过早确保video.muted true并在用户点击按钮后赋值srcObject并调用play()Android Chrome 闪退/白屏1. 检查是否在iframe中调用2. 查看chrome://flags中#unsafely-treat-insecure-origin-as-secure是否开启3. 检查getUserMedia是否在setTimeout中异步调用某些安卓厂商 ROM 对 iframe 内媒体调用有兼容性问题或setTimeout导致手势上下文丢失避免在 iframe 中调用确保getUserMedia在用户点击事件的同步回调中执行勿包裹setTimeout麦克风无声但摄像头正常1. 检查stream.getAudioTracks().length2. 查看系统音量是否为 03. 检查浏览器是否静音地址栏喇叭图标浏览器或系统级静音或audio: false误写为audio: {}在getUserMedia后立即检查stream.getAudioTracks()若为空则重试添加系统音量检测提示画面卡顿CPU 占用 100%1. 检查frameRate约束是否过高2. 查看video.videoWidth是否远超显示区域3. 检查是否在requestAnimationFrame中频繁drawImage高分辨率流在低端设备上解码压力大或 Canvas 绘制未节流将frameRate降至15分辨率设为640x480Canvas 绘制加throttle每 16ms 最多绘制一次getUserMedia调用后无权限弹窗1. 检查是否在 HTTPS 或 localhost2. 查看document.hasFocus()是否为false3. 检查是否在iframe中且allowcamera;microphone未声明非安全上下文、页面未聚焦、或 iframe 权限未声明确保 HTTPS在click事件中调用iframe 添加allowcamera;microphone属性5.2 独家避坑技巧来自三年踩坑总结这些技巧不会出现在任何官方文档里但能帮你少走半年弯路技巧一永远在click事件的同步回调中调用getUserMedia不要写button.addEventListener(click, () setTimeout(getUserMedia, 0))也不要写onClick{() Promise.resolve().then(getUserMedia)}。浏览器严格校验“用户手势”的连续性任何异步延迟都会导致NotAllowedError。正确姿势是// ✅ 正确同步调用 button.addEventListener(click, () { getUserMedia(...); }); // ❌ 错误异步打断手势链 button.addEventListener(click, () { setTimeout(() getUserMedia(...), 0); // 失败 });技巧二移动端首次调用前先getUserMedia({ audio: true })占位iOS Safari 有个隐藏规则首次调用getUserMedia时若同时请求音视频可能因权限弹窗顺序问题导致失败。我们的解法是在用户进入页面时静默调用一次getUserMedia({ audio: true })不显示 UI获取音频权限当用户点击“开启视频”时再调用video: true。这样两次调用都独立成功成功率从 70% 提升至 98%。技巧三用videoWidth/videoHeight替代getSettings()判断实际分辨率track.getSettings()返回的是“请求的约束”而非“实际输出”。例如你请求1280x720但设备只支持640x480getSettings()仍返回1280x720。而video.videoWidth是真实的渲染宽度。因此判断是否降级成功应读取video.videoWidthvideo.onloadeddata () { console.log(实际分辨率, video.videoWidth, x, video.videoHeight); if (video.videoWidth 640) { // 触发降级重试 } };技巧四Safari 的devicechange事件监听要防抖当用户插拔 USB 摄像头时Safari 会频繁触发devicechange事件一秒多次。若每次触发都enumerateDevices会导致性能抖动。必须加防抖let deviceChangeTimer: NodeJS.Timeout; navigator.mediaDevices.addEventListener(devicechange, () { clearTimeout(deviceChangeTimer); deviceChangeTimer setTimeout(async () { const devices await navigator.mediaDevices.enumerateDevices(); updateCameraList(devices); }, 300); });最后分享一个真实案例某次上线后大量 iOS 用户反馈“点击开启摄像头没反应”。我们远程 debug 发现问题出在video元素的className上——CSS 中写了transform: scale(0.99)这个微小缩放竟导致 Safari 15.4 的渲染管线异常videoWidth始终为0。去掉该样式后立即恢复。这提醒我们音视频功能的稳定性不仅取决于 JS 逻辑更依赖于整个渲染链路的健壮性。每一次 CSS 变更、每一次框架升级都值得为getUserMedia场景做专项回归测试。我在实际使用中发现最可靠的测试组合是一台 macOSChrome Safari、一台 WindowsChrome Edge、一台 iPhoneSafari、一台安卓真机Chrome。每天晨会前用这四台设备快速跑一遍“点击→预览→切换→停止”流程比写一百行单元测试更能守住底线。因为getUserMedia的世界永远是现实世界不是代码世界。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑