
简介这是一份面向前端与 uni-app 开发者的 H5 扫码功能实现资源包围绕 jsQR 与 html5-qrcode 两个 JavaScript 库提供从摄像头视频流获取、实时帧解析到二维码结果回传的完整示例也包含静态图片扫码与不同配置选项的演示可迁移到 Vue 组件与跨平台项目中。压缩包共 43 个文件、约 488KB涵盖 Vue 组件、TypeScript/JavaScript 逻辑、JSON 配置、HTML 页面及样式文件目录按扫码库、页面、工具模块划分便于按需提取其中的组件与回调示例。资源作者还整理了在 uni-app H5 环境中引入库文件、处理媒体权限及 HTTPS 必要的注意事项可避开常见的白屏与安全限制。目前已有 3601 人学习适合需要快速在 H5 或 uni-app 中集成扫码能力、并希望理解底层调用细节的开发者。1. 项目定位为什么说 H5 扫码不能只依靠 uni.scanCode如果你搜过“uni-app h5扫码”相关的关键词大概率已经被uni.scanCode在浏览器里的表现坑过一次App 端和小程序端都能弹起扫码界面唯独 H5 端在部分浏览器里要么直接提示不支持要么摄像头压根没反应。这个项目给出的答案是换成一套纯网页方案——用html5-qrcode把摄像头视频流拉进页面再用jsQR对每一帧图像做二维码定位和解码最后把结果传回 uni-app 业务层。整条链路不依赖后端不依赖原生插件只在浏览器标准 API 上跑。这套方案适合两类人一类是正在给 uni-app H5 端补扫码能力、不想被官方 API 兼容性问题卡住的前端开发者另一类是研究二维码识别性能、想弄清楚 canvas 截帧、ImageData、图像增强这些细节的技术爱好者。读完你不仅能把它跑起来还能按自己的需求改成连续扫码、相册选图识别甚至替换成自己的识别算法。2. 技术选型与架构html5-qrcode 管摄像头jsQR 管解析2.1 为什么是两个库而不是一个html5-qrcode本身自带一套解码实现但你打开一些开源项目的源码会发现很多人还是坚持引入jsQR来解码。原因不只是“多一个库更专业”而是两个库的职责边界确实分得很开。html5-qrcode擅长的是采集层封装getUserMedia、枚举摄像头设备、创建并渲染 video 元素、提供facingMode切换、处理权限拒绝的回调。这些逻辑看似简单真正自己写过一遍才知道坑有多少。举个例子getUserMedia的调用时机必须由用户手势触发Android 部分浏览器在页面加载完就调摄像头会导致权限弹窗直接失效iOS Safari 对autoplay策略有严格要求video 不静音会被拦截。html5-qrcode把这些底层兼容都处理掉了省下来的时间足够你多调两天 UI。jsQR则专注解码层输入一个ImageData对象输出decodedText。它对定位图案和校正图案的识别有自己的实现路径在弱光、透视畸变、二维码部分被遮挡这些场景下表现和html5-qrcode内置的解码器并不一样。很多项目选择用它是因为它方便在解码前对图像做额外的预处理——比如灰度化、对比度拉伸、只在扫描框内截取子区域。这两个库分工大概是这样能力维度html5-qrcodejsQR摄像头调度负责不负责video 渲染负责不负责二维码解码内置但可不用只做解码图像预处理接口无由外部控制适合角色采集壳解码引擎提示如果你的需求只是快速演示扫码直接用html5-qrcode的内置解码完全可以引入jsQR的价值在于可控的帧处理和离线图片识别复用这两点在后面改造章节会体现。2.2 整体数据流与 uni-app 中的接入位置从摄像头到业务结果的完整链路是这样的摄像头 →getUserMedia产生视频流 →html5-qrcode渲染到指定 div 下的 video 元素 → 页面里用 canvas 截取当前帧 → 转成ImageData交给jsQR→ 解码成功取出文本 → uni-app 页面通过事件总线或回调把结果传给业务层。这条链路里没有任何后端参与所有计算都在浏览器本地完成。放到 uni-app 工程里看这个扫码页就是一个普通页面挂在pages目录下不需要安装原生插件也不需要配置 manifest 里的原生 SDK。项目里常见的做法是用条件编译区分环境H5 端走这套网页方案App 端继续保持uni.scanCode。两端的业务逻辑可以共用同一套回调采集层各自实现。视频帧的截取频率需要重点控制。常见做法是配合requestAnimationFrame做循环而不是用setInterval固定间隔。后者的执行时间不稳定在低端 Android 机上帧间隔可能出现明显抖动导致二维码刚好在摄像头稳定之前被跳过。requestAnimationFrame会跟随屏幕刷新率配合 video 的readyState判断当前帧是否可用截帧节奏会更稳妥。2.3 为什么不直接用 uni.scanCodeuni.scanCode在 H5 端不是完全不可用而是表现不稳定。它在部分浏览器里只是简单包了一层 BarcodeDetector API而 BarcodeDetector 本身属于实验性能力Chrome 桌面版、iOS Safari 的支持情况参差不齐。更关键的是uni.scanCode不提供扫码 UI 定制能力你没法调整扫描框位置、没法加手电筒按钮、没法在扫码页底部放说明文案也做不到连续扫码——扫一次就得重新进入页面。业务上常见的定制需求比如核销码场景要连续扫十个码、设备巡检要一次性扫完清单上的二维码H5 页面必须完全掌控摄像头预览和识别循环uni.scanCode实现不了。这个项目把扫码拆成采集和解码两层就是为了在 H5 场景里不再受官方 API 的约束。3. 核心模块拆解从摄像头到二维码结果的完整链路3.1 第一步Html5Qrcode 的初始化与摄像头拉起先安装依赖npm install html5-qrcode jsqr初始化并启动摄像头import { Html5Qrcode } from html5-qrcode; this.scanner new Html5Qrcode(qr-reader); this.scanner .start( { facingMode: environment }, { fps: 10, qrbox: 300 }, () {}, // 内置解码结果我们不关心真正解码走 jsQR () {} ) .then(() { // 启动成功后才能拿到 video 元素开始截帧 this.startFrameLoop(); }) .catch((err) { this.handleScanError(err); });start方法接收三个参数。第一个参数是摄像头配置对象facingMode: environment表示优先使用后置摄像头在手机端这是扫码的最优选择user则对应前置摄像头。第二个参数是扫描配置fps控制解码频率一般设置 8 到 10 就足够太高会让低端机发烫qrbox是扫码框的边长像素值这个框只影响内置 UI 的视觉范围不影响我们后续截帧的区域。第三个参数是内置解码的成功回调和错误回调因为我们要用自己的解码逻辑这里传空函数即可。注意start是一个异步操作必须在用户点击事件比如点击“开始扫码”按钮之后调用否则会被浏览器自动拦截报NotAllowedError。启动成功后摄像头预览会出现在传入的qr-reader这个 div 内部html5-qrcode会在其中创建一个 video 元素。3.2 第二步用 jsQR 对视频帧做解码摄像头的 video 元素渲染出来之后接下来就是截帧和解码。先获取 video 元素再通过 canvas 把当前帧绘制出来转成ImageData交给jsQR。startFrameLoop() { const video document.querySelector(#qr-reader video); const canvas document.createElement(canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); let rafId null; const loop () { if (!video || video.readyState ! video.HAVE_ENOUGH_DATA) { rafId requestAnimationFrame(loop); return; } // 控制解码帧的尺寸避免把 1080p 大帧直接丢给 jsQR const targetWidth 480; const scale targetWidth / video.videoWidth; canvas.width targetWidth; canvas.height Math.round(video.videoHeight * scale); // 绘制前先做一次亮度与对比度增强提高弱光识别率 ctx.filter contrast(1.3) brightness(1.1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.filter none; const frame ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(frame.data, frame.width, frame.height); if (result result.data) { this.handleScanResult(result.data); } rafId requestAnimationFrame(loop); }; rafId requestAnimationFrame(loop); }这段代码里有几个关键点。ctx创建时传了willReadFrequently: true这是 Canvas 2D 的一个优化提示告诉浏览器我们接下来会频繁调用getImageData让它内部不要做太多 GPU 缓存避免每次读取都发生性能回退。ctx.filter在drawImage之前设置canvas 绘制时会对图像做一次对比度拉伸contrast(1.3)和brightness(1.1)是经过多轮测试比较可行的参数对比度太低二维码的模块边界不明显太高又容易把图片噪点放大误判成定位图案。尺寸控制也很重要。jsQR的解码复杂度跟像素总量有关如果把摄像头原本的 1920×1080 全帧数据直接传给它每秒只能跑两三帧而且还容易在运动模糊的帧上误判。这里先把宽度缩到 480高度按比例计算解码一帧耗时能降到十几毫秒识别率并不会显著下降。二维码本身是模块化图形只要模块在缩小后的图像里不低于两根像素jsQR基本都能定位到。jsQR的返回值如果为null说明这帧里没找到二维码或者找到了但无法解析。返回对象里的data字段是真正的扫码结果可能是文本、URL、JSON 字符串由业务方自己解析。3.3 第三步完整 vue 页面串起来在 uni-app 的 H5 端页面生命周期和浏览器能力要一起处理。下面是一个精简的扫码页面结构template view classscan-page view classscan-header扫码核销/view div idqr-reader classqr-reader/div view classscan-tip将二维码放入框内自动识别/view /view /template script import { Html5Qrcode } from html5-qrcode; import jsQR from jsqr; export default { data() { return { scanner: null, rafId: null, scanning: false, }; }, onReady() { // 必须在页面渲染完成qr-reader 节点存在后才能初始化 this.scanner new Html5Qrcode(qr-reader); }, onShow() { if (this.scaning) { // 从后台回到前台时需要恢复扫码 this.startScanning(); } }, onHide() { // 切后台时立刻停掉摄像头否则指示灯会一直亮 this.stopScanning(); }, onUnload() { this.destroyScanner(); }, methods: { async startScanning() { try { await this.scanner.start( { facingMode: environment }, { fps: 10, qrbox: 300 }, () {}, () {} ); this.scanning true; this.startFrameLoop(); } catch (err) { uni.showToast({ title: 无法访问摄像头, icon: none }); } }, stopScanning() { if (this.scanning) { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId null; } this.scanner.stop().then(() { this.scanning false; }); } }, async destroyScanner() { await this.stopScanning(); this.scanner.clear(); }, startFrameLoop() { const video document.querySelector(#qr-reader video); const canvas document.createElement(canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); const loop () { if (!video || video.readyState ! video.HAVE_ENOUGH_DATA) { this.rafId requestAnimationFrame(loop); return; } const targetWidth 480; const scale targetWidth / video.videoWidth; canvas.width targetWidth; canvas.height Math.round(video.videoHeight * scale); ctx.filter contrast(1.3) brightness(1.1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.filter none; const frame ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(frame.data, frame.width, frame.height); if (result result.data) { this.handleScanResult(result.data); } this.rafId requestAnimationFrame(loop); }; this.rafId requestAnimationFrame(loop); }, handleScanResult(data) { // 解码成功后先停止扫码再回调业务层 this.stopScanning(); getApp().globalData.qrResult data; uni.navigateBack({ delta: 1 }); }, }, }; /script页面生命周期里有几个容易忽略的点。onReady里只能实例化Html5Qrcode不能立刻start因为用户手势还没发生摄像头权限弹窗无法触发而且page的渲染可能还没完成。onHide必须停掉摄像头否则用户切到后台再回来摄像头状态可能已经失效再次扫码时 video 画面是黑的。stopScanning是异步的必须在cancelAnimationFrame之后调用不然帧循环还在跑scanner.stop()会反复尝试销毁已经释放的媒体流导致报错。4. 避坑与排查真机 H5 扫码反复翻车的五个典型问题4.1 只在 HTTPS 或 localhost 下才能拉起摄像头现象代码在开发者工具里跑得好好的一上传到测试服用手机打开页面点击开始扫描没反应控制台报NotAllowedError或者getUserMedia() not supported。原因浏览器的安全策略规定getUserMedia只在安全上下文里可用。所谓安全上下文就是 HTTPS 页面或者 localhost。测试服如果还是 HTTP 协议摄像头 API 直接被屏蔽连权限弹窗都不会出现。这是一个纯环境问题跟代码逻辑无关。解决把扫码页面部署到 HTTPS 环境或者至少在本地用localhost调试。如果公司内网测试机确实没有 HTTPS 证书可以在 Nginx 层挂一个自签名证书临时用但真机调试时浏览器会警告证书无效需要手动信任体验不太好。4.2 iOS 上只有两个“虚拟摄像头”现象在 Android 上枚举摄像头设备能拿到前摄、后摄等多个 deviceId到了 iOS Safari 上getCameras()返回的列表里只有两个 ID一个叫“用户摄像头”一个叫“环境摄像头”选了后置的 deviceId 启动后画面还是前置。原因iOS 的 WebRTC 实现不对网页暴露底层硬件设备列表只提供用户/环境两个抽象标识。facingMode: environment映射到环境摄像头facingMode: user映射到用户摄像头用 deviceId 去精确匹配在 iOS 上不一定生效。解决启动摄像头时不要自己枚举设备直接传{ facingMode: environment }让浏览器自己决定用哪个物理摄像头。如果必须用 deviceId先判断当前浏览器是否支持getCameras()返回多个为真实的物理设备iOS 上就直接降级用 facingMode。4.3 暗光、小码、快速移动时扫不出来现象光线正常时扫码很快到了灯光较暗的仓库里扫码进度明显变长甚至完全扫不出来。二维码如果贴在金属表面上反光严重时也会频繁失败。原因jsQR的定位逻辑依赖图像中模块之间的亮度梯度。环境太暗时梯度变化弱算法无法在有效时间内找到三个定位图形。视频帧如果同时存在运动模糊模块边界会被拉花进一步降低解码成功率。解决在drawImage前增加ctx.filter contrast(1.3) brightness(1.1)这是成本最低的增强方式。如果效果还不够可以在 canvas 绘制前把视频帧转为灰度再做直方图均衡把亮度范围拉满再交给jsQR。另一个思路是调小targetWidth比如从 480 降到 320这样帧率能提升一倍运动模糊的影响会小很多。4.4 页面销毁后摄像头指示灯还亮着现象扫码页里点击返回页面已经跳走但手机的摄像头指示灯还在亮或者浏览器标签页顶部的“正在使用摄像头”提示没有消失。原因Html5Qrcode.start()成功之后摄像头媒体流是被实例持有的页面路由跳转并不会自动释放。uni-app 页面onUnload里如果没有调用scanner.stop()和scanner.clear()媒体流会一直占用摄像头硬件。解决在onUnload里先cancelAnimationFrame停掉帧循环再调用scanner.stop()确保实例清理完成后再scanner.clear()。这里有个小细节stop()返回 Promise如果页面已经卸载异步回调可能触发警告可以给stop()加.catch(() {})吞掉无用的异常。从那次以后我每次写带摄像头的页面都会先在生命周期里把清理路径写完整再写业务逻辑。4.5 明明解码成功业务回调却没触发现象jsQR返回了非空data但页面上没有弹出结果控制台也没有报错看起来就像扫码成功后被静默吞掉了。原因最常见的是handleScanResult里调用了异步操作然后再执行scanner.stop()两个操作竞争同一个摄像头资源。stop()会销毁 video 元素而异步操作此时可能还在读取 video 的帧数据导致回调被中断。另一个可能性是noResult回调触发的频率太高把成功回调挤掉了。解决确保stop前一帧的解码结果已经保存到业务层再执行停止操作。更稳妥的做法是给扫码加一个“防连续触发”标志位——第一次拿到有效结果后先把this.scanning置为false再执行后续动作handleScanResult(data) { if (!this.scanning) return; this.scanning false; this.stopScanning(); this.processResult(data); }5. 二次开发改造从“扫一次就停”到连续扫码、相册识别5.1 改造一连续扫码与结果节流很多业务场景不需要扫一次就退出。设备巡检、批量核销、一码一刷的库存盘点都需要摄像头保持开启连续识别多个二维码。改造思路很简单解码成功时不调用stopScanning()而是把结果暂存并做节流。let lastText ; let lastTime 0; handleScanResult(data) { const now Date.now(); if (data lastText now - lastTime 3000) { // 同一二维码在三秒内重复识别直接忽略 return; } lastText data; lastTime now; // 这里把结果交给业务层处理 this.emitResult(data); }这套逻辑里节流时间要考虑二维码的实际贴面场景。如果用户需要连续扫五个相同的码节流时间太短会重复触发业务回调太长又会让下一个同码被吞掉。按现场经验3 秒是个比较合理的默认值具体可以根据业务容忍度调整。5.2 改造二从相册选图解码H5 扫码的另一大需求是从相册里选一张包含二维码的图片不解码就出结果。jsQR本身就是图片解码器只需要把图片文件转成ImageData。// input[typefile] 拿到文件后 const reader new FileReader(); reader.onload (e) { const img new Image(); img.onload () { const canvas document.createElement(canvas); canvas.width img.width; canvas.height img.height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0); const frame ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(frame.data, frame.width, frame.height); if (result) { this.emitResult(result.data); } }; img.src e.target.result; };注意图片的原始尺寸可能非常大jsQR对大图解码耗时是成倍增长的。实际改造时最好等比例缩小到宽度 640 再解码识别速度会快很多绝大多数二维码在 640px 宽度下依然能清晰读出。5.3 验证方法三台设备一组用例改完千万不要只在电脑浏览器上测试。一组基本验证用例至少覆盖三台设备一台高端 Android比如最新款旗舰、一台中端 Android千万不能用太新的最好找两年前的型号、一台 iPhone。三条用例分别是正常光线扫标准二维码、暗光环境扫贴墙二维码、相册选择一张包含二维码的照片。走完这三条基本能覆盖八成以上的真实场景。以后只要改了解码参数我每次都强制走一遍这套用例验证完再让测试介入。扫码这事翻车十次有八次是环境因素提前把设备矩阵搭好能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取