news 2026/9/20 20:39:47

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3移动端扫码实战:基于vue-qrcode-reader实现摄像头二维码识别

1. 项目背景与方案选型

1.1 为什么偏偏选了 vue-qrcode-reader

先说结论:在 Vue3 技术栈里做移动端 H5 扫码,vue-qrcode-reader 是目前性价比最高的选择,没有之一。

我之前做扫码功能时也踩过不少坑。早些年用原生的getUserMediaBarcodeDetectorAPI 自己封装,结果折腾了半天发现 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 dev

Vite 启动后默认地址是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表示摄像头被别的应用(比如微信自带的扫码、视频通话)占用了。如果不区分这几种情况,用户看到"摄像头启动失败"这种笼统提示根本不知道怎么处理。

第三,前后置切换时重置状态。这个必须做,因为QrcodeStreamcameraprop 变化时,需要重新初始化视频流,如果不重置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 的底层核心动作是:

  1. 组件挂载后,调用navigator.mediaDevices.getUserMedia({ video: { facingMode: ... } })获取视频流
  2. 把获取到的MediaStream绑定到内部创建的一个<video>元素的srcObject
  3. 视频元数据加载完成后,自动播放视频
  4. 开启一个定时器或者基于requestAnimationFrame的循环,不断地把当前视频帧绘制到一个隐藏的canvas
  5. 把 canvas 的图像数据交给 zxing-js 的解码器去解析矩阵,识别二维码
  6. 一旦识别成功,触发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当作一个不显眼的隐藏摄像头组件,配合浮层样式,可以在任何页面上快速集成扫码能力,不需要专门做一个扫码页面。这种思路在活动页、营销页上特别实用,能大大减少页面跳转带来的割裂感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 20:39:32

Notepad-- 完整指南:3 分钟快速装好免费跨平台文本编辑器

Notepad-- 完整指南&#xff1a;3 分钟快速装好免费跨平台文本编辑器 【免费下载链接】notepad-- 一个支持windows/linux/mac的文本编辑器&#xff0c;目标是做中国人自己的编辑器&#xff0c;来自中国。 项目地址: https://gitcode.com/GitHub_Trending/no/notepad-- 从…

作者头像 李华
网站建设 2026/9/20 20:39:30

医院数据信息爬虫实战:从数据源到字段清洗的完整方案

简介&#xff1a;一份覆盖全国医院等级、擅长病症、地址、邮箱、电话及官网链接的爬虫程序资源&#xff0c;服务医疗数据分析、公共卫生规划与机构运营等场景&#xff0c;适合有一定Python基础、需要批量采集医院结构化信息的开发者。资源包共3个文件&#xff0c;包含一个.py爬…

作者头像 李华
网站建设 2026/9/20 20:38:02

基于MATLAB的六足机器人步态仿真:三角步态与波动步态实现详解

六足机器人这个坑&#xff0c;我是从一台旧笔记本加一份MATLAB授权开始的。当时手上没有舵机、没有结构件、连一根杜邦线都没有&#xff0c;却特别想弄清楚一个看起来很简单的问题&#xff1a;六条腿到底按什么顺序抬起来&#xff0c;才能走得又稳又快&#xff1f;这个问题的答…

作者头像 李华
网站建设 2026/9/20 20:38:00

Simulink信号与系统仿真课程设计全攻略:建模、滤波与踩坑实录

简介&#xff1a;这是一份基于Matlab Simulink的信号与线性系统仿真课程设计文档&#xff0c;适合电子信息、通信工程等专业学生完成信号与系统相关课设或复习仿真方法。文档以完整课程设计报告形式呈现&#xff0c;涵盖快速傅里叶变换&#xff08;FFT&#xff09;、有限冲击响…

作者头像 李华
网站建设 2026/9/20 20:37:49

AI编程工具横向评测:Claude Code、Codex CLI、OpenClaw、Hermes Agent选型指南

市面上 AI 编程工具这两年冒出来一大堆&#xff0c;名字一个比一个唬人&#xff0c;但真正落到日常写代码、改 bug、跑脚本这些事上&#xff0c;能长期留在工具栏里的其实就那么几个。OpenClaw、Hermes Agent、Claude Code、Codex CLI 这四个是最近被问得最多的&#xff0c;问的…

作者头像 李华