H5扫码实战:jsQR与html5-qrcode选型与避坑指南
简介面向H5页面与uni-app开发者的扫码功能实现包整合jsQR与html5-qrcode两种解码方案帮助前端工程师在浏览器中快速完成摄像头扫码、图片识别与跨端集成。压缩包共43个文件以JavaScript脚本、TypeScript类型定义、Vue页面组件及JSON工程配置为主另有HTML示例页、PNG测试图片和说明文档整体仅488KB目录结构清晰便于按模块取用。目前已有3586人学习下载适合需要快速落地扫码能力的Web与跨平台项目。资源内含可直接运行的uni-app项目骨架包含manifest.json、pages.json、App.vue等工程文件以及两种解码库的调用示例、常用工具函数和页面逻辑配套的静态二维码图片可用来验证解析结果。通过对照该包可系统掌握视频流获取、逐帧图像抽取、二维码定位识别、错误容错及HTTPS环境适配等完整流程同时也可作为在现有工程中引入H5扫码功能的参考起点。1. h5 扫码不是吊起摄像头就完事jsQR 与 html5-qrcode 到底解决什么问题在 H5 里做扫码最容易遇到的黑匣子就是“摄像头已经亮了二维码也怼在镜头前了但结果一直是 undefined”。很多人以为 h5 扫码就是打开摄像头其实摄像头只负责取景二维码解析是另一套算法。h5 扫码这个需求要拆成“拿视频流”和“从图像帧里解码”两件事。jsQR 是一个纯前端二维码解码库给它一张图像的像素数组它返回二维码内容html5-qrcode 则把摄像头调用、扫码框、连续识别这些环节打成包底层识别用的同样是 jsQR。两者的关系类似发动机和整车。这篇文章按落地顺序讲先讲 jsQR 怎么搭一个最小扫码器再讲 html5-qrcode 怎么少写代码最后把微信、企业微信、App 内嵌 WebView 里的高频坑说清楚。适合正在做 H5 扫码、不想被手机型号和容器兼容性反复折磨的开发者。2. 自己写一个最小 h5 扫码器jsQR 的摄像头、画布与解码循环2.1 getUserMedia 打开摄像头分辨率、环境与前置摄像头的选法在写 jsQR 之前得先把摄像头打开。浏览器侧负责这件事的是navigator.mediaDevices.getUserMedia它只在安全上下文里可用线上必须 HTTPS本地可以把 localhost 当成例外。我习惯优先让系统挑后置摄像头因为扫码场景里用户多数是去扫别人出示的二维码前置摄像头拍出来是镜像二维码方向一错识别率就下来了。async function openCamera(videoEl) { const stream await navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: { ideal: environment }, width: { ideal: 1280 }, height: { ideal: 720 } } }); videoEl.srcObject stream; await videoEl.play(); return stream; }facingMode: { ideal: environment }是让浏览器优先选择后置摄像头。ideal表期望不强制设备没有后置时也会退回前置。改成{ exact: environment }会强制抛OverconstrainedError我一般不用 exact。width/height同样给 ideal720p 而不是 1080p 是因为解码耗时会随像素量上涨二维码识别并不需要 4K 画面分辨率太高反而容易掉帧。另一个容易被忽略的点videoEl.play()在部分 Android WebView 里可能一直 pending。如果视频画面出不来先检查 video 有没有playsinline属性这个坑在第 4 章会再展开。每次扫码结束后记得把摄像头停掉否则指示灯常亮用户会很慌。停止的方式是遍历轨道调用stop()function stopCamera(stream) { stream.getTracks().forEach(track track.stop()); }这个函数虽然简单但真正会写的人不多。很多扫码页从 A 页面跳到 B 页面后摄像头一直开着再回来时浏览器报NotReadableError就是因为上一个页面的流没有被释放。2.2 把视频帧画到 canvas为什么必须用 canvas 而不是直接截图jsQR 不认视频元素它认的是ImageData对象也就是一帧画面展开后的 RGBA 像素数组。所以要想让 jsQR 工作必须把视频当前帧画到一个 canvas 上再getImageData取像素。有人会偷懒用video.toDataURL()先转 base64再走一遍图片解析这在单次截图上没问题但做成每秒 10 帧的连续扫码性能和内存都扛不住。const scanCanvas document.createElement(canvas); const scanCtx scanCanvas.getContext(2d, { willReadFrequently: true }); function captureFrame(videoEl) { const vw videoEl.videoWidth; const vh videoEl.videoHeight; scanCanvas.width vw; scanCanvas.height vh; scanCtx.drawImage(videoEl, 0, 0, vw, vh); return scanCtx.getImageData(0, 0, vw, vh); }drawImage(videoEl, 0, 0, vw, vh)会把 video 当前帧完整画到 canvas。这里有个关键参数videoWidth和videoHeight是视频源的真实分辨率不是 video 元素的 CSS 尺寸。如果元素设置了width100%的样式video.videoWidth仍旧是原始分辨率。如果 canvas 尺寸小于视频尺寸drawImage 会自动缩放这个缩放是免费的等于帮 jsQR 降采样。我一般会把 canvas 宽度压到视频宽度的一半比如源 1280 就画到 640解码耗时会明显下降。scanCtx.getContext(2d, { willReadFrequently: true })里的willReadFrequently值得单独说它告诉浏览器这个上下文会被高频调用getImageData浏览器会为它选择更适合回读像素的存储方式。不加这个参数在某些浏览器上连续取像素会慢得明显。还有一个边界是 canvas 的宽高比不能和视频不一致否则二维码会被拉伸变形jsQR 的定位图形检测会失败。如果扫码框不是满屏只截取框内区域那就在 drawImage 的第 2 到第 5 个参数里设定源区域这样还能顺便减少背景干扰我最后一章会再讲。2.3 调用 jsQR 解码返回结果、失败判断与两个关键参数图像数据准备好后就可以交给 jsQR。函数签名是jsQR(imageData, width, height, options)。第一个参数必须是Uint8ClampedArray或直接传ImageData.datawidth 和 height 要和 imageData 的宽高一致填错会出现解码定位错误或者直接返回 null。import jsQR from jsqr; function decodeFrame(imageData) { const result jsQR( imageData.data, imageData.width, imageData.height, { inversionAttempts: attemptBoth, greyScale: false } ); return result ? result.data : null; }识别成功返回result.data内容可能是 URL、JSON 或普通文本识别失败返回null不是抛异常所以不用 try/catch 包这一层。options 里我调的最多是inversionAttempts。扫码时常见两种反向画面一种是黑底白字的二维码一种是屏幕或镜头反光导致明暗反转attemptBoth会让 jsQR 尝试正常和反色两种识别路径成功率更高但耗时也更高。如果你明确只扫自己生成的白底黑码比如内部系统的固定码可以设成dontInvert解码速度会有可感知的提升。greyScale默认是 false我保持默认。有的教程会建议先手动灰度再传给 jsQR实际上 jsQR 内部会做自适应二值化提前转灰度可能丢失二维码边缘的对比度。2.4 连续扫码的 requestAnimationFrame 循环与节流别让手机变成暖手宝实时扫码是一个持续动作摄像头每秒送来 30 帧但不该每帧都去调 jsQR。我之前为了追求“秒识别”把 fps 调高结果是识别没快多少手机先烫了iOS 上还出现热降频识别反而更慢。现在的做法是用 requestAnimationFrame 保持视频预览的流畅但用一个时间戳门槛控制实际解码频率。let scanning true; let lastDecodeTime 0; let activeStream null; function loop(timestamp) { if (!scanning) return; if (timestamp - lastDecodeTime 200) { const imageData captureFrame(videoEl); const text decodeFrame(imageData); if (text) { scanning false; handleSuccess(text); stopCamera(activeStream); return; } lastDecodeTime timestamp; } requestAnimationFrame(loop); } requestAnimationFrame(loop);timestamp - lastDecodeTime 200就是节流每 200ms 解码一次对应 5fps 的解码频率。200ms 是我在多数中端 Android 上的折中值。如果页面里只有扫码一个任务可以缩到 150ms如果扫码的同时还要上传轨迹、渲染列表放到 300ms 更安全。scanning false放在成功回调之前是为了防止用户按住手机时同一个二维码被连续识别多次。真实业务里扫码成功跳转前最好也加一个 500ms 的结果防抖第一次弹窗用户没点第二次又识别到同一个码容易重复提交。到这里最小扫码器已经能用了打开摄像头、取帧、解码、循环、停止。剩下的大多数问题不是代码逻辑而是手机摄像头和环境光这部分的坑我在第 4 章集中写。3. 用 html5-qrcode 少写一半代码npm 依赖、扫码区配置与 Vue 生命周期3.1 html5-qrcode 和 jsQR 的分工库内部还是用 jsQR 解码如果你不想自己管理摄像头、canvas、requestAnimationFrame 和节流html5-qrcode 是更务实的方案。它是一个 npm 包内置了摄像头选择、扫码框、失败提示、文件识别这些完整逻辑。很多人以为 html5-qrcode 和 jsQR 是并列的两种选择其实不是html5-qrcode 的二维码识别核心用的还是 jsQR它把摄像头权限、视频流、帧提取和解码循环都封装好了。换句话说先用 jsQR 自己写一遍是为了理解它的边界用 html5-qrcode 是为了让代码量和维护成本下来。安装方式就是常规的 npm 依赖npm install html5-qrcode这个包自带 TypeScript 类型不需要额外安装 types 包。引入路径上注意浏览器环境直接使用Html5Qrcode和Html5QrcodeScanner两个类避免手滑写成import html5Qrcode from html5-qrcode默认导入不是类。3.2 最小调用Html5QrcodeScanner 与 Html5Qrcode 两种模式这个库提供两套 API名字容易混Html5QrcodeScanner自带一套扫码 UI包括摄像头选择下拉框、扫码区域和“更换摄像头”按钮适合快速上线Html5Qrcode是底层类不带 UI适合要自己画扫码框、做个性化界面的情况。我大部分业务场景用的是 Scanner因为扫码页的设计通常由 UI 同学先行调用代码最少。import { Html5QrcodeScanner, Html5QrcodeSupportedFormats } from html5-qrcode; const scanner new Html5QrcodeScanner( qr-reader, { fps: 10, qrbox: { width: 250, height: 250 } } ); scanner.render( (decodedText) { console.log(识别到, decodedText); scanner.clear(); }, (error) { // jsQR 每帧解码失败都会走这里别把错误吐给用户 } );这里的fps不是摄像头本身帧率而是 html5-qrcode 每隔多久从视频流里抽一帧去解码10 对应每 100ms 一帧。qrbox是扫码区域宽高给的是实际像素不是比例。render的第一个参数是成功回调第二个是每帧失败回调。注意jsQR 识别失败非常频繁尤其当前几帧画面里没有二维码时失败回调可能每秒执行几十次千万别在里面做弹窗、上报或者 toast。Html5Qrcode的用法更接近自己管理摄像头const html5QrCode new Html5Qrcode(qr-reader); await html5QrCode.start( { facingMode: environment }, { fps: 10, qrbox: 250 }, (decodedText) console.log(decodedText) ); // 页面离开时一定要 stop否则摄像头不会关闭 await html5QrCode.stop();start的第一参数可以传摄像头 id 字符串也可以传{ facingMode: environment }这种约束。它返回 Promise摄像头权限被拒时会 reject记得用 try/catch 包住不然页面上会留下一个未处理的 Promise 错误。3.3 扫码框、二维码格式白名单、每秒帧数的参数设置scanner 的配置项比想象中多但真正值得调的也就几个。formatsToSupport用来限定只识别二维码避免相机被一维码带偏rememberLastUsedCamera可以让用户第二次进入时继续用上次选的摄像头showTorchButtonIfSupported是手电筒开关适合夜间扫屏幕码的场景。const config { fps: 10, qrbox: { width: 240, height: 240 }, aspectRatio: 1.0, formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE], rememberLastUsedCamera: true, showTorchButtonIfSupported: true };几个常见参数的作用整理如下参数常见值说明fps10每秒解码帧数不是摄像头帧率qrbox{ width: 240, height: 240 }扫码识别区域像素单位aspectRatio1.0视频画面宽高比避免二维码变形formatsToSupport[QR_CODE]只识别二维码不去追一维码rememberLastUsedCameratrue下次打开记住上次摄像头showTorchButtonIfSupportedtrue手电筒开关仅支持的设备出现aspectRatio我建议设成 1.0 或者和扫码框接近的比例这样视频画面不会因为拉伸导致二维码变形。qrbox是识别区域的像素尺寸不是整个视频的尺寸它越小解码时越省 CPU但也要求用户把二维码放到框内。实际扫码场景里用户往往习惯离远一点扫框太小时识别失败率会上升我的经验是宽度至少占屏幕宽度的 70%。3.4 在 Vue 组件里封装start/stop 与 beforeDestroy 的生命周期管理html5-qrcode 在 Vue 项目里最常见的坑是重复初始化。Scanner 一旦渲染到某个 DOM 节点就不可再对同一个节点调用第二次 render否则会报错。原因在于路由切换时组件销毁但摄像头和扫描循环没跟着停。解决方法是把 scanner 实例存在组件作用域里在路由组件卸载时主动 clear 或 stop。script setup import { onMounted, onBeforeUnmount } from vue; import { Html5QrcodeScanner } from html5-qrcode; let scanner null; onMounted(() { scanner new Html5QrcodeScanner(qr-reader, { fps: 10, qrbox: { width: 250, height: 250 } }); scanner.render(onScanSuccess, onScanFailure); }); onBeforeUnmount(() { if (scanner) { scanner.clear(); scanner null; } }); function onScanSuccess(text) { // 跳转前避免连续回调多次 scanner.clear(); console.log(text); } function onScanFailure() {} /scriptclear()和stop()有一点区别Scanner 实例用clear()Html5Qrcode 实例用stop()。如果用的是 Vue 2 的 Options API就把清理逻辑放到beforeDestroy里Vue 3 用onBeforeUnmount或onUnmounted。还有一个容易漏的scanner.clear()之后同一组件如果再次进入 mounted需要重新 new Scanner不能复用旧实例。4. h5 扫码翻车避坑指南权限、对焦、双镜头与误码率排查4.1 权限被拒或 getUserMedia 不存在先检查是不是 HTTPS 和 iframe 权限现象navigator.mediaDevices是 undefined或者getUserMedia一直返回NotAllowedError还有一种是在微信里点扫码摄像头权限弹窗一闪而过页面拿到 no permission。原因浏览器摄像头 API 只在安全上下文里开放http 页面访问非 localhost 域名时直接禁用iframe 嵌套时还需要在 iframe 标签上声明 allow 属性部分 Android WebView 如果不实现onPermissionRequestH5 拿不到授权。解决线上页面强制 HTTPS调试用 localhost如果业务要嵌到别的平台让客户端开发在 WebView 配置里允许摄像头权限iframe 场景在标签上加allowcamera。我一般会在 getUserMedia 之前先做环境判断if (!navigator.mediaDevices?.getUserMedia) { showError(当前浏览器不支持摄像头请使用 HTTPS 或最新版浏览器); }不要等到catch里才提示undefined 的情况根本没有 Promise 可 reject。4.2 二维码怼在镜头前也扫不出来光线、反光和对焦是三大玄学现象摄像头能看到画面二维码也清晰但 jsQR 持续返回 null尤其是拿手机去扫另一个手机屏幕屏幕上出现彩色条纹然后识别失败。原因手机屏幕刷新和摄像头传感器之间存在频闪环境光过强或过暗都会让二维码二值化失败摄像头对焦没有锁定二维码边缘发虚。解决把扫码框放在屏幕中央提示用户距离控制在 10-30cm夜间开启手电筒或者引导用户调节环境亮度如果场景是扫屏幕码可以在页面上增加一个“切换摄像头”或“打开手电筒”按钮。html5-qrcode 的showTorchButtonIfSupported只对支持的设备有效不要指望所有 Android 都有。自己写 jsQR 循环时如果连续识别失败超过几秒就在 UI 上给“光线不足或距离太近”的提示不要盲目调大 fps帧率再高也解决不了对焦问题。4.3 iOS 和安卓 WebView 表现不一致有些设备 getUserMedia 一直 pending现象同一个 H5 页面Android Chrome 扫码正常iOS Safari 也正常但嵌入 App 的 WKWebView 后摄像头打开了画面却卡住或者一直黑屏部分 iOS 上点击开始扫码后页面直接全屏播放视频。原因iOS WebView 对媒体播放有额外要求页面里的 video 要加playsinline属性不然调用play()时会进入全屏播放WKWebView 还需要在 App 的 Info.plist 里声明NSCameraUsageDescription。另外getUserMedia在 WebView 里如果不在用户手势触发下调用授权回调可能一直不回来。解决给 video 元素加playsinline和muted确认 App 的 WKWebView 配置允许媒体权限扫码入口按钮的点击事件里再调用getUserMedia不要在页面加载时就自动打开摄像头。如果 App 是别人家做的H5 无法控制客户端那就退而求其次在页面上加一个“调起系统扫码”的降级方案走微信 JSSDK 或原生 bridge。4.4 误码率高、识别到错误内容inversionAttempts、灰度、扫码区域调优现象识别结果偶发不对明明扫的是 A 二维码返回的却是上一帧 B 二维码的内容或者同一个二维码时灵时不灵。原因jsQR 每帧都从当前画面解一次码用户移动手机时画面里同时出现两个二维码jsQR 不保证返回哪个如果解码循环没有加冷却识别成功后没有立刻停下一帧可能又触发一次回调。另外inversionAttempts开成attemptBoth后反色尝试遇到低对比度画面时可能把噪声误识别成码。解决识别成功回调里先置scanning false再处理业务给结果加一个最短响应间隔比如 500ms 内的重复结果直接忽略如果业务允许只识别框内区域减小背景干扰。用 jsQR 时可以在 drawImage 时只截取扫码框范围的图像而不是全画面这样误码率会明显下降。还有一种做法是把inversionAttempts设成dontInvert前提是你确定二维码一定是白底黑码比如自己生成的码识别速度会快不少。5. 把扫码页装进微信/企业微信/App 内嵌 H5容器适配与结果处理5.1 微信/企业微信内打开扫码页JSSDK 的扫一扫和 WebRTC 如何选如果页面只在微信内使用还有一个更省事的方案微信 JSSDK 的wx.scanQRCode它直接调起微信原生扫码识别速度和成功率都比网页摄像头好。但它的问题是只能在微信内置浏览器里用而且 JSSDK 签名配置本身也是一道门槛。企业微信客服场景里打开的 H5签名逻辑和企业微信的agentConfig绑定不像普通公众号那样只配一个wx.config就完事很多团队在这里卡住。如果不想绑死微信或者产品要求同一个 H5 在微信、普通浏览器、App 内都能访问那还是用 html5-qrcode 的方案。我的选择依据是目标用户全部在微信内用 JSSDK存在混合分发用 html5-qrcode 加降级。判断当前是不是微信环境常规做法是看navigator.userAgent里有没有MicroMessenger但企业微信的 UA 里同时有wxwork和MicroMessenger要分开处理。5.2 App 内嵌 WebView 的摄像头授权与 WebRTC 白名单很多 App 内嵌 H5 后摄像头打开失败第一反应是 H5 代码有问题其实问题经常在客户端 WebView 配置。iOS 的 WKWebView 必须先在 Info.plist 里加NSCameraUsageDescription否则 getUserMedia 直接拒绝Android WebView 需要在 WebChromeClient 里重写onPermissionRequest并且 grant 之后还要允许页面继续使用摄像头。单纯让 H5 上 HTTPS 是不够的。如果你控制不了客户端至少要在 H5 端把错误类型区分开NotAllowedError是权限问题NotFoundError是设备没有摄像头NotReadableError是摄像头被其他程序占用或权限冲突。弹给用户的话术完全不同。“摄像头正在被占用”在 Android 上很常见比如用户刚用微信扫过码再切到 App 里扫摄像头没释放干净就会出现这个错。5.3 uniapp 封装 H5 指向多个域名时的摄像头权限坑有同学问过“uniapp 封装 h5 如何指向 2 个域名”这个场景和扫码结合时有一个隐蔽问题浏览器把摄像头授权存在“源”上。在 A 域名授权后跳到 B 域名B 源没有授权记录又要重新弹一次权限框如果 B 域名是 http那直接在源层面就没有摄像头能力。所以 uniapp 的 H5 如果分发给多个域名一定要保证所有入口都是 HTTPS并且主动告诉用户第一次进入时需要重新授权。另一个坑是 uniapp 的chooseImage和真正的摄像头权限不是一回事。很多 uniapp 页面用input typefile capture只能拿到相册或拍照结果这不是持续的视频流扫码。要在 uniapp H5 里做实时扫码仍然得写自己的navigator.mediaDevices.getUserMedia或引 html5-qrcodeuniapp 组件层帮不上忙只有 App 端走原生插件才另说。5.4 扫码结果处理跳转、上传、图片 blob 与键盘联动扫码成功后的处理比想象中更影响体验。如果结果是 URL直接location.href跳转会丢失当前扫码页的历史记录用户按返回键可能直接退出我一般用location.replace或在新窗口打开给用户一个停留在原页面的选择。如果结果是 JSON 字符串别急着JSON.parse先try/catch因为二维码里的 JSON 经常带不可见字符或被二次编码。有的业务需要把扫码连带的二维码图片保存到服务器这时把相机当前帧截出来转成 blob 上传是常见做法。h5 里的 blob 文件是能上传的前提是 canvas 来源没有被污染getUserMedia 的视频帧不会污染 canvas可以放心toBlobscanCanvas.toBlob(async (blob) { if (!blob) return; const formData new FormData(); formData.append(file, blob, scan.png); formData.append(code, decodedText); await fetch(/api/upload, { method: POST, body: formData }); }, image/png);扫码完成后如果需要跳到页面上某个输入框并自动弹出键盘注意 App 内 WebView 的软键盘会挤压视口扫码页面高度要用position: fixed或动态计算visualViewport否则键盘弹起来时扫码框被顶出屏幕用户以为页面崩了。企业微信里做客服聊天场景时扫码结果通常是发给客服的一条消息多模态格式要注意图片大小扫码截图 PNG 可能几百 KB改成 JPEG 质量 0.8 会明显降低上传耗时。6. 把 jsQR 用得更顺手相册识别、连续扫码降功耗和一个小工具验证6.1 用 jsQR 识别相册里的二维码图片让扫码页多一个降级入口实时扫码偶尔会被权限、光线卡住给页面加一个“从相册选择二维码”的入口很实用。流程是用input typefile acceptimage/*拿到图片读入 Image 对象绘制到 canvas 后再用同样的 jsQR 调用。async function decodeFromFile(file) { const url URL.createObjectURL(file); const img new Image(); await new Promise((resolve, reject) { img.onload resolve; img.onerror reject; img.src url; }); const canvas document.createElement(canvas); canvas.width img.naturalWidth; canvas.height img.naturalHeight; 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); URL.revokeObjectURL(url); return result?.data ?? null; }注意大图要先压缩否则一张 4000x3000 的图片getImageData会拿到 4800 万个像素值解码可能卡一两秒。可以在drawImage时直接把图片画到一个宽度不超过 1000 的 canvas 上解码速度能快很多。6.2 连续扫码降功耗把解码间隔从 50ms 调到 300ms实时扫码的功耗主要来自三块摄像头常开、视频帧持续绘制、jsQR 持续解码。前两块省不掉第三块是唯一能让手机不烫的调节空间。fps 不是越高越好10fps 和 20fps 在识别率上的差距远小于发热降频带来的差距。如果用户在室内稳定环境下扫码把解码间隔放到 300ms一秒钟只解 3 次手机温度会明显降下来识别率反而稳定。6.3 用 performance.now 验证解码耗时别凭感觉调参数调参不能靠肉眼我习惯把单帧解码耗时打出来用数据决定到底该降分辨率还是降 fps。const start performance.now(); const code jsQR(frame.data, frame.width, frame.height, { inversionAttempts: dontInvert }); const spent performance.now() - start; console.log(jsQR 解码耗时, spent.toFixed(1), ms);如果spent经常超过 80ms优先减小 canvas 尺寸而不是降 fps如果spent稳定在 30ms 以内可以尝试把inversionAttempts切回attemptBoth提升反色码识别率。我之前有一次上线后用户反馈扫码页烫手最后发现是 fps 设了 30每帧全分辨率解码。改成 10fps 加半分辨率画布之后识别率反而提升因为手机不再降频。这个教训让我后来每次调扫码参数都会先测单帧耗时而不是凭感觉堆配置。希望帮到你。本文还有配套的精品资源点击获取