简介:面向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或动态创建后未插入 DOM,Safari 在渲染视频前需要确保元素在文档流中可见。
解法:把<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 里”,再决定要不要加一个原生配置前置检查。希望这篇能帮你把那些隐蔽的坎提前绕过去,真正做出一版拿来即用的扫码页。
本文还有配套的精品资源,点击获取