Vue3移动端扫码实战:基于vue-qrcode-reader实现摄像头二维码识别

Vue3移动端扫码实战:基于vue-qrcode-reader实现摄像头二维码识别 1. 项目背景与方案选型1.1 为什么偏偏选了 vue-qrcode-reader先说结论在 Vue3 技术栈里做移动端 H5 扫码vue-qrcode-reader 是目前性价比最高的选择没有之一。我之前做扫码功能时也踩过不少坑。早些年用原生的getUserMedia加BarcodeDetectorAPI 自己封装结果折腾了半天发现 Safari 的兼容性惨不忍睹iOS 14.0 之前的版本压根不支持BarcodeDetector安卓机的 WebView 也是五花八门有的支持有的直接白屏。后来换过html5-qrcode功能倒是全但体积偏大在低端安卓机上扫码时那叫一个卡CPU 直接拉满页面掉帧到没法看。直到遇见了 vue-qrcode-reader我才感觉找到了正主。这个库是专门为 Vue 生态设计的对 Vue3 有完整的官方支持底层封装了摄像头调用的复杂逻辑对外暴露的 API 非常简洁。它内部机制是基于zxing-js的扫码引擎做解码这个引擎在二维码识别领域的成熟度极高无论是清晰度要求、畸变校正还是快速响应都比我自己用BarcodeDetector拼接的方案稳定太多。说白了vue-qrcode-reader 解决了一个真实痛点把扫描摄像头权限管理、视频流渲染、二维码识别、兼容性处理这一整套脏活累活封装好了让你能够把精力放在业务逻辑上而不是和浏览器的摄像头的怪癖死磕。1.2 这个方案到底能解决什么问题我在实际项目里高强度用过这套方案之后总结出它最让人舒服的几个点接入成本极低只需要安装依赖引用组件写一个decode事件回调扫码功能就通了。从我打开编辑器到真机扫码成功整个过程确实可以在 5 分钟内完成。移动端适配省心内置了移动端摄像头权限申请、视频流初始化、页面卸载时自动释放摄像头等逻辑。这些逻辑听起来简单但自己写的时候处处是坑比如 iOS 的getUserMedia返回的 stream 不释放会导致摄像头一直亮红灯。识别性能靠谱zxing-js 的解码算法在常规场景下表现优秀即使是稍微模糊的二维码、光线不足的情况识别的成功率也相当高不需要频繁调整镜头位置。支持前置/后置摄像头切换这一点在需要扫一扫和出示二维码两个场景切换的应用里非常实用。你如果只是要在微信内置浏览器、普通安卓 H5 壳、iOS Safari 里做一个扫码入口这个方案完全够用。至于用它来做原生 App 级别的连续扫码、批量扫码那是另一个量级的工程问题我们放到后面的常见问题里聊。2. 环境准备与项目初始化2.1 创建 Vue3 项目这一步比较基础但为了照顾第一次接触 Vue3 的朋友我还是从头走一遍。如果你已经有一个 Vue3 项目直接跳过本节去装依赖就行。我习惯用 Vite 来创建项目相比 webpackVite 的开发服务器启动速度快HMR 也流畅对移动端调试的体验好很多。命令行执行npm create vitelatest qrcode-demo -- --template vue cd qrcode-demo npm install这里用的模板是基础的 Vue3 JavaScript 模板。如果你用的是 TypeScript 技术栈把--template vue换成--template vue-ts就行代码逻辑完全一致只是多了类型声明。启动项目npm run devVite 启动后默认地址是http://localhost:5173本机先跑起来看一眼页面是否正常渲染接下来装扫码库。2.2 安装 vue-qrcode-reader 与版本确认这一步很重要也很容易踩坑。网上有很多旧教程用的是 1.x 或 2.x 的 API但 Vue3 项目必须用 3.x 以上的版本API 有变化。npm install vue-qrcode-reader安装完检查一下版本号npm list vue-qrcode-reader如果不放心直接看package.json里的版本字段只要是^3.0.0或者更高的版本就没问题。本项目基于 Vue3.4 vue-qrcode-reader 3.x 编写如果你用的是 Vue3.2 或之前的版本大概率也兼容但建议能升就升。安装依赖之后我强烈建议你先在浏览器里直接测一下不要把写代码和真机调试混在一起。桌面端 Chrome 就可以调用本地摄像头按下F12打开 DevTools在Sources面板里找到Media可以模拟摄像头输入源这样开发阶段不用拿真机反复验证。2.3 为什么必须用 HTTPS 访问这件事必须在动手写代码之前说清楚在移动端使用摄像头扫码的页面必须运行在 HTTPS 环境下或者等价的安全上下文Secure Context中。这是浏览器的安全策略不是你代码能绕过的限制。具体表现是你在本地开发时用http://localhost:5173是可以打开摄像头的因为 localhost 被浏览器视为安全上下文。但一旦你把页面部署到测试服务器上只要不是 HTTPSgetUserMedia就会被浏览器拒绝摄像头权限请求根本弹不出来页面会一直黑屏或提示权限错误。解决办法也很直接如果是在微信里调试微信开发者工具自带 HTTPS 代理可以绕过这个限制如果是自建测试环境强烈建议直接用vite --host 内网穿透工具或直接部署到一个带 HTTPS 证书的测试域名上如果是本地开发可以给 Vite 配一个自签名证书但移动端访问自签名站点时要手动信任证书略麻烦最省事的方案直接把代码推到测试服务器用现成的 HTTPS 域名访问这个坑我吃过两次亏一次是在客户内网服务器上用 http 部署Android WebView 里怎么都调不起摄像头排查了半天才发现是安全上下文的问题另一次是本地用真机调试手机和电脑连同一个 WiFi但访问的是http://192.168.x.x:5173结果是同样的黑屏。记住这句话移动端摄像头 HTTPS没有例外。3. 代码落地从组件引入到完整实现3.1 最简版本三行代码出扫码效果先把最核心的代码写出来。在你的 Vue3 项目里找到src/App.vue把内容替换成下面这样template div classscanner-page h3扫一扫/h3 QrcodeStream decodeonDecode / /div /template script setup import { QrcodeStream } from vue-qrcode-reader function onDecode(result) { console.log(扫码结果, result) alert(识别成功${result}) } /script style scoped .scanner-page { max-width: 600px; margin: 0 auto; padding: 20px; } /style然后启动项目在浏览器里打开页面允许摄像头权限对准一个二维码你就能在控制台看到识别结果。真的就这么简单。这个最简版本的逻辑是这样的QrcodeStream组件挂载的时候会主动请求摄像头权限拿到视频流之后在内部渲染出一个video元素然后不间断地截取视频帧并识别其中的二维码。识别成功的帧内容会通过decode事件抛出来你的回调函数里就能拿到二维码承载的字符串内容了。3.2 完整版带错误处理、权限拦截和摄像头切换真正拿到项目里用的代码不能这么简陋一个正经的扫码页至少要处理这些情况用户拒绝了摄像头权限浏览器不支持getUserMedia摄像头加载中需要给用户一个 loading 反馈识别过程中需要遮罩和提示文案前置/后置摄像头切换下面是我在项目里实际使用并打磨过的完整版本你可以直接复制过去改改就能用template div classqr-scanner !-- 顶部操作栏 -- div classscanner-header span classtitle二维码扫描/span button classswitch-btn clicktoggleCamera {{ useRearCamera ? 切换前置 : 切换后置 }} /button /div !-- 视频扫描区域 -- div classscanner-body QrcodeStream v-if!cameraError :camerauseRearCamera ? rear : front :pausedpaused decodeonDecode camera-onhandleCameraOn camera-offhandleCameraOff errorhandleCameraError / !-- 加载中状态 -- div v-ifloading classscanner-status span classloading-text摄像头启动中.../span /div !-- 错误状态 -- div v-ifcameraError classscanner-status p classerror-text{{ cameraError }}/p button classretry-btn clickretryCamera重新尝试/button /div !-- 权限被拒绝的提示 -- div v-ifpermissionDenied classscanner-status p classerror-text摄像头权限被拒绝请在浏览器设置中允许访问摄像头。/p /div /div !-- 提示文字 -- p classscanner-tip将二维码放入框内即可自动扫描/p !-- 识别结果展示 -- div v-ifresult classresult-panel h4识别结果/h4 p classresult-content{{ result }}/p button classreset-btn clickresetScanner继续扫描/button /div /div /template script setup import { ref } from vue import { QrcodeStream } from vue-qrcode-reader const loading ref(true) const paused ref(false) const cameraError ref() const permissionDenied ref(false) const useRearCamera ref(true) const result ref() /script说明一下上面的script部分我只写了状态定义后面的处理函数我放到下一节单独讲。因为这段代码的函数逻辑稍微多一点一起贴出来容易看晕。下面把函数补齐。3.3 核心处理函数解码、权限、异常、切换script setup import { ref } from vue import { QrcodeStream } from vue-qrcode-reader const loading ref(true) const paused ref(false) const cameraError ref() const permissionDenied ref(false) const useRearCamera ref(true) const result ref() // 解码成功回调 function onDecode(content) { result.value content paused.value true // 可以在这里接业务逻辑比如把扫码结果回传页面 console.log([qrcode] 识别结果, content) } // 摄像头已开启 function handleCameraOn() { loading.value false console.log([qrcode] 摄像头已开启) } // 摄像头已关闭 function handleCameraOff() { loading.value true console.log([qrcode] 摄像头已关闭) } // 摄像头异常 function handleCameraError(error) { loading.value false console.error([qrcode] 摄像头错误, error) if (error error.name NotAllowedError) { permissionDenied.value true cameraError.value 请在浏览器设置中授权摄像头权限 } else if (error error.name NotFoundError) { cameraError.value 未检测到可用摄像头设备 } else if (error error.name NotReadableError) { cameraError.value 摄像头被其他应用占用请关闭后重试 } else { cameraError.value 摄像头启动失败请重试 } } // 切换前后置摄像头 function toggleCamera() { useRearCamera.value !useRearCamera.value // 切换后重置状态并重新加载 cameraError.value permissionDenied.value false loading.value true } // 重新尝试 function retryCamera() { cameraError.value permissionDenied.value false loading.value true } // 重置继续扫描下一个 function resetScanner() { result.value paused.value false } /script这套代码里我做了几件在真实项目里必须做的事第一识别成功后暂停视频流。如果不暂停QrcodeStream会继续在后台逐帧识别不仅浪费 CPU还会导致同一个二维码被连续触发多次decode弹窗弹到怀疑人生。我用paused变量来控制。第二按错误类型给出分类提示。NotAllowedError表示用户拒绝了权限应该引导去设置里改权限NotFoundError表示设备没有摄像头NotReadableError表示摄像头被别的应用比如微信自带的扫码、视频通话占用了。如果不区分这几种情况用户看到摄像头启动失败这种笼统提示根本不知道怎么处理。第三前后置切换时重置状态。这个必须做因为QrcodeStream在cameraprop 变化时需要重新初始化视频流如果不重置loading状态用户在切换过程中会看到一片黑屏没有加载提示体验很差。3.4 样式部分遮罩、对齐线、响应式扫码页的样式虽然不影响功能但非常影响用户对产品质量的第一印象。一个没有遮罩、没有对齐线的扫码页看上去就像内部测试版本。下面是我的样式方案style scoped .qr-scanner { min-height: 100vh; background: #000; color: #fff; display: flex; flex-direction: column; } .scanner-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; } .scanner-header .title { font-size: 17px; font-weight: 600; } .switch-btn, .retry-btn, .reset-btn { background: rgba(255, 255, 255, 0.2); border: none; color: #fff; padding: 8px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; } .scanner-body { position: relative; flex: 1; overflow: hidden; } .scanner-body video { width: 100%; height: 100%; object-fit: cover; } /* 遮罩层 */ .scanner-body::after { content: ; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 70%; aspect-ratio: 1; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 12px; box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); pointer-events: none; } /* 扫描线动画 */ .scanner-body::before { content: ; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: calc(70% - 4px); height: 3px; background: linear-gradient(90deg, transparent, #4caf50, transparent); animation: scan-line 2s ease-in-out infinite; z-index: 2; pointer-events: none; } keyframes scan-line { 0% { transform: translate(-50%, -80%); } 50% { transform: translate(-50%, 0%); } 100% { transform: translate(-50%, 80%); } } .scanner-status { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.7); z-index: 3; } .loading-text { font-size: 15px; color: #fff; } .error-text { font-size: 15px; color: #ff6b6b; padding: 0 30px; text-align: center; margin-bottom: 16px; } .scanner-tip { text-align: center; font-size: 14px; padding: 16px; color: rgba(255, 255, 255, 0.7); } .result-panel { padding: 20px; background: #1e1e1e; border-top: 1px solid rgba(255, 255, 255, 0.1); } .result-content { word-break: break-all; color: #4caf50; margin: 10px 0; } /style特别注意遮罩层的实现。我用的是box-shadow: 0 0 0 9999px rgba(0,0,0,0.5)这个技巧来制造扫码区域外变暗的效果比额外画四个半透明遮罩块要简洁得多而且兼容性非常好。扫描线的动画用的是 transform 的平移避免直接修改 top 值因为 transform 不会触发重排性能更优。需要说明的是QrcodeStream内部的视频元素默认就带一些样式如果你发现视频画面和容器尺寸不对齐去页面里审查一下 video 元素的样式把object-fit改成 cover 通常就能解决画面拉伸的问题。4. 摄像头调用的底层原理与兼容性深挖4.1 摄像头权限的底层逻辑安全上下文与 Permissions Policy在实际开发中你可能会碰到页面在某些浏览器、某些 WebView 里就是调不起摄像头的情况这通常不是代码问题而是浏览器的权限策略在起作用。浏览器对摄像头权限的控制分为两个层面第一层是安全上下文Secure Context。前面说过只有 HTTPS 或者 localhost 才认为是安全上下文。在非安全上下文下navigator.mediaDevices这个对象都是undefined你调getUserMedia直接报类型错误根本走不到权限询问那一步。第二层是 Permissions Policy之前叫 Feature Policy。这个有点隐蔽。有些站点的 HTML 的head里会有类似这样的标签meta http-equivPermissions-Policy contentcamera(self) /或者是服务端返回的 HTTP 响应头里带了Permissions-Policy配置。如果你是嵌在第三方页面里的 iframe 里做的扫码功能没有显式给 iframe 加allowcamera属性子页面里的摄像头调用也会被父页面的策略拦截掉。这个问题的排查思路是先用 Chrome DevTools 的Application面板查看当前页面的安全上下文状态再在Network面板里看响应头的Permissions-Policy字段一条条排除。在微信内置浏览器里微信会有自己的 JSSDK 权限体系如果你遇到微信里扫码权限弹不出来建议优先检查是否是微信的 JSSDK 没有注入成功具体表现是wx.invoke(scanQRCode)可以调用但自定义摄像头页面黑屏。遇到这种情况别死磕看看业务上能不能直接降级用微信原生的扫一扫能力。4.2 vue-qrcode-reader 到底是怎样调起摄像头的从使用者的角度QrcodeStream把摄像头逻辑全部封装了你感知不到底层发生了什么。但从排查问题的角度了解一下底层机制很有必要。vue-qrcode-reader 的底层核心动作是组件挂载后调用navigator.mediaDevices.getUserMedia({ video: { facingMode: ... } })获取视频流把获取到的MediaStream绑定到内部创建的一个video元素的srcObject上视频元数据加载完成后自动播放视频开启一个定时器或者基于requestAnimationFrame的循环不断地把当前视频帧绘制到一个隐藏的canvas上把 canvas 的图像数据交给 zxing-js 的解码器去解析矩阵识别二维码一旦识别成功触发decode事件把结果字符串抛出来在这个链条里性能瓶颈在第 4 步到第 5 步。视频分辨率越高单帧图像的数据量就越大解码耗时就越长。vue-qrcode-reader 内部做了一些优化比如自动降低采集帧率、缩小 canvas 尺寸等但在低端安卓机上还是会吃力。如果你在真机上测试发现扫码特别慢几乎要定格 2 秒才能识别出来可以考虑手动指定 getUserMedia 的视频约束让摄像头输出更小的分辨率。vue-qrcode-reader 的QrcodeStream支持通过 slot 传递自定义约束这个写法有点 trick直接通过video-constraints属性传入会更方便。用法如下QrcodeStream :constraints{ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 } } } decodeonDecode /把分辨率控制在 720p 而不是默认的 4K识别速度会有质的提升而且画面质量肉眼根本看不出差别。4.3 兼容性横评哪些环境能用哪些环境会翻车桌面端 Chrome / Edge支持 getUserMedia支持 BarcodeDetector新版 Chrome 内置vue-qrcode-reader 正常工作推荐开发阶段用桌面端调试iOS SafariiOS 14.3 支持 BarcodeDetector但支持度一般vue-qrcode-reader 走的是 zxing-js 的纯 JS 解码不依赖 BarcodeDetector所以低版本 iOS 也能用注意iOS Safari 上切换前后置摄像头有时会偶发黑屏建议在切换后用setTimeout延迟调整视频流尺寸微信内置浏览器iOS / Android支持 getUserMedia但部分 Android 机型的 WebView 内核版本老旧可能出现兼容问题微信内置浏览器对摄像头权限的提示语是XXX 想要访问你的相机用户拒绝后没有再次询问入口只能引导去系统设置里改如果业务场景允许优先使用wx.scanQRCode原生的扫码能力体验更稳定常见 WebView 壳Android如果你的 App 是 Android WebView 做的内嵌 H5需要在原生代码里给 WebView 设置setMediaPlaybackRequiresUserGesture之类的权限开关部分 WebView 默认没有开启摄像头权限需要在原生层onPermissionRequest里做授权处理否则 H5 里永远拿不到权限解决思路让原生同事检查 WebChromeClient 的onPermissionRequest回调系统里确认授予PermissionRequest.RESOURCE_VIDEO_CAPTURE小程序 WebView业务方嵌入小程序里的 web-view 组件是受限能力默认不支持摄像头权限调getUserMedia会失败如果你在微信小程序里跳到 web-view 做扫码建议直接放弃这个方案改用小程序原生的wx.scanCodeAPI4.4 性能优化别让扫码页吃光手机内存扫码页如果优化不好在低端安卓机上最容易出现两个问题页面卡顿和内存泄漏。页面卡顿的直接原因是视频帧解码太频繁。vue-qrcode-reader 在识别成功之前是不停解码的这本身是必要开销但我见过一些项目在decode事件里做重活比如直接把整个 result 塞到 Vuex 里触发全量更新导致页面卡死。建议在onDecode里只做轻量操作比如function onDecode(content) { // 立即暂停阻止后续帧继续解码 paused.value true // 用 setTimeout 延迟一下先让 UI 反映暂停状态再处理业务 setTimeout(() { handleBusiness(content) }, 100) }内存泄漏更容易被忽略。QrcodeStream在组件卸载时会自动释放摄像头资源但如果你在扫码过程中跳转路由且页面上有定时器、事件监听、Vuex 订阅没有清理浏览器仍然会持有旧页面的引用导致摄像头的MediaStream无法被垃圾回收。典型表现是从扫码页跳走后手机顶部的摄像头指示灯还亮着。解决方法是在组件的onBeforeUnmount里手动做一次清理兜底import { onBeforeUnmount } from vue onBeforeUnmount(() { paused.value true // 让 QrcodeStream 有机会释放内部资源 })其实我们正常写代码QrcodeStream自己会处理这些的。你只需要避免在它销毁前还在持续往 store 里写数据就行。5. 完整可运行代码工程与实战演示5.1 直接把整个 App.vue 抄走有些读者可能不想要我上面拆分讲解的代码想直接拿到一个能跑的完整文件。没问题这里我把去掉注释后的完整版给出来。你新建一个 Vue3 项目把App.vue全部替换成下面的代码运行起来就是能用的扫码页。template div classqr-scanner div classscanner-header span classtitle二维码扫描/span button classswitch-btn clicktoggleCamera {{ useRearCamera ? 切换前置 : 切换后置 }} /button /div div classscanner-body QrcodeStream v-if!cameraError :camerauseRearCamera ? rear : front :pausedpaused decodeonDecode camera-onhandleCameraOn camera-offhandleCameraOff errorhandleCameraError / div v-ifloading classscanner-status span classloading-text摄像头启动中.../span /div div v-ifcameraError classscanner-status p classerror-text{{ cameraError }}/p button classretry-btn clickretryCamera重新尝试/button /div /div p classscanner-tip将二维码放入框内即可自动扫描/p div v-ifresult classresult-panel h4识别结果/h4 p classresult-content{{ result }}/p button classreset-btn clickresetScanner继续扫描/button /div /div /template script setup import { ref } from vue import { QrcodeStream } from vue-qrcode-reader const loading ref(true) const paused ref(false) const cameraError ref() const useRearCamera ref(true) const result ref() function onDecode(content) { result.value content paused.value true console.log([qrcode] 识别结果, content) } function handleCameraOn() { loading.value false console.log([qrcode] 摄像头已开启) } function handleCameraOff() { loading.value true console.log([qrcode] 摄像头已关闭) } function handleCameraError(error) { loading.value false console.error([qrcode] 摄像头错误, error) if (error error.name NotAllowedError) { cameraError.value 请在浏览器设置中授权摄像头权限 } else if (error error.name NotFoundError) { cameraError.value 未检测到可用摄像头设备 } else if (error error.name NotReadableError) { cameraError.value 摄像头被其他应用占用请关闭后重试 } else { cameraError.value 摄像头启动失败请重试 } } function toggleCamera() { useRearCamera.value !useRearCamera.value cameraError.value loading.value true } function retryCamera() { cameraError.value loading.value true } function resetScanner() { result.value paused.value false } /script style scoped .qr-scanner { min-height: 100vh; background: #000; color: #fff; display: flex; flex-direction: column; } .scanner-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; } .scanner-header .title { font-size: 17px; font-weight: 600; } .switch-btn, .retry-btn, .reset-btn { background: rgba(255, 255, 255, 0.2); border: none; color: #fff; padding: 8px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; } .scanner-body { position: relative; flex: 1; overflow: hidden; } .scanner-body video { width: 100%; height: 100%; object-fit: cover; } .scanner-body::after { content: ; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 70%; aspect-ratio: 1; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 12px; box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); pointer-events: none; } .scanner-body::before { content: ; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: calc(70% - 4px); height: 3px; background: linear-gradient(90deg, transparent, #4caf50, transparent); animation: scan-line 2s ease-in-out infinite; z-index: 2; pointer-events: none; } keyframes scan-line { 0% { transform: translate(-50%, -80%); } 50% { transform: translate(-50%, 0%); } 100% { transform: translate(-50%, 80%); } } .scanner-status { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.7); z-index: 3; } .loading-text { font-size: 15px; color: #fff; } .error-text { font-size: 15px; color: #ff6b6b; padding: 0 30px; text-align: center; margin-bottom: 16px; } .scanner-tip { text-align: center; font-size: 14px; padding: 16px; color: rgba(255, 255, 255, 0.7); } .result-panel { padding: 20px; background: #1e1e1e; border-top: 1px solid rgba(255, 255, 255, 0.1); } .result-content { word-break: break-all; color: #4caf50; margin: 10px 0; } /style5.2 如何快速做真机预览写完之后想在手机上看效果你只需要把项目部署到一个 HTTPS 可访问的环境然后用手机浏览器访问就行。我个人的工作流是先用 Vite 起本地服务然后用一个内网穿透工具把 localhost 代理出来生成一个 HTTPS 的外网地址手机和电脑连同一个网络就能直接打开。这样实时预览改代码之后热更新手机上立刻就能看到效果非常方便而且是标准的 HTTPS 环境摄像头权限不会因为协议问题被拦截。如果你和原生同事配合调试也可以直接用 Android Studio 的 WebView 调试工具配合 Chrome DevTools 的远程调试可以看到 WebView 里的 console 日志和 DOM 结构排查问题效率高很多。5.3 摄像头切换的一个隐藏坑iOS Safari 偶发黑屏我在真机测试时遇到过 iOS Safari 上切换前后置摄像头偶发黑屏的情况画面全黑但切换按钮还能点。排查下来发现是 iOS Safari 对MediaStreamTrack的切换支持不是特别稳定有时候旧的视频流没有彻底关闭就开始初始化新的。解决思路是在切换前先强制暂停和延迟一会儿给浏览器足够的清理时间async function toggleCamera() { // 先强制暂停扫码和解码 paused.value true await nextTick() await new Promise((resolve) setTimeout(resolve, 300)) // 再切换摄像头方向 useRearCamera.value !useRearCamera.value cameraError.value loading.value true // 重新开启解码 paused.value false }nextTick确保 Vue 完成 DOM 更新setTimeout 300ms确保浏览器有空闲时间释放旧资源。这个方案实测下来黑屏概率从经常发生降到了几乎没有。6. 常见问题排查与避坑指南6.1 黑屏、提示权限失败按这个顺序查摄像头问题排查有个通用的顺序你遇到黑屏或者权限提示的时候按下面的步骤走一遍大部分问题都能解决第 1 步确认访问协议是 HTTPS 还是 localhost。这是最常见的原因。打开浏览器开发者工具看地址栏是不是http://开头。如果是去配置 HTTPS 或者使用 localhost 访问。内网穿透、线上测试域名都行。第 2 步确认浏览器是否支持 getUserMedia。在 Console 里执行navigator.mediaDevices navigator.mediaDevices.getUserMedia如果返回undefined说明当前环境不支持或者非安全上下文。反过来如果能打印出函数说明基础环境OK。第 3 步确认摄像头没有被占用。比如你电脑上开着视频会议软件、手机上有其他 App 正占用摄像头浏览器调用就会失败。在桌面浏览器Chrome 会有对应提示摄像头正被其他应用使用。第 4 步确认权限设置里没有拒绝。Chrome 地址栏右侧有个摄像头权限图标点开看权限状态。如果是已阻止手动改回来再刷新页面。第 5 步检查 Permissions Policy。打开 Network 面板看 HTML 响应头里有没有Permissions-Policy: camera()之类的字段。如果有需要去掉或者改配置。第 6 步换一个环境试试。如果桌面端 Chrome 正常、手机 Safari 不正常大概率是环境差异问题。在手机 Chrome 上再试一次排除手机系统权限设置的干扰。6.2 扫码识别率低、识别速度慢怎么办这个问题的核心是解码拿到的图像质量不够好。二维码识别有自己的黄金标准图像清晰、边缘锐利、对比度足够。如果你是扫码识别率低按优先级做这几件事第一让视频约束更保守。把摄像头请求的分辨率从 4K 降到 1080p 甚至 720p减少单帧图像的数据量解码速度会明显提升。视频分辨率不是越高越好超过解码器需要的分辨率纯粹是浪费。第二调整摄像头对焦模式。现在的手机摄像头默认是自动对焦但扫码时如果镜头离二维码太近对焦可能会犹豫不决。有些环境的 H5 方案支持手动设置对焦模式但在网页层面控制对焦比较有限。一个简单技巧是让用户把手机稍微拿远一点保证二维码完整落入画面内。第三确保二维码本身质量好。这听起来像废话但实践中经常遇到。网页生成的二维码如果内容过长导致编码密度过高或者打印时被压缩变形怎么扫都费劲。建议在生成二维码时使用高容错级别如 H 级别这样就算打印出来有点污损依然能识别。第四降低单帧解码频率。vue-qrcode-reader 默认是每一帧都解码。如果你觉得 CPU 占用太高可以自己控制解码频率但我们平常用的QrcodeStream在内部已经做了一些帧率限制实际不需要过度干预稍微注意一下不要在decode回调里做重计算就行。6.3 IOS 16 以下版本的兼容性问题如果你必须支持 iOS 16 以下的版本需要知道一个冷知识iOS 14.3 之前的版本不支持 BarcodeDetector API但 vue-qrcode-reader 用的是 zxing-js 纯 JS 解码所以依然能用。唯一需要注意的是 iOS 15 及以下版本Safari 对getUserMedia在非用户手势触发的场景下有限制。简单说如果你在页面加载后立即自动调起摄像头而用户没有做过任何点击操作iOS Safari 可能会静默失败或者弹权限框的时机不对。解决办法是给页面加一个点击开始扫码的按钮用用户点击手势去触发摄像头的初始化。这个虽然不是 vue-qrcode-reader 要求的但 iOS 浏览器底层有这个限制也是实践总结出来的经验。6.4 构建后体积优化按需引入和懒加载vue-qrcode-reader 这个库带着 zxing-js打包体积大概在 80KB 左右gzip 后约 25KB。放在扫码页面单独用问题不大但如果你的项目是单页应用把这个库打进主包里首页加载就会变慢。解决办法是用 Vue Router 的懒加载让扫码页单独成为一个 chunk只有在用户真正进入扫码页时才加载这个库const router createRouter({ routes: [ { path: /scanner, component: () import(../views/ScannerPage.vue) } ] })这样一来vue-qrcode-reader的代码会被 webpack 或 Vite 自动拆到ScannerPage.vue对应的 chunk 里不会拖累首屏加载。这在移动端尤其重要因为移动端网络环境往往没有桌面端稳定首屏能少吃一点资源是一点。6.5 安卓 WebView 内的权限配置清单如果你的 H5 是嵌入到原生安卓 App 的 WebView 里的麻烦会多一些。原生同事需要在 WebView 初始化时做一些配置否则你前端代码写得再完美也没用。直接把下面这几点发给原生同事实现WebChromeClient.onPermissionRequest并且授予PermissionRequest.RESOURCE_VIDEO_CAPTURE确认 WebView 的Settings里开启了setMediaPlaybackRequiresUserGesture(false)这个配置影响视频是否能自动播放如果扫码页面要用 HTTP 访问摄像头需要额外处理混合内容的问题建议直接用 HTTPS确认 WebView 的User-Agent没有被改成 PC 端标识否则部分设备可能被识别为桌面浏览器导致移动端摄像头策略不生效6.6 常见问题速查表为了方便你快速定位我把上面所有问题整理成了下面的速查表现象排查点解决方案打开页面直接黑屏非 HTTPS 环境配置 HTTPS 或 localhost 访问摄像头权限弹窗没出现WebView 未授权原生配置 onPermissionRequest权限弹窗出现但点授权后没反应摄像头被占用关闭其他用到摄像头的应用扫描识别慢视频分辨率过高传入 constraints 限制 720p识别结果重复触发没有暂停解码decode 后立即设 pausedtrue切换摄像头偶发黑屏iOS Safari 清理不及时setTimeout 延迟 nextTick 后再切换首页加载变慢库打进了主包路由懒加载扫码页iframe 内嵌时摄像头不可用父页面权限策略iframe 加 allowcamera安卓 WebView 一直报权限错误原生 WebView 未授权给原生同事发上面 6 点清单6.7 我在实际项目中踩过的几个坑最后分享一下我个人在这些年的扫码开发里踩过最有代表性的坑。第一个坑是路由复用导致的摄像头不释放。当时做的是一个后台管理系统的扫码插件扫码页是keep-alive缓存的。用户扫完码跳转别的页面再回到扫码页时发现摄像头打不开因为旧的视频流一直被 keep-alive 缓存的组件持有而新的视频流又申请不到资源。后来我把扫码页从keep-alive里排除掉每次进入扫码页都重新走一遍完整的初始化流程问题就解决了。第二个坑是在decode回调里立刻调alert。这个小细节特别坑。iOS Safari 上扫码成功后如果立刻弹alert会导致视频流暂停但回调执行完之后视频流不会自动恢复页面就一直卡在最后一帧看起来像是死机了。所以后来我在扫码成功的逻辑里先paused.value true让组件停止解码再用setTimeout做一个 100 毫秒左右的延迟最后才弹提示框或者跳转这样用户交互就流畅了不会出现卡住的假死现象。第三个坑是参数传反了。这属于低级错误但也值得说一句。vue-qrcode-reader 的camera属性rear是后置摄像头front是前置摄像头。我在一个项目里把初始值传成了front结果用户点进扫码页发现打开的是自拍镜头怎么都对不上二维码。这个在桌面浏览器上没区别因为桌面端一般只有一个摄像头只有在真机上才能暴露出来。所以调试时一定要在真机上测一遍前后置切换。第四个坑是android 端 oppo/vivo 等部分机型 WebView 对 getUserMedia 支持异常。这个属于内置浏览器内核的兼容性差异常规手段很难排查。我当时的兜底方案是如果页面检测到navigator.mediaDevices不存在或者getUserMedia调用报错就提示用户使用系统相机扫码或者引导用户复制链接到系统浏览器打开。这个方案虽然笨但至少能保证用户在绝大多数机型上能完成扫码流程。7. 写在最后的实用建议到这里基于 Vue3 和 vue-qrcode-reader 的移动端扫码方案就完整介绍完了。从我个人的实际体会来看扫码功能属于典型的看起来简单、做起来有坑的前端需求。如果你只在电脑浏览器上调试可能觉得这功能没什么难度但真正放到移动端、放到微信、放到各种定制 WebView 里兼容性问题能让你焦头烂额。所以从一开始就用对工具、处理好权限和生命周期能帮你少走太多弯路。最后再分享一个小技巧如果你只是临时需要扫码能力不要求自定义 UI可以直接把 vue-qrcode-reader 的QrcodeStream当作一个不显眼的隐藏摄像头组件配合浮层样式可以在任何页面上快速集成扫码能力不需要专门做一个扫码页面。这种思路在活动页、营销页上特别实用能大大减少页面跳转带来的割裂感。