H5扫码实战:jsQR与html5-qrcode分工及uni-app兼容方案
简介面向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、环境切换、扫码框绘制、连续扫码回调内部默认用的解码器就是 jsQR2.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()拿到摄像头列表。这个方法返回一个 Promiseresolve 一个数组每个元素有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 idqr-reader stylewidth: 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的宽高设置为容器宽度的百分比折算值。比如容器宽度 350pxqrbox宽度想要占 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 端没有现成的扫码 APIuni.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 就能跑起来。希望帮到你。本文还有配套的精品资源点击获取