简介这是一套面向Web/H5前端及uni-app开发者的二维码扫码技术参考包围绕jsQR与html5-qrcode两个JavaScript库完整演示了从摄像头视频流获取、逐帧解码到结果回调的扫码流程并覆盖H5端权限申请、HTTPS环境要求及摄像头兼容性等常见问题适合需要在移动端H5、小程序或App中快速接入扫码能力的前端工程师。资源共43个文件压缩包仅488KB涵盖TypeScript与JavaScript源码、Vue组件、HTML页面、JSON配置、样式表及图片素材内部按uni-app工程组织业务页面、公共工具、静态资源、依赖锁定与打包配置分区清晰可直接作为二次开发或学习仿照的基础模板。已有3598人浏览学习。借助该压缩包读者既能对比理解jsQR纯JS解码与html5-qrcode完整API的差异也能掌握在uni-app跨端项目中集成扫码功能的工程化方法同时获得扫码框、提示动画、静态图片识别等交互增强思路以及一套开箱即用的Demo目录结构便于快速移植到实际业务场景。1. h5扫码为什么比你想的难jsQR 和 html5-qrcode 要解决的问题做过原生扫码的人第一次碰 h5 扫码十有八九会翻车。原生扫码是系统帮你把摄像头、对焦、图像处理全包了你只拿结果。到了 h5 页面你面对的是「拿不拿得到摄像头权限、视频流怎么拉、每一帧怎么转成可识别的图像、识别不出来怎么办」这一整串问题。jsQR 和 html5-qrcode 这两个库恰好代表了处理这串问题的两条路线前者是一个纯解码器只管把图像里的二维码读出来后者是一个全家桶把摄像头调用、画面渲染、扫码框 UI、解码逻辑全部封装好你传几个参数就能跑。这篇笔记就围绕这两个库讲清楚什么时候选谁、最小可运行的代码长什么样、参数怎么调以及真实项目里最常踩的五个坑。适合读这篇的人是那些要在自己的业务系统里嵌入扫码入口但又不想引入原生 App 或第三方 SDK 的从业者。无论你是要给后台管理系统加一个扫码登录还是给生产管理系统做一个物料码扫描页只要技术栈是 h5这篇文章里的方案都能直接改着用。2. 选库之前先看架构jsQR 和 html5-qrcode 的本质区别与实际选型2.1 拆开 h5 扫码的链路摄像头、取流、降采样、解码四层h5 扫码看起来是「打开摄像头 → 扫一下 → 出结果」实际拆开是四层独立的事情。第一层是摄像头权限获取浏览器层用getUserMedia向用户申请摄像头权限拿到一个MediaStream对象。第二层是视频流拉取把MediaStream绑定到video元素的srcObject上让画面在页面上渲染出来。第三层是帧转码因为解码器不认识视频流它只认识像素数据所以你需要把 video 的当前画面用canvas.drawImage()画到画布上再用getImageData()取出像素数组。第四层是解码把像素数组交给解码库让它定位二维码并读出字符串。两边的边界就在这里清晰了。jsQR 只做第四层前两层你得自己写getUserMedia怎么调、video怎么渲染、canvas怎么取帧全是你自己的代码。html5-qrcode 则一层到四层全包你调用它的start()方法它会自己去申请摄像头、渲染画面、内部取帧并解码你的业务代码只需要传入一个Html5QrcodeSuccessCallback回调接收结果。这也解释了为什么这两个库经常一起出现在检索词里。它们在功能上是重叠的在架构上是并行的选哪一个取决于你愿意自己写多少层。2.2 两个库在链路里的位置完全不同jsQR 的定位是「解码器」不是「扫码器」。它的核心能力是给定一个图像数据ImageData或像素数组的宽高和 Uint8ClampedArray定位画面中的二维码解码出文本内容并返回二维码四个角的位置信息。这意味着它的 API 极简核心就是jsQR(imageData, width, height)这一个函数剩下的所有事情你都自己掌控。你得到的自由度是摄像头用前置还是后置完全由你的getUserMedia参数决定扫码区域是一个固定矩形还是整个画面完全由你的 canvas 绘制范围决定连续扫码还是单帧扫码完全由你的 JavaScript 逻辑决定。代价是你得自己处理视频流的生命周期页面隐藏、退出、切换摄像头时都要自己写清理逻辑漏一步手机摄像头指示灯就会常亮。html5-qrcode 则把你需要自己写的部分全部收走了。它的Html5Qrcode类内部封装了getUserMedia调用、video 渲染、canvas 取帧和解码你在初始化时传入qrbox就能显示一个扫码框传fps就能控制每秒处理几帧。它还有一个Html5QrcodeScanner类连扫码框带摄像头切换按钮都自动渲染出来适合业务上只需要「打开页面就能扫」的快速集成场景。如果你本身就有自己的 UI 设计扫码框的位置、样式、动画都要和你的系统风格一致html5-qrcode 的默认 UI 反而是个需要绕开的障碍。如果你完全不想碰摄像头和 canvas 那堆细节只想快点出结果jsQR 那套自己写链路的方式就显得笨重。这里有一个选型判断可以分享你搜这两个词大概率是要在自有的业务系统里做一个 h5 扫码页而不是做通用扫码产品。这种情况下我通常会建议先看 UI 自由度。UI 定制需求高选 jsQR 自己控链路UI 无所谓、能接受框架自带样式选 html5-qrcode 少写代码。2.3 选型结论什么时候用 jsQR什么时候用 html5-qrcode直接用一张表给出结论如果你还在犹豫照着对号入座判断维度选 jsQR选 html5-qrcodeUI 定制扫码框样式、结果提示全部自绘UI 必须贴合系统能接受内置取景框样式或用 CSS 简单覆盖摄像头控制需要精细控制前置/后置切换、扫码区域位置只需要能打开摄像头并扫码不关心细节代码量自己写 getUserMedia、canvas、循环解码约 100~200 行核心调用不到 20 行适合快速交付性能敏感完全控制降采样帧率高适合多码连续扫fps 参数可调但取帧流程固定黑盒成分多学习曲线需要懂 MediaStream 和 canvas 基础几乎零门槛还有第三种常见做法也是我目前在项目里用得最多的用 html5-qrcode 来管理摄像头和视频流但把formatsToSupport只开二维码解码拿到结果后立即调用stop()停止扫码。这种组合既省掉了自己维护摄像头生命周期的麻烦又能在扫码成功后立刻释放资源避免手机持续发热掉电。3. 用 jsQR 手写一个连续扫码的最小实现3.1 最小 HTML 骨架video 和 canvas 就够了不引入任何构建工具一个单页就能跑通。先看 HTML 部分!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlejsQR 扫码示例/title /head body video idvideo playsinline muted/video canvas idcanvas styledisplay:none;/canvas p idresult等待扫码.../p button idbtnStop停止扫码/button script srchttps://unpkg.com/jsqr1.4.0/dist/jsQR.js/script script // 核心逻辑见下一节 /script /body /html说明几点video必须加playsinline特别是在 iOS Safari 上不加这个属性摄像头画面会被系统全屏接管页面上的扫码框和提示信息全部看不到。muted同样重要没有声音轨道的视频流如果不加 muted部分浏览器会拒绝自动播放。canvas不需要显示在页面上它的职责只是取帧。需要留意的是 jsQR 的引入版本。上面的示例使用了常见稳定版本如果你的页面有严格的 CSP 或需要离线部署建议把 jsQR 文件下载到本地作为静态资源引入不要依赖公共 CDN。这一点在部署到内网系统时尤其重要内网环境访问不了外网 CDN扫码页就会直接空白并报加载错误。3.2 核心循环requestAnimationFrame 里做降采样、解码、抖动抑制jsQR 的官方示例演示的是「拍一张照片再识别」但真实业务里用户希望的是「对准就出结果」也就是连续扫码。我常用的实现是基于requestAnimationFrame构建一个取帧循环const video document.getElementById(video); const canvas document.getElementById(canvas); const resultEl document.getElementById(result); const btnStop document.getElementById(btnStop); // 降采样目标宽度不要直接使用视频原始分辨率 const TARGET_WIDTH 480; let stream null; let scanning true; async function startCamera() { stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 } }, audio: false, }); video.srcObject stream; await video.play(); requestAnimationFrame(scanLoop); } function scanLoop() { if (!scanning) return; const videoWidth video.videoWidth; const videoHeight video.videoHeight; if (videoWidth 0 videoHeight 0) { // 按比例缩放保持宽高比避免二维码被拉伸变形 const scale Math.min(1, TARGET_WIDTH / videoWidth); const width Math.floor(videoWidth * scale); const height Math.floor(videoHeight * scale); canvas.width width; canvas.height height; const ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(video, 0, 0, width, height); const imageData ctx.getImageData(0, 0, width, height); const code jsQR(imageData.data, width, height, { inversionAttempts: dontInvert, }); if (code code.data) { resultEl.textContent code.data; scanning false; // 拿到结果后停止循环 stopStream(); } else { requestAnimationFrame(scanLoop); } } else { // 视频元数据还没加载完成等下一帧再试 requestAnimationFrame(scanLoop); } } function stopStream() { if (stream) { stream.getTracks().forEach((track) track.stop()); stream null; } } btnStop.addEventListener(click, () { scanning false; stopStream(); }); startCamera().catch((err) { resultEl.textContent 摄像头启动失败: err.message; });这个循环里最关键的参数是TARGET_WIDTH。我一般设成 480也就是把视频帧降采样到宽度 480 再做解码。jsQR 在图像分辨率过高时反而更容易丢码因为二维码在画面里占的比例变小定位算法需要扫描的像素点增多耗时呈指数级上升。比如一个二维码在 1280 宽的帧里只占 200 像素在 480 宽的帧里占了 200 像素缩放比例不变识别速度和稳定性都会明显改善。inversionAttempts: dontInvert这个参数也很值得说明。jsQR 默认会尝试对图像做反色处理也就是把白底黑码当成黑底白码再试一次这能解决部分反色二维码的识别问题但代价是单帧处理时间几乎翻倍。在连续扫码场景里我建议先设为dontInvert如果实际使用中经常遇到深色背景的二维码再改成attemptBoth。attemptBoth模式下每一帧都要做两遍解码对 CPU 的压力比较大部分低端安卓机上会出现画面卡顿。3.3 清理资源退出页面必须 stop 掉摄像头连续扫码最隐蔽的问题不是扫不出来而是扫码页退出后摄像头一直被占用。用户从扫码页跳转到别的页面手机顶部的录屏指示灯可能还亮着这时候你的页面代码已经被浏览器冻结了setInterval里的清理逻辑根本不会执行。我的习惯是通过三种方式兜底function stopStream() { if (stream) { stream.getTracks().forEach((track) track.stop()); stream null; } scanning false; } // 页面关闭或刷新 window.addEventListener(beforeunload, stopStream); // 页面切到后台iOS Safari 会冻结页面但事件先触发 document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { stopStream(); } }); // 单页应用路由切换以 Vue 为例的简单示意 // router.afterEach(() { stopStream(); });需要注意visibilitychange是双刃剑。页面切到后台时停止摄像头是对的但用户从后台切回来时如果还需要继续扫码就要重新调用startCamera()恢复否则视频画面会黑屏。所以更稳妥的做法是在visibilitychange里只做stopStream()在页面重新可见时检测video.srcObject是否存在不存在就重新启动。这个「恢复」逻辑是很多人会漏的实测中大概有一半的扫码页在切后台再回来之后会卡在黑屏状态。4. 用 html5-qrcode 十分钟跑通带 UI 的扫码页4.1 带 UI 的 Html5Qrcode内置取景框参数最少如果你不想碰摄像头和 canvas 那些细节html5-qrcode 的Html5Qrcode类是最快路径。这里以最常见的稳定版 API 为例!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlehtml5-qrcode 扫码示例/title /head body div idreader/div p idresult等待扫码.../p script srchttps://unpkg.com/html5-qrcode2.3.8/html5-qrcode.min.js /script script const resultEl document.getElementById(result); const html5QrCode new Html5Qrcode(reader); html5QrCode .start( { facingMode: environment }, { fps: 10, qrbox: { width: 250, height: 250 }, }, (decodedText) { resultEl.textContent decodedText; html5QrCode.stop().then(() { // 停止成功后把界面上的取景框清理掉 document.getElementById(reader).innerHTML ; }).catch((err) { console.error(停止扫码失败, err); }); }, (errorMessage) { // 这是一个无结果的帧通常忽略不处理 // console.log(errorMessage); } ) .catch((err) { resultEl.textContent 扫码初始化失败: err; }); /script /body /html入门最常见的错误是第三个参数回调里拿到结果后只更新 UI没有调用stop()。扫码结果虽然显示了但摄像头还开着继续消耗电量和流量带宽。上面的示例里我在拿到结果后立即调stop()并清理了容器里的残留 UI这在连续扫码业务中不是必须的但在「扫一次出结果就跳转」的业务中是标准做法。fps: 10是推荐值。不要盲目调高到 20 或 30因为每秒钟处理 20 帧会对 CPU 造成额外负载而且摄像头实际输出帧率通常达不到这个值调高了只是增加无效的取帧和解码中低端安卓机上会出现明显的发热和掉帧。4.2 不带 UI 的 Html5QrcodeScanner少写代码但 ui 不够灵活很多教程会直接推荐Html5QrcodeScanner因为它连初始化带 UI 渲染全自动const scanner new Html5QrcodeScanner(reader, { fps: 10, qrbox: { width: 250, height: 250 }, }); scanner.render( (decodedText) { document.getElementById(result).textContent decodedText; scanner.clear(); }, (errorMessage) { // 无结果的帧不用处理 } );这段代码确实最短整个页面的扫码能力就完成了。但它有一个明显的问题Html5QrcodeScanner会在reader容器里渲染自己的按钮、取景框、摄像头切换下拉框样式是框架自带的。如果你的业务系统里已有设计规范这个默认 UI 会显得和整体风格格格不入要覆盖它的样式并不容易因为它的内部 DOM 结构是私有的改起来要翻源码找类名。我的建议是快速做 Demo 验证可行性可以用Html5QrcodeScanner进入正式业务开发要么用Html5Qrcode配合自己的 UI要么回到 jsQR 路线自己控画面。在同一个页面里混用这两个类没有意义Html5QrcodeScanner内部其实就是对Html5Qrcode的封装。4.3 摄像头选择与切摄像头videoConstraints 的正确写法两个库处理摄像头选择的思路一致都遵循 WebRTC 的MediaTrackConstraints规范。先看正确写法// 启动时直接指定后置摄像头 { facingMode: environment } // 启动时不指定让用户选择 // 然后在运行时切换 html5QrCode.start( { facingMode: environment }, // 或 user config, successCallback, errorCallback );有一个高频踩坑点是很多人会写成{ facingMode: { exact: environment } }这里直觉上会以为「精确指定后置摄像头」更可靠。但实际上exact表示的是「如果找不到完全匹配的摄像头直接抛出错误不要降级」。在部分安卓机上系统对environment的映射并不一致加了exact后反而会导致一部分设备扫码初始化失败错误信息提示找不到满足约束的摄像头。在 jsQR 的自研发方案里切换摄像头需要先stopStream()再重新getUserMedia然后重置 canvas 和循环。在 html5-qrcode 里更简单它内部管理了这一切停止后再次start()即可但要注意停止和启动之间留一点时间间隔让摄像头硬件释放干净。连续快速切换时有些安卓设备会返回「设备忙」错误我实测间隔 300 毫秒以上基本可以规避。// 切换摄像头的推荐节奏伪代码示意 html5QrCode.stop() .then(() { // 等待一小段让摄像头硬件完全释放 return new Promise((resolve) setTimeout(resolve, 300)); }) .then(() { // 再次启动使用另一个 facingMode return html5QrCode.start(...); });页面上如果提供「切换摄像头」按钮建议在前后摄像头之间切换时不要修改qrbox和其他解码参数位置和尺寸保持一致用户体验更稳定。5. h5 扫码避坑权限、摄像头、解码率与性能的 5 个真实踩坑记录5.1 坑一本地直接用 file:// 打开扫码页直接白屏现象在电脑上写好扫码页面双击 HTML 文件用浏览器打开页面一片空白控制台报错getUserMedia is not defined或者提示Failed to execute getUserMedia on Navigator。原因getUserMedia只能在安全上下文中使用。安全上下文包括 HTTPS 页面和localhost而file://协议不属于安全上下文浏览器直接拒绝提供摄像头权限接口。解决本地开发时用本地服务启动比如npx serve或任意静态服务器访问http://localhost:端口即可。真机调试时要么把页面部署到 HTTPS 环境要么使用内网穿透工具给本地服务临时套一层 HTTPS。如果只是简单测试摄像头是否可用也可以先用浏览器打开about:config一类设置的方案但生产环境没有任何绕过手段必须 HTTPS。5.2 坑二后置摄像头调不起来或者调起来是前置现象在安卓机上用{ facingMode: environment }能正常调起后置摄像头但在同一套代码部署到 iOS 设备上后打开的是前置摄像头或者干脆黑屏。原因iOS Safari 对facingMode的解析和安卓不完全一致部分 iOS 版本在遇到environment时映射到的摄像头索引和预期不符。另外在部分定制 ROM 的安卓机上系统把前后摄像头做了交换强制指定exact: environment时会直接失败。解决不要依赖facingMode的精确匹配启动时先不指定或者只给ideal倾向值让系统自己选一个默认摄像头然后在 UI 上提供手动切换按钮。切换时先停后启间隔 300 毫秒左右。如果测试发现某台设备上手动切换也无效那大概率是系统层面的摄像头映射问题属于设备兼容性问题只能引导用户换浏览器或换设备不是代码能解决的。另一个经验是如果你必须默认后置摄像头启动后主动检测一下当前视频的宽高比。前置摄像头通常是竖屏比例后置摄像头是横屏比例根据video.videoWidth / video.videoHeight可以反推当前是哪个摄像头在出画面不对就自动切换这个方法在我的实际项目里比facingMode可靠得多。5.3 坑三二维码对准扫码框了但一直不出结果现象页面上显示了一个方形的扫码框用户把二维码放到框内摄像头也在工作但就是识别不出来。用系统相机一照就识别成功说明二维码本身没有问题。原因qrbox只是画面上的一个 UI 遮罩它在 html5-qrcode 内部不一定等同于解码区域。实际取帧时html5-qrcode 内部是从完整视频画面里裁剪qrbox对应的矩形区域做解码如果你的页面通过 CSS 拉伸了video的显示尺寸或者视频画面是object-fit: cover屏幕上的扫码框和内部裁剪区域就不重合了。你以为是框住二维码在扫实际内部裁剪的是框偏左或偏上的画面。解决确认扫码框的显示尺寸和解码尺寸一致。使用 html5-qrcode 时qrbox的数值会被直接作为内部解码区域的宽高所以你要保证两个地方的一致性。使用 jsQR 时不要踩入 CSS 拉伸的坑video 元素保持原始分辨率显示或者把 video 的显示尺寸和 canvas 取帧尺寸设为相同比例不要让二维码在视觉上变形。最简单验证方式扫码页上放一个文本标记让二维码对准扫码框四角位置各扫一次观察结果是偏左上还是偏右下反向调整即可。5.4 坑四退出扫码页手机摄像头指示灯还亮着现象扫码成功跳转到其他页面后手机状态栏的摄像头图标常亮过一会儿才熄灭有些机器上会一直亮到应用被杀掉。原因拿到结果后只处理了文本数据没有调用stop()或stopStream()摄像头资源始终被占用。在单页应用里路由已经从扫码页切换到别的组件但Html5Qrcode实例和MediaStream仍然存在于内存中摄像头硬件就一直在工作。解决统一封装一个停止函数。html5-qrcode 调用html5QrCode.stop()jsQR 方案调用stream.getTracks().forEach(track track.stop())。在扫码成功回调里、路由切换前、组件卸载时、页面隐藏时都要调用。前端领域常见的做法是把摄像头生命周期封装在一个独立的工具模块里比如在 Vue 项目的onUnmounted中调用。不要再写「只在扫码成功回调里 stop 一次」的逻辑多写几次 stop 不会有副作用重复 stop 是被允许且安全的。5.5 坑五光线稍微暗一点就扫不动解码率断崖式下跌现象室内光线充足时扫码秒出结果傍晚光线稍弱或二维码在阴影里就一直转圈扫不出。把手电筒打开照一下二维码立刻识别成功。原因二维码能被识别的前提是黑白模块之间有足够对比度。光线不足时摄像头自动增益把整体亮度拉上来了但黑色模块和白色模块之间的灰度差反而缩小了jsQR 等解码器的二值化算法在这样的输入上找不到清晰的明暗边界。解决第一在 getUserMedia 的约束里加上advanced参数尝试调整曝光例如video: { facingMode: environment, advanced: [{ exposureMode: continuous }] }但不是所有设备都支持。第二在 jsQR 解码前对 canvas 图像做一次对比度拉伸也就是先对像素值做一次线性变换让暗的更暗、亮的更亮这能显著提升弱光下的解码率。第三注意 UI 引导在扫码页上提示用户在光线不足时开启手机手电筒这个操作成本最低反而是实际项目里最有效的兜底手段。6. 上线前的性能验证与提交流程用 responseTime、成功率和稳定性说话扫码功能验收时「能扫出来」只是及格线。真实项目里用户不会把二维码端端正正地放在扫码框里等三秒他们期望的是对准就响。所以我每次交付扫码页前都会用三个指标做一轮标准验证responseTime、successRate、stability。responseTime 指的是从摄像头画面对准二维码到回调函数收到正确文本的时间这个值反映的是解码链路是否有不必要的耗时。successRate 是连续扫码 20 次成功次数除以 20低于 90% 说明参数需要调整。stability 是连续扫描 10 分钟观察摄像头是否掉流、页面是否出现内存持续上涨或卡死。验证环境至少覆盖以下场景室内正常光、室内暗光、室外逆光、二维码轻微倾斜、二维码距离 30 厘米和 60 厘米。把每个场景的结果填进一张表你会发现真正的问题往往出现在暗光和远距离这两列。根据验证结果调整参数我的调整顺序是先看响应时间是否超过 1.5 秒超了就降采样宽度到 320 或缩小qrbox再看暗光成功率是否低于 90%低了就加对比度预处理或提示用户开手电筒最后看稳定性连续扫描卡顿的话把 fps 从 10 降到 5把inversionAttempts设为dontInvert。这套流程比凭感觉调参数可靠得多也是我在扫码页上线前最后做的一步。还有一个值得投入的进阶动作把 jsQR 替换为 wasm 版本的 qrcode 解码器。jsQR 是纯 JavaScript 实现解码耗时在高分辨率帧上不稳定而 wasm 版本在浏览器里编译执行单帧解码时间可以压缩到纯 JS 的三分之一到一半。我目前的主力项目已经切换到 wasm 路线jsQR 和 html5-qrcode 只保留在低配兼容和快速集成的场景里。切换时注意wasm 版本通常需要你手动加载.wasm文件内网部署时要把 wasm 文件的 MIME 类型配成application/wasm否则浏览器会拒绝加载。整个扫码页走完这套流程后我养成了一个习惯任何扫码页面功能交付上线的同时必须附一张三指标验证表。参数调得再好看不如一张记录着「暗光 20 次全成功、平均响应时间 800 毫秒」的验收单有说服力。这个习惯帮我拦下过不少上线后才暴露的弱光识别问题也省去了在用户现场拿手机反复调试的尴尬。希望帮到你。本文还有配套的精品资源点击获取