简介面向Web前端的HTML5条形码识别示例包解决在移动浏览器中调用摄像头实时扫描Code 128条码的需求覆盖电商、物流、库存管理等快速录入场景也适合希望掌握浏览器调起摄像头与扫码库配合思路的中级开发者。压缩包共3个文件含一个可直接运行的演示页面与两个JS库分别提供页面结构、摄像头预览和条形码解码能力整包仅291KB无需额外下载依赖即可本地运行验证。已有1684人学习下载。示例串起获取媒体流、视频实时预览、条形码解码、识别结果回调的完整链路并针对后置摄像头与常见一维码格式做了基础配置代码精简、注释清晰。读者稍作修改即可接到自己的业务系统中也可进一步扩展多格式识别或与后台联动是移动端Web扫码功能快速落地的实用参考。1. H5 利用手机摄像头识别条形码别急着上原生 App仓库盘点、商超收银、售后退货这些场景里最常见的动作就是对着条形码扫一下。以前大家习惯做原生扫码页一套 Android、一套 iOS成本高不说还要发版审核。近两年我用 H5 在手机浏览器里直接调摄像头完成条形码识别效果完全够用而且一张网页就能同时覆盖两大平台。这里说的“H5 利用手机摄像头识别条形码”本质上就是调用用户手机摄像头的实时画面在浏览器端对视频帧逐帧做条形码解码整个过程不经过服务器也不用装 App。如果你的项目要求是“扫码够快、集成够轻、发个链接就能用”这个方向值得认真评估。但很多初次接触的人会低估水有多深摄像头权限明明弹了却黑屏后置摄像头亮不起来条码扫到了却久久不出结果iOS 上偶发白屏闪退。这篇文章就按我实际做过的方案把方案选型、核心代码、参数调整和踩过的典型坑一次讲透。新手照着敲熟手也能拿来当排查清单。2. 摄像头获取与扫码方案选型三种主流路线的取舍2.1 BarcodeDetector、jsQR、ZXing 三选一先看兼容矩阵在做任何代码之前先把“浏览器里识别条形码”这事拆开。它需要两条链路第一条是拿到手机摄像头的实时视频流第二条是每帧图像里的条形码解码。第一条人称统一都是navigator.mediaDevices.getUserMedia()第二条则分出了截然不同的三条路线。最直观的是浏览器原生BarcodeDetectorAPI。它由浏览器直接提供解码能力不需要引入任何外部库调用一次就能返回条形码内容、格式和位置框。不过它的兼容性一直在变当前主流 Chromium 内核的浏览器中桌面端和 Android 可用iOS 上的 Safari 至今不承诺支持。也就是说如果你只做安卓的内部扫码页直接用原生 API 是最省事的选择。第二条是纯 JavaScript 解码库 jsQR。它把解码逻辑全部用 JS 实现体积压缩后约 100 多 KB能同时识别二维码和多种一维码。它在任何有 canvas 的浏览器里都能跑兼容性靠的是自己的代码不受浏览器厂商脸色影响所以常被拿来当降级方案。第三条是 ZXing 的 WebAssembly 移植版。原生的 ZXing 是 Java 写的移植到 Wasm 后解码速度快、识别率在复杂背景下更稳但集成成本高还需要处理 Worker 线程和二进制文件。我通常只在需要识别低对比度、模糊、破损条码的重场景下才搬出它。普通 H5 页面用前两条已经能覆盖九成以上需求。选型上我一般这样定只做安卓手机端的单条码扫码首选 BarcodeDetector零依赖、速度快需要兼容 iPhone或者在一张页面里同时支持二维码和一维码就上 jsQR如果现场扫码经常遇到脏污、反光、不平整的条码或者识别的条码质量极差那就直接给用户推一个用 ZXing Wasm 的独立扫码页。没有万能方案只有特定场景下最合适的组合。2.2 用 getUserMedia 打开后置摄像头的最小命令不管最终选择哪种解码库第一步都是拿到视频流。这一行代码是全篇的地基const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: environment }, width: { ideal: 1280 }, height: { ideal: 720 }, }, audio: false, }); const video document.getElementById(scanner); video.srcObject stream; await video.play();这段代码的意思是请求浏览器分配一个视频流facingMode: { ideal: environment }告诉系统优先用后置摄像头。注意这里用的是ideal而不是exact因为部分浏览器在指定exact时一旦找不到后置头会直接抛OverconstrainedError而用ideal则允许系统退回到自拍头。width和height同样用ideal避免低端机达不到 1080p 时报错。拿到流之后还要明确一件事video.play()必须在浏览器的自动播放策略允许下执行。如果你在页面加载后立刻调用浏览器可能认为这是“无用户手势播放”导致画面停滞。我通常把整个扫码初始化过程放在用户点击“开始扫码”按钮的事件回调里这样既符合自动播放策略也顺势把摄像头权限请求挂在用户手势下权限弹窗显得不那么突兀。3. 用 BarcodeDetector 识别条形码核心代码与识别参数3.1 BarcodeDetector 的浏览器支持与降级策略BarcodeDetector 的兼容性是最容易让人栽跟头的地方。在写代码时它不像getUserMedia那样普适而且同是 Chrome不同版本支持的条形码格式还不一样。你必须先做能力检测再决定走原生还是降级。if (!(BarcodeDetector in window)) { // 原生不可用准备加载 jsQR 或提示用户 }如果你只打算支持 Chrome 系还应该了解一下它支持的格式。当前它支持一维码中的ean_13、ean_8、code_128、code_39、code_93、upc_a、upc_e、itf二维码则是qr_code、pdf417、aztec、data_matrix。但在实际使用时我会主动指定需要的格式而不是让它全量检测格式多了不仅慢还会出现“低价码先命中、期望码后命中”的错乱。建议这样实例化const formats [ean_13, ean_8, code_128, qr_code]; const detector new BarcodeDetector({ formats });这种写法只注册业务里真实出现的条码制式识别速度和准确率都会提升。如果业务上线后收到“扫同一码有时出结果有时不出”的反馈头一件事就要检查 formats 列表是否包含该条码的类型。3.2 从视频帧到检测结果的完整实现原生 BarcodeDetector 的工作逻辑是这样检测器接受一个图像源可以是ImageBitmap、CanvasImageSource或Blob然后返回一个 BarcodeFormat 数组。你要做的就是每隔固定时间把视频的当前帧画到 canvas 上再把 canvas 交给检测器。这个流程不难但有几处需要讲究。以下是我常用的循环实现const canvas document.createElement(canvas); const video document.getElementById(scanner); let scanning true; async function detectFrame() { if (!scanning) return; if (video.readyState ! 4) { requestAnimationFrame(detectFrame); return; } canvas.width video.videoWidth; canvas.height video.videoHeight; const ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(video, 0, 0); try { const codes await detector.detect(canvas); if (codes.length 0) { const code codes[0]; console.log(识别到条码:, code.rawValue, 位置:, code.boundingBox); scanning false; // 此处跳转或回填表单 } } catch (err) { console.warn(检测异常:, err); } requestAnimationFrame(detectFrame); } requestAnimationFrame(detectFrame);这段代码的关键在于不是每帧都调一次detect而是用requestAnimationFrame跟随屏幕刷新率持续跑。我先检查video.readyState是否为 4已在播放确保拿到的是有内容的画面。willReadFrequently: true是给 canvas 带的一句提示告诉浏览器这个画布会频繁读取像素让它内部不要做过多 GPU 优化避免读取时强制回读造成卡顿。检测结果里的boundingBox就是条码在视频帧里的坐标框。它很重要后面做视觉反馈全靠它。另外别把requestAnimationFrame改成setInterval后者在高刷屏上会因为节流而掉帧识别体验明显卡顿。4. 用 jsQR 做兼容兜底二维码条码通吃怎么接4.1 jsQR 集成步骤与画布取帧当 BarcodeDetector 不可用时jsQR 是我默认的兜底。它解码时需要你给出一个ImageData对象也就是 canvas 像素数据的 Uint8ClampedArray。所以流程比原生多了一步画视频帧、取出 ImageData、喂给 jsQR。听起来简单但取这帧画面的尺寸是有讲究的。// 先加载 jsQR比如 script src/libs/jsqr.min.js/script function decodeFromVideo(video) { const shortSide Math.min(video.videoWidth, video.videoHeight); const scale shortSide 640 ? 640 / shortSide : 1; const drawWidth Math.round(video.videoWidth * scale); const drawHeight Math.round(video.videoHeight * scale); canvas.width drawWidth; canvas.height drawHeight; const ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(video, 0, 0, drawWidth, drawHeight); const imageData ctx.getImageData(0, 0, drawWidth, drawHeight); const result jsQR(imageData.data, drawWidth, drawHeight, { inversionAttempts: dontInvert }); if (result) { console.log(识别条码:, result.data); } return result; }这里最值得说的就是那个缩略处理。摄像头预览画面往往是 1280x720 甚至更高直接把全帧丢给 jsQR一帧解码可能要耗几十毫秒而且低端机的getImageData也会卡。先把短边缩到 640既不影响一维码的条纹宽度又能让解码耗时降到可接受范围。inversionAttempts: dontInvert也很关键它告诉 jsQR 不要额外尝试颜色反转。默认设置是attemptBoth会在解码失败时把黑白对调后再试一次这对二维码有效但对一维码来说反而会大幅拖慢速度因为一维码在白底下黑条本身就很清晰没必要反转试探。如果你发现某批条码反色印刷白条黑底再单独把这个参数改成attemptBoth不要所有页面都开着。4.2 识别频率控制与防抖jsQR 是纯 CPU 解码不能像原生 BarcodeDetector 那样无脑每帧跑否则手机发热、掉帧、扫码界面转圈。我通常在视频帧上都做一次“降频防抖”处理。降频的意思是识别循环不要用 requestAnimationFrame 每秒跑 60 次改成每 200 毫秒跑一次防抖的意思是一旦识别到一个条码立即进入“冷却期”期间不做解码避免同一画面重复触发。let lastDecodeTime 0; let awaitingCallback false; let locked false; async function tick(now) { if (locked) return; if (now - lastDecodeTime 200) { requestAnimationFrame(tick); return; } if (awaitingCallback) { requestAnimationFrame(tick); return; } awaitingCallback true; const result decodeFromVideo(video); awaitingCallback false; lastDecodeTime now; if (result) { locked true; handleResult(result.data); // 3 秒后自动解锁允许再次扫描 setTimeout(() { locked false; }, 3000); } requestAnimationFrame(tick); } requestAnimationFrame(tick);防抖的 3 秒冷却很实用。现实中用户扫码后会把手机移开画面里条码消失但如果那条码一直对着摄像头jsQR 每 200 毫秒返回一次结果页面就会连续弹 N 次内容。加了locked之后不管识别成功多少帧只回调一次。等 3 秒后用户再扫下一件商品又能正常触发。5. H5 扫码避坑指南权限、对焦、光线、iOS 兼容5.1 getUserMedia 权限被拒和 HTTPS 要求现象页面弹出“无法使用摄像头”“NotAllowedError”控制台报NotAllowedError: Permission denied。原因通常是三种用户点了拒绝、浏览器设置里关闭了该站点的摄像头权限、页面运行在非 HTTPS 的局域网 IP 上。getUserMedia在主流浏览器里强制要求安全上下文。HTTP 内网 IP 在 Chrome 里也被视为安全上下文但 Safari 在不同版本上张得比较严很多 iOS 内网页面直接调不动摄像头。解决生产环境一律上 HTTPS内网测试时至少保证使用localhost或通配的127.0.0.1访问别图方便用 IP。权限被拒的处理也要做两手准备。第一次被拒后再次调用getUserMedia往往会直接走失败回调不再弹窗。这时你应该在界面里引导用户去浏览器站点设置里手动打开权限而不是机械地重试。我在做后台扫码页时会在失败分支里单独做一个“重新检测权限”按钮点击后调用navigator.permissions.query({ name: camera })看状态再根据状态提示用户该去哪里改设置。5.2 后置摄像头调不动与对焦问题现象明明手机上只有一个摄像头但是facingMode: environment就是报错或者画面出来的是前摄镜像。原因一部分安卓国产浏览器对environment支持不完整会把前摄当后摄用。我的做法是同时写facingMode: { ideal: environment }和advanced降级链先尝试后置失败后直接getUserMedia({ video: true })让浏览器自己选。如果video: true返回的画面是前置镜像再提示用户手动切换。切换摄像头时需要先停止原有视频流的轨道否则相机资源一直被占用。另一个让人头疼的是对焦。摄像头默认对焦距离通常在 1-2 米之外近距离看条码时画面模模糊糊。网页是没有办法直接调用“近距离对焦”的我们能做的是通过约束参数碰碰运气。const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: environment }, width: { ideal: 1920 }, height: { ideal: 1080 }, focusMode: { ideal: continuous }, advanced: [{ focusMode: continuous }], }, });focusMode在移动浏览器上支持度参差不齐真正的解法是引导用户把手机远近调整让条码进入对焦范围。页面里放一句“把条码放在框内保持 15 厘米距离”这句话比任何代码都管用。5.3 光线和条码印刷质量导致识别失败现象扫码页面在室内亮堂时一切正常换到日光下或灯管忽闪的环境里识别成功率直线下降尤其是打印在塑料包装袋上的条码基本扫不出。原因强光下条码反光暗光下对比度不足塑料包装弯曲导致条码畸变。jsQR 和 BarcodeDetector 针对灰度图像解码对“条码区域颜色混乱”非常敏感。代码层面的补救是提高画布对比度和亮度后再喂给解码器。我常用的两步先用ctx.filter把视频帧转成灰度再加大对比度但这个 filter 在移动端的性能开销不可忽视不能每帧都用只有在连续解码失败 3 帧后才启用一次“增强模式”。ctx.filter grayscale(1) contrast(1.6) brightness(1.1); ctx.drawImage(video, 0, 0, drawWidth, drawHeight); ctx.filter none;这一步不能保证百分百救回但能把反光条码的识别率提升一截。真碰上多次失败也别死磕桥代码直接把画面放大两倍再解码一次。放大后条码条纹在 canvas 像素里宽度变大解码器更易找到起始定界符。唯一代价是 CPU 耗时上浮适合放在手动“再试一次”按钮里。5.4 iOS 上黑屏和视频流不渲染现象iPhone 上页面能打开、权限也弹了但视频区域一直黑屏或者第一次扫码正常退出后第二次进页面直接白屏。原因下面代码执行顺序有问题video.srcObject stream发生在play()之前没有问题但如果你在play()之前对video设置了display:none或动态创建后未插入 DOMSafari 在渲染视频前需要确保元素在文档流中可见。解法把video放到页面里设置playsinline属性并且在video.play()返回的 Promise 里才操作摄像头切换或 UI 状态。iOS 对playsinline是硬性要求不设置的话视频会试图进入全屏播放直接打断扫码流程。另一点经验是在停止扫码时一定要遍历轨道并 stop否则 iOS 摄像头指示灯会一直亮着再次打开摄像头时出现黑屏。function stopCamera(stream) { if (stream) { stream.getTracks().forEach(track track.stop()); } const video document.getElementById(scanner); if (video) video.srcObject null; }这个 stop 函数在页面隐藏事件里也必须挂一份。很多移动浏览器在切后台后会自动释放摄像头资源如果你的页面却还持有着旧的 stream 引用回到前台再重新扫码时就会遇到摄像头打不开的诡异问题。6. 进阶条码定位框与实时放大镜一旦基础扫码稳定最值得提升的是“看似高级、实际可用”的三件事识别结果定位框、增强现实放大镜、扫码成功后的震动反馈。第一步是把 BoundingBox 映射到页面上的扫描框。检测器返回的条码框坐标是相对于识别像素的你需要按比例换算到视频元素的实际显示尺寸。我用一个普通的 div 做覆盖层把视频外层的position: relative容器拿来当作坐标系计算换算比例时记得考虑视频元素用 CSS 缩放带来的偏移。const videoRect video.getBoundingClientRect(); const pointX (box.x / canvas.width) * videoRect.width; const pointY (box.y / canvas.height) * videoRect.height; const pointWidth (box.width / canvas.width) * videoRect.width; const overlayBox.style.left pointX px;这个定位框不是为了好看。实测下来用户看到绿色框套住条码时会本能地稳定手持速度反而比盲目找角度快得多。当条码被准确框住后我再在框内左上角渲染一个基于 canvas 的局部放大镜截取条码区域纹理画到一个离屏 canvas 上做二次取样再把放大后的纹理贴回浮层这样即使手机离得近也能快速对准焦点。震动反馈在安卓浏览器上可以用navigator.vibrate(30)iOS Safari 不支持所以我会用一层 180ms 的绿色高亮动画做替代。这些反馈加起来用户会明显感觉到“扫到了”而不是干等回调。最后说一说我踩过最深的坑摄像头页面在混合 App 的 WebView 里运行WebView 对getUserMedia支持要靠原生层开启权限配置导致我在某公司的内部工具里折腾了两天最后发现是原生壳没给Permissions声明。从此我接任何 H5 扫码需求先问一句“会不会跑在 WebView 里”再决定要不要加一个原生配置前置检查。希望这篇能帮你把那些隐蔽的坎提前绕过去真正做出一版拿来即用的扫码页。本文还有配套的精品资源点击获取