直接上结论jsQR 这个库确实能让 H5 页面在手机上调用摄像头识别二维码而且整个方案比我想象中轻量很多。不需要后端参与不依赖原生 App一个支持 HTTPS 的网页就能跑起来。这篇文章我会把完整的实现思路、踩过的坑、以及一些常规文档里不会写清楚的细节全部摊开来讲。1. 为什么选 jsQR 而不是微信内置扫码或原生扫码先说选型。做 H5 扫码识别市面上其实有好几条路可以走我最初也犹豫过但最终选择 jsQR 是基于以下几个非常现实的考量。1.1 几种主流扫码方案的横向对比方案是否需要原生开发是否需要后端兼容性适用场景微信内置 JSSDK 扫一扫否是需公众号配置仅微信浏览器微信公众号内业务原生 App 扫码是否仅自家 AppApp 内嵌 H5第三方云识别 API否是全浏览器对实时性要求不高的场景jsQR 前端识别否否现代浏览器均可通用 H5、微信公众号、普通浏览器如果你做的只是一个简单页面想在微信里扫个码完成某个操作那直接调微信扫一扫是最省事的。但问题是微信扫一扫依赖公众号的 JS-SDK 权限配置域名要校验而且只能用在微信里用户一旦用 Safari 或 Chrome 打开就废了。原生扫码更不用说了你得先有个 App。jsQR 的核心价值在于它把二维码图像解析完全放在浏览器端通过getUserMedia获取摄像头画面再用 Canvas 截帧最后交给 jsQR 的识别算法去解码。整个过程前后端都不需要参与部署起来就是一个静态页面。1.2 我在真实项目里遇到的需求我之所以折腾这个方案是因为要做一个设备巡检的 H5 页面用户用手机浏览器打开页面对准设备上的二维码铭牌扫一下页面自动识别设备编号并跳转到对应的巡检记录页。这个场景有几个硬性条件第一页面要跑在微信公众号里但也可能要复制链接到系统浏览器打开所以不能依赖微信独有的 API。第二设备二维码是印在金属铭牌上的表面有反光而且磨损严重识别难度比普通纸质二维码高不少。第三不能因为引入一个扫码功能就让页面体积膨胀太多加载速度必须快。jsQR 正好满足这些条件。它不挑宿主环境只要浏览器支持navigator.mediaDevices.getUserMedia和 Canvas 2D 绘图就能用打包体积在压缩后大约 40KB 左右对移动端来说完全可以接受。2. 实现前必须搞清楚的几个底层机制很多教程上来就贴代码但忽略了最关键的原理部分。如果你不清楚底层机制遇到问题会非常被动。我拆成三个部分讲清楚。2.1 getUserMedia如何拿到摄像头画面navigator.mediaDevices.getUserMedia是 Web 平台提供的一个 API作用就是向用户请求摄像头权限并返回一个媒体流。这个 API 需要 HTTPS 环境才能运行localhost算例外手机浏览器访问局域网 IP 时是不行的这一点后面会专门讲。另外它要求页面必须是用户主动操作触发的请求也就是用户点了某个按钮之后才能调用否则浏览器会直接拒绝这是出于安全考虑。代码层面通常是这样const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, // 优先使用后置摄像头 width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false });这里重点说两个参数。facingMode: environment表示优先调用后置摄像头扫码场景几乎都是扫实体二维码前置摄像头拍不清这个参数必须加。width和height不建议直接指定固定值用ideal让浏览器根据设备实际能力来选兼容性更好。有些教程让你直接设width: 1920但在低端安卓机上会导致帧率暴跌反而影响识别速度。拿到stream之后需要把它塞给video元素才能看到画面const video document.getElementById(video); video.srcObject stream; await video.play();这里有个小坑部分安卓浏览器在video.play()之前视频画面是不可见的而且必须等到video的readyState达到至少HAVE_METADATA才能确保有画面输出。实际开发中我一般会在video的loadedmetadata事件里再去启动识别循环这样最稳。2.2 Canvas 截帧视频流和图像识别之间的桥梁getUserMedia拿到的是实时视频流但 jsQR 并不认识视频流。它需要的是一个ImageData对象也就是一帧一帧的像素数据。所以中间必须有一个截帧的动作创建一个隐藏的canvas把video当前画面绘制到 canvas 上然后通过getImageData取出来。const canvas document.createElement(canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); function captureFrame() { canvas.width video.videoWidth; canvas.height video.videoHeight; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); return imageData; }这里有一个性能优化点很多人没注意getContext(2d, { willReadFrequently: true })。默认情况下Canvas 2D 上下文会优先考虑绘制性能而getImageData这种频繁读取像素的操作会触发 GPU 到 CPU 的拷贝性能开销很大。加上willReadFrequently: true之后浏览器会改用 CPU 渲染路径读取像素数据的速度会明显提升。这个参数在桌面浏览器上感知不明显但在手机上差别很大。另一个优化思路是降采样。假设手机摄像头输出的是 1280x720 的画面但二维码在画面中的占比通常不会特别小这时候完全可以用更小的 canvas 去绘制。比如固定把 canvas 设置成 480px 宽绘制时让浏览器自动缩放这样getImageData拿到的像素数会减少 80% 以上识别一帧的耗时能压到 20ms 以内。当然降采样不能太狠如果二维码在画面里占比本来就小降低分辨率会导致条码细节丢失识别率反而下降。这个权衡后面在识别率调优章节会展开。2.3 jsQR 的定位和识别流程jsQR 拿到ImageData之后内部会做几件事先转成灰度图然后通过边缘检测把可能有二维码的区域框出来再用三种定位图案就是二维码三个角的回字形方块去匹配候选区域最后对候选区域做透视变换、采样、解码。这个流程意味着它对图像质量有几个隐性要求。第一二维码的三个定位角必须完整出现在画面中缺一个就识别不了。第二二维码在画面中不能太小一般建议至少占画面宽度的 30% 以上。第三画面不能太暗或太亮反光和阴影都会导致灰度化之后定位图案丢失。我们是在浏览器里跑不是在实验室里所以不能要求用户把光照调好再扫。只能靠前端代码做自适应如果连续多帧识别失败就自动调低截帧分辨率、调整二值化阈值、或者提示用户靠近二维码。jsQR 自身没有提供这些辅助策略识别率优化全靠业务代码这点一定要有心理准备。3. 从零搭建最小可用版本原理讲完了接下来是真正的实操。我会把完整代码拆成几个关键模块来说你直接照着抄就能跑起来。3.1 目录结构与基础 HTML整个项目不需要构建工具不需要 npm 包只需要三个文件qrcode-scan/ ├── index.html ├── jsQR.js // 从 node_modules 或 CDN 拷贝出来的库文件 └── scan.jsindex.html里的核心结构如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title扫码识别/title style body { margin: 0; padding: 0; background: #000; overflow: hidden; } #video-container { position: relative; width: 100vw; height: 100vh; } #video { width: 100%; height: 100%; object-fit: cover; } /* 遮罩层逻辑在视频上方画一个扫码框增强用户体验 */ #scan-line { position: absolute; left: 10%; width: 80%; height: 2px; background: #00ff00; top: 50%; } #result-box { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 14px; background: rgba(0,0,0,0.6); padding: 10px 20px; border-radius: 6px; white-space: nowrap; } #start-btn { position: absolute; bottom: 100px; left: 50%; transform: translateX(-50%); padding: 12px 30px; background: #07c160; color: #fff; border: none; border-radius: 8px; font-size: 16px; } /style /head body div idvideo-container video idvideo playsinline muted/video div idscan-line/div div idresult-box请将二维码对准摄像头/div button idstart-btn开启摄像头/button /div script src./jsQR.js/script script src./scan.js/script /body /html有几个细节要提前说明。playsinline属性在 iOS Safari 上必须加否则视频会被强制全屏播放页面上的扫码框和提示文字全被挡住。muted属性是因为浏览器对自动播放有策略限制虽然视频没有声音但加上muted能避免某些浏览器把视频播放拦截掉。object-fit: cover是为了让画面填满屏幕且不变形代价是画面的边缘会被裁掉但这不影响扫码。3.2 核心识别逻辑循环截帧与防抖scan.js的完整逻辑如下const video document.getElementById(video); const startBtn document.getElementById(start-btn); const resultBox document.getElementById(result-box); const canvas document.createElement(canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); let scanning false; let detectTimer null; let pendingResult ; // 二维码识别成功后的回调函数这里是业务接入点 function onDetected(content) { // 简单防抖同一内容 3 秒内不重复触发 if (content pendingResult) return; pendingResult content; console.log(识别结果:, content); resultBox.textContent 识别成功: content; // 在这里做业务跳转例如 location.href detail.html?code encodeURIComponent(content); setTimeout(() { pendingResult ; }, 3000); } async function startScan() { if (scanning) return; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert(当前浏览器不支持摄像头访问请使用较新版本的浏览器); return; } try { const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); video.srcObject stream; await video.play(); scanning true; startBtn.style.display none; requestAnimationFrame(detectFrame); } catch (err) { console.error(摄像头启动失败:, err); if (err.name NotAllowedError) { alert(摄像头权限被拒绝请在设置中允许访问摄像头); } else if (err.name NotFoundError) { alert(未找到摄像头设备); } else { alert(摄像头启动失败: err.message); } } } function detectFrame() { if (!scanning) return; // 截帧 const targetWidth 480; // 降采样后的目标宽度 const scale targetWidth / video.videoWidth; canvas.width targetWidth; canvas.height Math.floor(video.videoHeight * scale); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); try { const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const code jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: dontInvert }); if (code code.data) { onDetected(code.data); } } catch (e) { // getImageData 偶尔会在页面切后台时抛异常忽略即可 } // 控制识别频率避免每帧都识别导致 CPU 过载 if (detectTimer) clearTimeout(detectTimer); detectTimer setTimeout(() { requestAnimationFrame(detectFrame); }, 100); } startBtn.addEventListener(click, startScan);核心逻辑就这些。requestAnimationFrame配合setTimeout控制识别频率这里设置的 100ms 意味着每秒最多识别 10 次。这个频率是我反复测试后觉得最合适的比它更快会明显发热掉电比它更慢又会有明显的延迟感。3.3 识别成功后的业务接法识别到二维码之后具体干什么就看你自己的业务了。我这边做的是识别设备编号然后跳详情页所以代码里是location.href。但有几个更复杂的场景值得提一下如果你做的是表单填写类应用可能需要把识别到的内容回填到某个input中这时要小心input的change事件触发时机建议用原生setter触发事件。如果你做的是扫描结果 手动确认的流程建议在识别成功后先展示结果和确认按钮等用户点击确认再做跳转避免误扫。如果你做的是批量录入场景可以在onDetected里做去重和缓存同一张二维码只允许录入一次。我的经验是识别成功后的反馈一定要及时且明确。用户看不到任何反馈会以为没扫上从而反复调整手机角度体验极差。我通常会加一个短暂的震动通过navigator.vibrate(100)同时在界面上显示识别成功字样和识别到的内容如果是跳转页还会给一个小延迟300ms 左右让用户看清结果再跳。4. HTTPS 与浏览器兼容性最容易踩的隐形坑这部分内容是我在项目上线前后被反复折腾的地方也是大多数网上教程不会仔细讲的。4.1 为什么必须用 HTTPSgetUserMedia有一个严格的安全上下文要求。所谓安全上下文Secure Context在移动端浏览器里基本可以理解为 HTTPS 页面。http://协议下navigator.mediaDevices这个对象都是undefined代码会在第一行就报错根本走不到获取摄像头那一步。唯一的例外是localhost。因为本地开发时如果连 localhost 都不允许开发者会非常难受。但注意这个例外不适用于局域网 IP 访问。也就是说你在电脑上把页面跑在http://localhost:8080然后用手机通过http://192.168.x.x:8080去访问是不行的照样拿不到摄像头权限。我自己的一个教训是项目开发前期图省事直接用 HTTP 在手机上联调结果所有的逻辑都写好了摄像头却怎么都打不开。排查了半天才发现是协议问题。后来学乖了开发阶段直接配好 HTTPS 证书用https://192.168.x.x访问调试环境再也没有被这个问题卡过。4.2 在手机上真机调试时怎么配 HTTPS真机调试是 H5 开发绕不开的一环。最实用的方案是使用mkcert这个工具它能生成本地受信任的 HTTPS 证书然后把证书安装到手机上。基本流程是这样的# 1. 安装 mkcertmacOS 用 brew install mkcert 即可 brew install mkcert # 2. 初始化根证书 mkcert -install # 3. 生成本机 IP 的证书注意这里是局域网 IP mkcert 192.168.1.100 localhost生成之后会得到两个文件.pem和-key.pem把它们配置到你的本地服务器里。我用的是vite或者webpack-dev-server配置 HTTPS 的方式网上都有现成的不会太复杂。然后在手机浏览器里访问https://192.168.1.100:8080第一次会有证书不信任的提示需要在手机设置里安装mkcert生成的那个根证书。这一步做完以后你的 H5 页面在 HTTPS 环境下就能正常调用摄像头了。微信内置浏览器通常不需要手动安装根证书前提是你的页面部署在正式的 HTTPS 域名下。4.3 浏览器兼容性iOS 和 Android 的差异处理浏览器对getUserMedia的支持度已经很高了但在移动端有两个明显的差异。iOS Safari 从 iOS 14.3 开始支持facingMode: environment但早期的 iOS 版本会忽略这个参数默认调用前置摄像头。如果你需要兼容很老的 iOS 版本就只能通过检测摄像头标签来提示用户手动切换。好消息是现在主流用户设备的 iOS 版本都不低了这个问题的实际影响已经越来越小。Android 端的主要问题是碎片化。部分国产浏览器内核尤其是某些老版本 WebView对getUserMedia的支持不完整表现为能拿到流但视频画面黑屏或者权限弹窗出现两次第一次点允许后没有反应。这些问题无法在代码层面完全规避比较务实的做法是在页面加载时检测navigator.mediaDevices navigator.mediaDevices.getUserMedia如果不支持就给出友好提示建议用户用系统浏览器打开。这里要给一个非常实在的建议务必在项目上线前拿几台真实机型过一遍主流程。模拟器里的行为跟真机差异很大尤其是摄像头权限这种跟系统强相关的功能不能只在电脑上测试完就以为万事大吉。5. 识别率与性能调优从能扫出来到好扫能把码扫出来只是及格线真正拉开体验差距的是识别率和识别速度。性能调优这部分我在项目里摸索了很久核心其实就那么几件事。5.1 降采样策略降低分辨率反而提高识别率很多人有个误区觉得摄像头分辨率越高识别越准。实际上二维码识别并不需要太多像素只要二维码在画面中占到足够大的比例就行。高分辨率带来的问题是每帧像素数据量巨大getImageData和jsQR的处理时间会显著拉长导致识别循环变慢用户体验是扫了半天没反应。我在项目中用的方案是动态降采样默认把截帧宽度控制在 480px然后根据连续识别失败的帧数动态调整。如果 10 帧都没识别出来就尝试把截帧宽度降到 320px因为可能有人在很远的地方扫码小分辨率反而能捕捉到整体结构如果识别出来了就恢复到默认值。具体代码改造也比较简单在detectFrame中把targetWidth变成一个可变变量即可let targetWidth 480; // 动态调整的截帧宽度 let failCount 0; function detectFrame() { if (!scanning) return; const scale targetWidth / video.videoWidth; canvas.width targetWidth; canvas.height Math.floor(video.videoHeight * scale); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); try { const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const code jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: dontInvert }); if (code code.data) { failCount 0; onDetected(code.data); } else { failCount; if (failCount 10 targetWidth 320) { targetWidth - 80; failCount 0; } } } catch (e) {} detectTimer setTimeout(() requestAnimationFrame(detectFrame), 100); }注意降采样之后video.videoWidth是原始视频宽度不是降采样后的宽度所以每次绘制时都要重新计算scale。我就是因为一开始忘了这一步导致降采样后画面总是被裁剪掉一部分二维码的定位角经常缺一块识别率不升反降。5.2 设置 handleInversion 参数破解反色二维码jsQR 的第三个参数是配置对象其中有一个inversionAttempts字段可选值有dontInvert、onlyInvert、attemptBoth和invertFirst。它控制的是当正常颜色解析失败时是否尝试把画面颜色反转后再解析一次。常规白底黑字的二维码用dontInvert就够了还能省掉一次解析时间。但对于部分金属铭牌上的二维码或者反色印刷的二维码可能需要attemptBoth才能识别出来。这个参数对性能影响很大attemptBoth的耗时可能是dontInvert的两倍以上所以如果你的业务场景中二维码颜色比较随机我建议先用dontInvert跑一版实测识别率不理想再加反转尝试。我在设备巡检项目里一开始用了attemptBoth因为铭牌反光导致黑白色块经常识别出反色的假码。但后来发现真正的反色二维码其实是极少数大多数识别失败是因为光照和角度问题把参数改回dontInvert之后配合良好的扫码引导框识别率并没有明显下降性能和发热问题却减轻了很多。5.3 相机启动参数的进一步优化getUserMedia的视频参数除了facingMode之外还可以设置frameRate。扫码场景其实不需要 60 帧的高帧率我测试过frameRate: { ideal: 15, max: 30 }的配置画面流畅度足够而且帧率上限的限制可以让摄像头在低光照下自动调长曝光时间反而有助于暗光环境下的识别。另外advanced参数可以设置一些浏览器特有的能力比如自动对焦模式。不过这个参数兼容性比较差不建议作为核心依赖可以作为渐进增强const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 15, max: 30 }, advanced: [{ zoom: 2 }] // 部分安卓机型支持不支持会忽略或报错需 try-catch }, audio: false });注意advanced数组里只要有一个约束不被支持整个getUserMedia就会抛OverconstrainedError。所以不能直接把advanced写死要放在try块里失败后降级再去调用不带advanced的版本。6. 实战中的常见异常与处理问题清单最后把这些天踩过的坑按问题形态汇总成表便于你按图索骥。异常表现根本原因解决方案navigator.mediaDevices为undefined非 HTTPS 环境或浏览器不支持部署到 HTTPS检查浏览器版本摄像头权限弹窗不出现页面不是用户主动操作触发的getUserMedia调用给调用包一层按钮点击事件iOS 视频全屏缺少playsinline属性video 标签加上playsinline视频黑屏但有画面声音虽然没音频部分 WebView 内核兼容问题尝试降级分辨率或检测到黑屏时提示用系统浏览器识别速度慢分辨率太高、频率太快降采样到 480px 宽识别间隔调到 100ms近处能扫、远处扫不到截帧分辨率太低开启动态降采样策略大二维码在画面中过大时自动调高分辨率反色码扫不到inversionAttempts配置问题适当使用attemptBoth或根据业务调整页面切后台后识别循环卡死requestAnimationFrame在后台被暂停监听visibilitychange回到前台时重启识别循环补充两个列表里放不下但很重要的实战细节第一页面切后台后requestAnimationFrame会被浏览器冻结这个机制是对的能省电。但用户切回来之后识别循环可能没有自动恢复因为requestAnimationFrame的回调在冻结期间被跳过了。解决方案是监听visibilitychange事件当页面回到前台时重新调用一次requestAnimationFrame(detectFrame)。第二长时间开着摄像头会让手机发热、电量掉得飞快。如果你的业务不是那种扫一次就退出的场景建议加一个停止扫码按钮主动调用stream.getTracks().forEach(track track.stop())来关闭摄像头。我还在项目中加了一个 2 分钟无识别自动关闭摄像头的逻辑用户反馈续航表现好了很多。7. 如果再让我做一次我会这样简化流程最近帮朋友重构了一个类似的扫码页面回头审视整个开发流程我发现有很多当时觉得绕不过去的坎其实是可以提前规避的。这里给后来者一个精简版的执行顺序建议照着这个顺序走应该能少走不少弯路。第一步先把 HTTPS 环境搞定。不要等项目写到一半再补到时候排查问题会让你怀疑人生。第二步用最小页面把摄像头调通。这一步只做两件事点击按钮能出画面视频能正常播放。先别管识别先确保最底层的能力可用。第三步在最小页面里接入 jsQR验证识别效果。用一张打印好的测试二维码分别在白天、灯光下、弱光环境各测一次。如果这一步通过了后续的优化都是锦上添花。第四步再做 UI 细节和业务逻辑。扫码框、提示语、识别成功后的跳转、异常处理这些都放到这一步来做避免过早陷入 UI 细节而忽略了核心功能的验证。我自己第一次做的时候就是顺序搞反了先折腾了半天 UI扫码框画得花里胡哨结果摄像头还没调通最后所有东西都要返工重来。做 H5 扫码这类跟系统能力强相关的功能先确保核心链路可用再做增量是最高效的路线。最后再分享一个我觉得很实用的小技巧在开发调试阶段可以在页面上加一个隐藏的测试模式开关直接用本地图片文件代替摄像头去测 jsQR 的识别效果。这样可以断点调试识别链路里的每一步不用每次都对着摄像头举二维码。等到图片识别链路完全跑通了再切换到实时摄像头模式基本上一次就能通过。