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 来创建项目,相比 webpack,Vite 的开发服务器启动速度快,HMR 也流畅,对移动端调试的体验好很多。命令行执行:
npm create vite@latest 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 被浏览器视为安全上下文。但一旦你把页面部署到测试服务器上,只要不是 HTTPS,getUserMedia就会被浏览器拒绝,摄像头权限请求根本弹不出来,页面会一直黑屏或提示权限错误。
解决办法也很直接:
- 如果是在微信里调试,微信开发者工具自带 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 class="scanner-page"> <h3>扫一扫</h3> <QrcodeStream @decode="onDecode" /> </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 class="qr-scanner"> <!-- 顶部操作栏 --> <div class="scanner-header"> <span class="title">二维码扫描</span> <button class="switch-btn" @click="toggleCamera"> {{ useRearCamera ? '切换前置' : '切换后置' }} </button> </div> <!-- 视频扫描区域 --> <div class="scanner-body"> <QrcodeStream v-if="!cameraError" :camera="useRearCamera ? 'rear' : 'front'" :paused="paused" @decode="onDecode" @camera-on="handleCameraOn" @camera-off="handleCameraOff" @error="handleCameraError" /> <!-- 加载中状态 --> <div v-if="loading" class="scanner-status"> <span class="loading-text">摄像头启动中...</span> </div> <!-- 错误状态 --> <div v-if="cameraError" class="scanner-status"> <p class="error-text">{{ cameraError }}</p> <button class="retry-btn" @click="retryCamera">重新尝试</button> </div> <!-- 权限被拒绝的提示 --> <div v-if="permissionDenied" class="scanner-status"> <p class="error-text">摄像头权限被拒绝,请在浏览器设置中允许访问摄像头。</p> </div> </div> <!-- 提示文字 --> <p class="scanner-tip">将二维码放入框内,即可自动扫描</p> <!-- 识别结果展示 --> <div v-if="result" class="result-panel"> <h4>识别结果:</h4> <p class="result-content">{{ result }}</p> <button class="reset-btn" @click="resetScanner">继续扫描</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-equiv="Permissions-Policy" content="camera=(self)" />或者是服务端返回的 HTTP 响应头里带了Permissions-Policy配置。如果你是嵌在第三方页面里的 iframe 里做的扫码功能,没有显式给 iframe 加allow="camera"属性,子页面里的摄像头调用也会被父页面的策略拦截掉。
这个问题的排查思路是:先用 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 } } }" @decode="onDecode" />把分辨率控制在 720p 而不是默认的 4K,识别速度会有质的提升,而且画面质量肉眼根本看不出差别。
4.3 兼容性横评:哪些环境能用,哪些环境会翻车
桌面端 Chrome / Edge
- 支持 getUserMedia,支持 BarcodeDetector(新版 Chrome 内置)
- vue-qrcode-reader 正常工作
- 推荐开发阶段用桌面端调试
iOS Safari
- iOS 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.scanCodeAPI
4.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 class="qr-scanner"> <div class="scanner-header"> <span class="title">二维码扫描</span> <button class="switch-btn" @click="toggleCamera"> {{ useRearCamera ? '切换前置' : '切换后置' }} </button> </div> <div class="scanner-body"> <QrcodeStream v-if="!cameraError" :camera="useRearCamera ? 'rear' : 'front'" :paused="paused" @decode="onDecode" @camera-on="handleCameraOn" @camera-off="handleCameraOff" @error="handleCameraError" /> <div v-if="loading" class="scanner-status"> <span class="loading-text">摄像头启动中...</span> </div> <div v-if="cameraError" class="scanner-status"> <p class="error-text">{{ cameraError }}</p> <button class="retry-btn" @click="retryCamera">重新尝试</button> </div> </div> <p class="scanner-tip">将二维码放入框内,即可自动扫描</p> <div v-if="result" class="result-panel"> <h4>识别结果:</h4> <p class="result-content">{{ result }}</p> <button class="reset-btn" @click="resetScanner">继续扫描</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; } </style>5.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 后立即设 paused=true |
| 切换摄像头偶发黑屏 | iOS Safari 清理不及时 | setTimeout 延迟 + nextTick 后再切换 |
| 首页加载变慢 | 库打进了主包 | 路由懒加载扫码页 |
| iframe 内嵌时摄像头不可用 | 父页面权限策略 | iframe 加 allow="camera" |
| 安卓 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当作一个不显眼的隐藏摄像头组件,配合浮层样式,可以在任何页面上快速集成扫码能力,不需要专门做一个扫码页面。这种思路在活动页、营销页上特别实用,能大大减少页面跳转带来的割裂感。