☰
H5扫码实战:jsQR与html5-qrcode分工及uni-app兼容方案
2026/9/26 17:12:55 网站建设 项目流程

简介:面向Web前端与uni-app开发者的H5扫码功能示例资源包,演示了如何借助jsQR与html5-qrcode两个JavaScript库在浏览器中完成二维码识别,解决移动端用户无需安装原生应用即可扫码的需求。压缩包共43个文件,约488KB,包含13个TypeScript文件、9个JavaScript文件、6个JSON配置、4个Vue组件,以及HTML、SCSS、PNG图片等辅助文件,覆盖uni-app项目结构、扫码页面组件、工具封装与页面配置。目前已有3586人学习下载。通过该资源可掌握摄像头视频流获取、逐帧二维码解析、解码结果回调及错误处理等完整流程,同时理解uni-app集成第三方扫码库时的HTTPS约束与兼容性问题。示例代码含清晰的目录组织与基础配置,适合直接迁移到实际项目中,快速搭建带扫码框和动态效果的H5扫描页面。

1. H5 扫码:别急着接 Camera,先搞清 jsQR 和 html5-qrcode 的分工

H5 页面要扫码,第一反应往往是打开摄像头、抓帧、解析,听着简单,落地全是坑。iOS 的 Safari 对 getUserMedia 的约束、Android 各种机型对视频流的分辨率适配、连续扫码时的性能损耗,任何一个环节翻车,用户拿到的就是一个“白屏或黑屏的扫码页”。我拆过很多这类项目,结论是:不要从零手写,直接用现成方案,但要分清两个库的定位——jsQR 是纯解码器,只负责把图像数据解析成二维码内容;html5-qrcode 是封装好的扫码组件,帮你管摄像头调用、画面渲染和帧提取。两者不是替代关系,而是上下游关系。

这份资源解决的就是 uni-app 的 H5 端扫码需求:从库选型、参数配置,到权限失败、iOS 照片上传、连续扫码防抖,全部覆盖。适合正在做 uni-app H5 扫码、或者在小程序转 H5 过程中被摄像头兼容性卡住的前端工程师。

2. 选型:为什么是 html5-qrcode 加 jsQR,而不是原生 ZXing 移植

2.1 纯前端扫码的两个致命限制

H5 扫码和原生扫码最大的区别在于:没有原生 API 能直接“打开扫码界面”。你拿到的是 getUserMedia 的 MediaStream,需要自己把视频流绘制到 video 或 canvas 上,再定时截取画面做解码。ZXing 是 Java 写的,虽然有人做了 GWT 编译版,但体积大、维护少,在移动端表现不稳定。jsQR 是纯 TypeScript 实现,压缩后大约 100KB 左右,解码精度在同级别纯 JS 库里算第一梯队,而且不依赖 DOM,给什么图像数据都能解。

html5-qrcode 则是站在摄像头这一层,封装了 getUserMedia、环境切换、扫码框绘制、连续扫码回调,内部默认用的解码器就是 jsQR(2.x 以后版本内置了 zxing-js 和 jsQR 两套引擎,可以切换)。所以你不需要纠结“哪个库能扫码”,而是要纠结“哪个封装层更省事”。

2.2 html5-qrcode 的三个核心配置项

html5-qrcode 的 Html5Qrcode 类,最常用的初始化方式是new Html5Qrcode("qr-reader"),其中qr-reader是页面上一个 div 的 id,库会把 video 元素和扫码框动态渲染进去。启动扫码的核心方法是start(cameraId, config, callback),三个参数分别是摄像头 id、配置对象、解码回调。

const html5QrCode = new Html5Qrcode("qr-reader"); html5QrCode.start( { facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0, disableFlip: false, }, (decodedText, decodedResult) => { console.log("解码结果:", decodedText); // 拿到结果后立即停止扫码,避免重复触发 html5QrCode.stop().then(() => { console.log("扫码已停止"); }).catch(err => { console.error("停止失败", err); }); }, (errorMessage) => { // 这里不是致命错误,比如某帧没解出来,不用处理 } ).catch(err => { console.error("启动失败", err); });

facingMode: "environment"指定后置摄像头,这是扫码的默认选择。fps: 10表示每秒最多解码 10 帧,这个值不要调太高,手机端 5 到 10 就够,调高了反而增加 CPU 负担,导致画面卡顿。qrbox定义扫码框大小,注意它是实际视频画面上的像素尺寸,不是 CSS 像素。disableFlip: false允许镜像翻转,某些 Android 机型的前置摄像头画面是反的,这个配置能纠正。

启动失败的错误处理很关键,常见的是NotAllowedError(用户拒绝权限)和NotFoundError(没有可用摄像头)。这两种情况要分别提示用户,前者引导去浏览器设置里打开权限,后者提示当前设备无摄像头。

2.3 摄像头枚举与默认摄像头选择

多摄像头设备(比如部分 Android 手机有前置、后置、广角三个摄像头)上,不能直接写死调用后置,要用Html5Qrcode.getCameras()拿到摄像头列表。这个方法返回一个 Promise,resolve 一个数组,每个元素有id和label。

Html5Qrcode.getCameras().then((cameras) => { if (cameras.length === 0) { alert("未检测到摄像头"); return; } // 优先选标签里带 back / rear 的摄像头 const backCamera = cameras.find(cam => cam.label.toLowerCase().includes("back") || cam.label.toLowerCase().includes("rear") ); const cameraId = backCamera ? backCamera.id : cameras[0].id; console.log("选中摄像头:", cameraId); }).catch(err => { console.error("摄像头枚举失败", err); });

注意cameras[0]不一定就是后置。我遇到过一个华为机型,列表顺序是前置、后置、广角,直接取第一个结果就是前置摄像头。用 label 关键词匹配更靠谱,但 label 在部分浏览器里是空字符串(比如某些 WebView),这时候只能退回到facingMode让浏览器自己选,或者做一个人工切换摄像头的按钮。

3. 实战 html5-qrcode:扫码框、连续扫码与性能调优

3.1 扫码框区域的 UI 控制

html5-qrcode 会把 video 元素插入到你指定的 div 里,但样式上有个常见问题——div 尺寸不等于视频实际渲染尺寸。如果 div 设置了width: 100%,视频画面会按比例缩放,扫码框却按配置的固定像素绘制,导致扫码框错位。我的做法是外层 div 固定宽高,内部扫码框用百分比自适应。

<div id="qr-reader" style="width: 100%; max-width: 400px; margin: 0 auto;"></div>
#qr-reader video { width: 100%; height: auto; object-fit: cover; }

object-fit: cover能保证视频画面填满容器,但会裁剪边缘。如果扫码框正好在画面中间,裁剪对扫码没影响;如果二维码偏大,可能部分区域被裁掉导致解码失败。这种情况优先用object-fit: contain,让画面完整显示,四周留白。两个方案各有取舍,实测中二维码内容密集时cover的失败率明显更高。

3.2 Ui 模式:连续扫码 vs 单次扫码

html5-qrcode 提供了两种渲染模式:Html5QrcodeUiMode.CONTINUOUS_SCAN(连续扫码)和Html5QrcodeUiMode.SINGLE_SCAN(单次扫码)。区别在于,连续模式下扫码框会一直存在,每次解码成功都会触发回调;单次模式下扫码成功一次后自动停止。

实际业务中,绝大多数场景要的是单次扫码——扫一次就停,避免同一二维码被多次解码触发重复提交。但这里有个细节:停止扫码后摄像头会关闭,再次扫码需要重新启动,中间有 1 到 2 秒的延迟。如果你的业务允许,可以保持连续扫码模式,在回调里做防抖,比如 3 秒内只处理一次结果。

let lastScanTime = 0; const SCAN_INTERVAL = 3000; // 3 秒内不重复处理 html5QrCode.start(cameraId, config, (decodedText) => { const now = Date.now(); if (now - lastScanTime < SCAN_INTERVAL) { return; // 防抖,忽略重复扫码 } lastScanTime = now; handleScanResult(decodedText); });

防抖时间建议设置在 2000 到 3000 毫秒之间。太短挡不住快速重复扫码,太长用户扫完一个二维码马上扫下一个时会觉得“卡住了”。

3.3 帧率与解码耗时的平衡

fps配置直接影响解码频率,但这不只是“每秒钟解几次”的问题。解码是一个同步阻塞操作,jsQR 在解析大图像时可能占用 50 到 100 毫秒,这意味着在解码期间主线程是卡住的。如果fps设置过高,视频渲染会被阻塞,用户看到的就是画面一顿一顿的。

我的建议是:fps: 10是一个比较安全的起点,如果扫码成功率低且手机性能好,提到 15;如果页面同时还有其他动画或请求,降到 5。另外,qrbox不要设太大,250x250 在大多数场景下足够,二维码在画面中的占比低于 20% 时解码成功率高。qrbox设太大反而会截断二维码边缘,降低识别率。

3.4 调整扫码框大小后画面错位的排查

现象:修改qrbox尺寸后,扫码框与实际可识别区域明显错位。

原因:html5-qrcode 的qrbox是相对视频原始分辨率的像素值,而页面上的视频经过 CSS 缩放后,扫码框位置会自动换算。但如果外层容器有 padding 或 border,换算会偏移。

解决:外层容器不要设 padding,扫码框直接贴容器边缘;或者把qrbox的宽高设置为容器宽度的百分比折算值。比如容器宽度 350px,qrbox宽度想要占 70%,那qrbox.width就写 245。注意容器宽度是动态的,需要监听 resize 事件重新计算。

4. 降级方案:关闭摄像头直接用 jsQR 解析图片

4.1 为什么需要图片解析

不是所有扫码场景都有摄像头权限。iOS 的微信内置浏览器在部分版本中 getUserMedia 会被拦截,用户看到的是黑屏而不是摄像头画面。这时候需要降级方案:让用户上传二维码图片,前端用 jsQR 解析。另外还有个场景是“相册识别”——用户已经截图了二维码,不想再调摄像头。

4.2 用 FileReader 读取图片并解析

jsQR 的输入不是图片文件本身,而是 ImageData 对象。所以流程是:拿到文件 → 用 createImageBitmap 或 Image 对象加载 → 绘制到 canvas → 从 canvas 读取 ImageData → 交给 jsQR 解析。

async function decodeQRFromImage(file) { // 1. 读取文件为 Data URL const dataUrl = await readFileAsDataURL(file); // 2. 加载图片 const img = new Image(); img.src = dataUrl; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); // 3. 绘制到 canvas 并获取 ImageData const canvas = document.createElement("canvas"); // 限制最大尺寸,避免大图解析耗时过长 const maxSize = 1024; let { width, height } = img; if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width = Math.round(width * scale); height = Math.round(height * scale); } canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0, width, height); // 4. 提取 ImageData 并交给 jsQR const imageData = ctx.getImageData(0, 0, width, height); const code = jsQR(imageData.data, imageData.width, imageData.height); if (code) { return code.data; } return null; } function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); }

这里有两个关键参数。maxSize: 1024是最大图片边长,原图可能是 4000 像素宽的大照片,直接拿原始尺寸解析会非常慢,canvas 操作也卡;缩放到 1024 以内既能保证二维码清晰度,又把单次解析控制在 50 毫秒以下。willReadFrequently: true是 canvas 上下文的一个提示,告诉浏览器这个 canvas 会被频繁调用getImageData,在部分 Chrome 版本里能显著提升读取性能。

4.3 图片旋转导致的解析失败

现象:从相册选择的某些图片,无论怎么调整扫码框都解不出来。

原因:手机相册照片带了 EXIF 方向信息,Image对象加载后会自动应用方向,但canvas.drawImage在部分浏览器里不处理这个方向,导致画到 canvas 上的图片是横的。二维码横过来之后,jsQR 无法识别。

解决:检测 EXIF 方向,旋转 canvas 绘制。

// 读取 EXIF 方向(简化版,只处理最常见的两个方向) async function getExifOrientation(file) { const buffer = await file.arrayBuffer(); const view = new DataView(buffer); // JPEG 的 EXIF 偏移在 0xFFE1 标记后,这里简化处理 // 实际项目中可以引入 exif-js 库 if (view.getUint16(0) !== 0xFFD8) return 1; // 非 JPEG 默认方向 1 // ... 完整的 EXIF 解析逻辑较长,此处省略 return 1; }

我的习惯是:先用 exif-js 库读取方向值,方向不为 1 时,在 drawImage 前旋转 canvas 上下文。旋转后的 canvas 宽高要对调,否则图片会被拉伸变形。

5. uni-app H5 扫码实践:拿这套方案能直接用

5.1 uni-app H5 端的相机权限封装

uni-app 的 H5 端没有现成的扫码 API,uni.scanCode在小程序端可用,但在 H5 端会直接报错。我的做法是用条件编译,在 H5 端走 html5-qrcode,小程序端保留uni.scanCode。这里有个容易踩的坑:H5 端的权限询问时机必须在用户手势事件里触发,否则浏览器会静默拒绝。

// scan.js export function scanQRCode() { // #ifdef H5 return startH5Scan(); // #endif // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.scanCode({ success: (res) => resolve(res.result), fail: reject }); }); // #endif } function startH5Scan() { // 必须在用户点击事件的同步流程里调用,不能加 setTimeout return new Promise((resolve, reject) => { const html5QrCode = new Html5Qrcode("qr-reader"); html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 } }, (decodedText) => { html5QrCode.stop(); resolve(decodedText); }, () => {}).catch(reject); }); }

注意startH5Scan函数必须在用户点击按钮的同步调用栈里执行。如果你在uni.showLoading的回调里再启动,或者先发了一个网络请求再启动,都会导致权限弹出被浏览器拦截。这是 H5 扫码最常见的翻车原因之一。

5.2 iOS 微信内置浏览器的兼容问题

微信内置浏览器的 iOS 版本对 getUserMedia 的支持一直不稳定。iOS 14 之前基本不可用,iOS 14 之后部分版本可用但会先弹出“是否允许网页访问摄像头”。这个弹窗的提示文案是中文的,用户可以理解。但有个边界情况:用户在微信里打开了扫码页,第一次拒绝了权限,后续再点击扫码按钮,不会重新弹出权限询问,而是直接走NotAllowedError。

处理方法:检测到NotAllowedError时,提示用户“请点击右上角三个点,在浏览器中打开后重新授权”,同时提供“从相册选择”的降级入口。这个降级路径很重要,不然用户就死锁在“没权限→扫不了→没权限”的循环里。

5.3 H5 端的二维码被微信识别干扰

现象:扫码页打开后,微信底部会弹出“识别网页中的二维码”的提示条,用户点击后直接跳转到二维码对应的链接,根本没走到自己的扫码流程。

原因:微信内置浏览器会自动检测页面中出现的二维码图片,给出系统级提示。如果页面里恰好渲染了一个二维码(比如测试用的示例码),微信的识别会优先触发。

解决:不要在扫码页面渲染任何二维码图片。需要测试时用摄像头对着另一个屏幕扫,或者用文件上传的降级方案。另外,html5-qrcode 的qr-readerdiv 里的 video 元素会持续渲染视频流,微信不会对视频流里的二维码做识别,这个是安全的。

6. 避坑手册:权限、白屏、重复触发和 JS 报错的现场复盘

6.1 白屏但不报错,摄像头没画面

现象:扫码页面打开了,没有任何报错,但 video 区域是黑色的。

原因:这个不是 html5-qrcode 的问题,而是 video 标签自动播放策略。iOS Safari 要求 video 必须通过用户手势触发 play(),否则视频流不会渲染。html5-qrcode 在start()里会尝试自动播放,但在某些 WebView 里会被拦截。

解决:在调用start()之前,先手动触发一次音频或视频播放的“暖场”。最简单的做法是在页面上放一个“开始扫码”按钮,按钮点击后再初始化 html5-qrcode。实测在微信内置浏览器里这个方案 100% 有效。

6.2 扫码成功后重复提交两次

现象:扫码成功后,后端收到了两次相同的提交记录。

原因:start()的回调里你调用了stop(),但stop()是异步的,在摄像头真正关闭之前,可能还有一帧视频被解码并触发回调。

解决:在回调里设置一个scanning标志位,第一次进入回调后直接拦截后续调用。

let scanning = false; html5QrCode.start(cameraId, config, (decodedText) => { if (scanning) return; scanning = true; // 处理扫码结果 handleResult(decodedText); html5QrCode.stop().finally(() => { scanning = false; }); });

这个scanning标志位同时解决了防抖的问题,比时间戳方案更可靠。注意stop().finally()里要把标志位复位,以便用户再次扫码。

6.3 jsQR 在低版本浏览器上报exports is not defined

现象:使用了构建工具后,jsQR 在 Android 的旧版 WebView 上报错。

原因:jsQR 是 CommonJS 格式的包,构建工具处理时如果 output 配置不对,会在浏览器环境遗漏模块导出。

解决:在 vue.config.js 或 vite.config.js 里把 jsQR 单独配置为不打进主 bundle,用 CDN 引入。

// vite.config.js export default { build: { rollupOptions: { external: ['jsqr'], output: { globals: { jsqr: 'jsQR' } } } } }

6.4 安卓摄像头画面竖屏拉伸

现象:Android 手机竖屏使用时,视频画面被横向拉伸,人物变形。

原因:部分 Android 机型的前置摄像头视频流原始分辨率是横屏的(宽度大于高度),页面没有换算宽高比。

解决:监听视频的loadedmetadata事件,拿到视频原始分辨率,用 CSS 强制宽高比。

const video = document.querySelector('#qr-reader video'); video.addEventListener('loadedmetadata', () => { const { videoWidth, videoHeight } = video; video.style.width = '100%'; video.style.height = `${(videoHeight / videoWidth) * 100}%`; });

6.5 页面关闭后摄像头灯还亮着

现象:离开扫码页后,摄像头指示灯仍亮,麦克风权限提示也被占用。

原因:html5-qrcode 没有在页面卸载时自动停止摄像头。

解决:在onHide或beforeDestroy生命周期里强制stop()。如果已经调用了stop()但摄像头没关,可以再降级用navigator.mediaDevices.getUserMedia的 track 手动 stop。

// uni-app 页面 onHide() { if (this.html5QrCode) { this.html5QrCode.stop().catch(() => {}); } }

7. 收尾技巧:把扫码封装成 Promise 工具函数的最终形态

经过前面的拆解,我通常会把这套逻辑封装成一个独立的扫码工具模块,页面里只需要一行调用。核心思路是:Promise 管理异步流程,内部处理摄像头枚举、防抖、权限降级、生命周期清理。封装完成后,页面代码干净很多。

// qr-scan-tool.js import { Html5Qrcode } from 'html5-qrcode'; import jsQR from 'jsqr'; let currentScanner = null; export function scanQRCode(options = {}) { const { containerId = 'qr-reader', timeout = 30000 } = options; return new Promise((resolve, reject) => { // 如果已有实例,先清理 if (currentScanner) { currentScanner.stop().catch(() => {}); currentScanner = null; } const scanner = new Html5Qrcode(containerId); currentScanner = scanner; // 超时保护 const timer = setTimeout(() => { scanner.stop().catch(() => {}); reject(new Error('扫码超时')); }, timeout); // 检查容器是否存在 if (!document.getElementById(containerId)) { clearTimeout(timer); reject(new Error('扫码容器不存在')); return; } scanner.start({ facingMode: 'environment' }, { fps: 10, qrbox: { width: 250, height: 250 } }, (decodedText) => { clearTimeout(timer); scanner.stop().catch(() => {}); resolve(decodedText); }, () => {}).catch((err) => { clearTimeout(timer); reject(err); }); }); }

这个封装有几个值得注意的参数。timeout: 30000是超时保护,30 秒内没扫到就自动取消,避免用户长时间停留导致摄像头一直占用。currentScanner全局引用是为了防止用户反复进入页面产生多个摄像头实例,这在 WeChat WebView 里会导致摄像头资源泄漏。每次调用前先停掉上一个实例,是血泪换来的教训。

从那以后我每次封装扫码功能,都强制走一遍这套流程:先列权限场景(拒绝、未安装摄像头、WebView 不支持)、再列出单次扫码和连续扫码的业务差异、最后检查页面生命周期里有没有漏掉stop()。这个习惯帮我避开了大多数线上事故。项目里用到的 jsQR 和 html5-qrcode 的具体版本、完整示例代码,都在下载包里,拿过去改一下容器 ID 就能跑起来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询