简介:这是一份基于 HTML5 与 JavaScript 的条形码和二维码扫描插件资源包,面向需要为网页快速接入摄像头扫码能力的前端开发者,解决浏览器端实时识别条码、解析二维码信息并与业务系统交互的问题。资源包共 88 个文件,约 9.27MB,包含 TS 核心源码、MD 说明文档、示例图片、HTML 页面,以及 JS/JSON/YML 等配置文件,适合阅读与二次开发。已有 2127 人学习下载。借助它,开发者可以深入理解 html5-qrcode 的调用逻辑,参考示例快速搭建扫码页面,掌握摄像头权限申请、浏览器兼容性和实时解码流程;压缩包中的测试用例、第三方解码库与打包配置,还能帮助处理解析失败、性能优化及 XSS 安全防护。实际落地时,可应用于电商商品快速查找、物联网设备配对、移动支付验证等场景,显著提升网页端输入效率与交互体验。 最近连续好几个读者问我同一个问题:怎么在一个纯 HTML+JS 的网页里实现条形码和二维码的扫一扫?说实话,这个需求比很多人想象中要普遍。我做过的项目里,仓库盘点、门店收银、资产登记、展会签到、后台系统扫码登录,几乎都能看到它的身影。只要业务系统是 B/S 架构,早晚有一天要面对"在网页里调摄像头扫码"这回事。很多团队的直觉是"扫码当然得用原生App",但网页方案的优势恰恰在于免安装、跨平台、改一次立刻全端生效。这篇文章我就从实际项目出发,完整拆解一套基于 HTML、JavaScript 的扫码插件搭建方案:底层原理、代码实现、兼容性踩坑、性能优化一次讲透。无论你是刚接触前端扫码的新手,还是已经被各种怪问题折磨过几轮的老开发,这篇都值得先收藏再阅读。
1. 为什么要在网页里做扫码:需求场景与方案选型
1.1 常见业务场景
先聊场景。做 Web 扫码最常见的几类:
- 仓储物流:PDA 或电脑端扫描包裹条形码,更新库存状态。
- 零售门店:收银台或移动端替代扫码枪,快速识别商品条码。
- 资产管理:固定资产生成二维码,手机浏览器打开页面直接扫。
- 会展签到:二维码门票,现场扫码验证。
- 系统登录:后台管理系统的扫码登录。
这些场景有个共同点:使用者不固定,设备不统一,如果为了扫码强制装一个 App,实施和培训成本都很高。网页扫码可以做到打开链接就用,尤其适合内部工具和轻量级 B 端系统。
另一个常被忽略的点是迭代速度。原生 App 改了扫码逻辑要等发版审核,H5 页面改完直接部署,业务方第二天就能用到新功能。对于需求变化频繁的运营类工具,这个优势是决定性的。
1.2 Web 扫码和原生扫码的真实差距
很多团队纠结原生还是 H5,我的看法是:
- 原生 App 扫码的优势是摄像头控制能力强、识别率高、能做图像增强,但开发和发版成本高。
- H5 扫码的短板是兼容性碎片化、识别率受浏览器限制,但胜在零安装、快速迭代。
- 小程序扫码体验接近原生,但要受平台限制,且必须先有平台账号。
如果你的场景是"给几十个内部员工用、系统已经有 Web 管理端",H5 扫码是性价比最高的选择。但如果是"面向百万级 C 端用户、对识别率要求极致",可能需要考虑混合方案。我的原则是:先想清楚使用边界,再决定技术路线,别一上来就搞重方案。
1.3 主流的几个 JS 扫码库怎么选
选库是整个方案的地基,我整理了实测过的几个主流方案:
| 方案 | 支持格式 | 额外体积 | 维护状态 | 适合场景 |
|---|---|---|---|---|
| BarcodeDetector(原生API) | 二维码 + Code128/EAN 等 | 0(浏览器内置) | Chrome 系可用 | 只需跑在新版 Chrome/Edge |
| @zxing/library | 二维码 + 几乎全部一维码 | 约 500KB | 持续维护 | 通用 H5 项目、兼容性要求高 |
| html5-qrcode | 二维码 + 部分一维码 | 约 300KB | 已归档不再更新 | 快速 demo 或原型 |
| QuaggaJS | 一维码为主 | 约 700KB | 基本停更 | 一维码专项 |
| Dynamsoft Barcode Reader | 几乎全部格式 | 较大 SDK | 商业授权 | 识别率要求高的商业化场景 |
我主推 @zxing/library。为什么?它底层是 ZXing 的 Java 版移植到 JavaScript,识别格式覆盖广,文档和社区都不错。而且它在解码时支持自定义 Hint,对条形码的兼容性也比 html5-qrcode 更灵活。
另外要注意,BarcodeDetector 虽然零依赖,但 iOS Safari 不支持,微信内置浏览器也不稳定,拿来当演示可以,做生产环境要慎重。html5-qrcode 其实封装得很顺手,但它已经归档不再维护,我用它做过原型,后来还是迁到了 @zxing/library。
2. 扫码底层的两条技术线:摄像头采集与图像解码
2.1 getUserMedia:网页能"看到"世界的前提
网页扫码首先需要拿到摄像头画面。核心 API 是navigator.mediaDevices.getUserMedia。
注意几个前置条件:
- 页面必须运行在 HTTPS 环境下,或者本机 localhost 环境。这是因为浏览器为防止恶意调用摄像头,要求安全上下文。
- 调用会触发浏览器的权限弹窗,用户拒绝后只能通过设置里的站点权限重新授权。
- 优先使用后置摄像头,约束条件里写
facingMode: 'environment'。
基础的获取代码是这样的:
async function startCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); const video = document.getElementById('video'); video.srcObject = stream; await video.play(); return stream; }这里我特意把分辨率约束到 1280x720。很多人上来就设 1920 甚至 4K,实际上扫码识别不需要那么高,分辨率越高图像数据越大、解码越慢,反而容易在弱光下拖垮性能。
2.2 从 video 画面到识别库的一帧数据
拿到摄像头视频流之后,识别库并不能直接读取 video 元素。它们一般接收的是 ImageData 或二进制图像数据。
数据流的链路是:
- video 标签播放摄像头实时画面。
- 用 canvas 的
drawImage()把当前帧画到画布上。 - 调用
getImageData()拿到像素数据。 - 转成识别库需要的格式(ZXing 内部是 RGBLuminanceSource)。
- 进行解码,得到条码文本内容。
简化代码如下:
function captureFrame(video, canvas) { const ctx = canvas.getContext('2d', { willReadFrequently: true }); canvas.width = video.videoWidth; canvas.height = video.videoHeight; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); return ctx.getImageData(0, 0, canvas.width, canvas.height); }有个容易被忽略的点:canvas 拿到getContext('2d')之后,如果这个 canvas 只在"读像素"场景用,建议带上willReadFrequently: true的配置,让浏览器知道我们会频繁读取像素,从而选择更合适的存储方式。
2.3 识别库到底在"看"什么
很多人以为扫码是"拍照后对图片做文字识别",其实完全是另一套逻辑。
- 二维码(如 QR Code)的结构里包含三个角上的"回"字形寻像图形,解码器先在图像里找这些特征来定位,然后做透视校正、网格采样,最后读取编码区域并经过纠错算法还原内容。
- 一维条码(如 EAN-13、Code128)则是靠一系列不同宽度的黑白条纹组合,解码时先识别条和空的比例关系,再映射回字符。
所以,模糊、反光、遮挡、倾斜角度过大,都会让解码器找不到特征点或读错比例,识别失败很正常。这也是为什么后面要用 TRY_HARDER 等 Hint 来提高容错。
ZXing 读取一帧的代码大致是:
const source = new RGBLuminanceSource(imageData.data, width, height); const bitmap = new BinaryBitmap(new HybridBinarizer(source)); const result = reader.decode(bitmap, hints);HybridBinarizer负责把彩色图像转成二值图(黑/白),这一步对识别效果影响非常大。光照不均、阴影都会导致二值化的阈值判断出错。这也是为什么"扫码时保证光照均匀"不是一句空话。
3. 手把手搭一个扫一扫插件:从零到能跑
3.1 初始化工程与依赖引入
用 npm 安装:
npm install @zxing/library然后在业务模块里引入:
import { BrowserMultiFormatReader, DecodeHintType, BarcodeFormat } from '@zxing/library';如果不方便用 npm,也可以直接 CDN 引入 UMD 版本:
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>这样全局会挂一个ZXing对象,后面代码直接用ZXing.BrowserMultiFormatReader即可。我自己做内部工具时常用 CDN,省去打包配置的麻烦;做正式产品我会用 npm 引入,方便按需加载和 tree-shaking。
3.2 页面结构与摄像头初始化
HTML 结构尽量简单:
<video id="scan-video" playsinline muted></video> <canvas id="scan-canvas" style="display:none;"></canvas> <div id="scan-result">等待扫码...</div> <button id="stop-btn">停止扫描</button>需要注意:
playsinline属性一定要加。在 iOS Safari 里,如果不加,video 会强制全屏播放,扫码画面直接被撑开,体验很糟糕。muted属性虽然没有音频语义,但有些浏览器默认不自动播放带声音的视频,加上保险。
JS 里启动摄像头:
const codeReader = new BrowserMultiFormatReader(); async function initScan() { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); const video = document.getElementById('scan-video'); video.srcObject = stream; await video.play(); startDecodeLoop(video); }注意:用BrowserMultiFormatReader时也可以直接用它的decodeFromVideoDevice方法,它会自己管理摄像头和循环。但如果你想微调识别参数、处理兼容性,我更推荐手动控制摄像头和识别循环,这样每一步都在你手里。
3.3 识别循环:抽帧、解码、回调
核心识别循环我习惯用requestAnimationFrame+ 时间戳控制间隔,而不是每帧都解码:
let lastResult = ''; let lastDecodeTime = 0; const DECODE_INTERVAL = 300; // ms function startDecodeLoop(video) { const canvas = document.getElementById('scan-canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); const tick = async (timestamp) => { if (timestamp - lastDecodeTime < DECODE_INTERVAL) { requestAnimationFrame(tick); return; } lastDecodeTime = timestamp; if (video.readyState >= 2 && video.videoWidth > 0) { 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); try { const result = decodeFromImageData(imageData, canvas.width, canvas.height); if (result && result.getText() !== lastResult) { lastResult = result.getText(); onScanSuccess(result.getText(), result.getFormat()); } } catch (e) { // 没有识别到是常态,不需要抛出 } } requestAnimationFrame(tick); }; requestAnimationFrame(tick); }这里我把识别封装成decodeFromImageData,内部用 ZXing 的底层 API:
import { RGBLuminanceSource, BinaryBitmap, HybridBinarizer, MultiFormatReader } from '@zxing/library'; const reader = new MultiFormatReader(); const hints = new Map(); hints.set(DecodeHintType.TRY_HARDER, true); reader.setHints(hints); function decodeFromImageData(imageData, width, height) { const source = new RGBLuminanceSource(imageData.data, width, height); const bitmap = new BinaryBitmap(new HybridBinarizer(source)); return reader.decode(bitmap); }这一步是很多人容易懵的地方——BrowserMultiFormatReader可以省事,但底层用MultiFormatReader直接解码反而更灵活。比如你可以随时改 Hints、支持多格式、返回格式名。
停止扫码时,一定要把摄像头资源释放掉:
function stopScan(stream) { if (stream) { stream.getTracks().forEach(track => track.stop()); } lastResult = ''; lastDecodeTime = 0; }3.4 封装成通用的扫码插件
前面都是散落的函数,实际项目里我会封装成一个类,方便不同页面复用:
export class ScanHelper { constructor(options = {}) { this.videoId = options.videoId || 'scan-video'; this.canvasId = options.canvasId || 'scan-canvas'; this.onResult = options.onResult || function () {}; this.onError = options.onError || function () {}; this.formats = options.formats || null; this.hints = new Map(); this.hints.set(DecodeHintType.TRY_HARDER, true); if (this.formats) { this.hints.set(DecodeHintType.POSSIBLE_FORMATS, this.formats); } this.reader = new MultiFormatReader(); this.reader.setHints(this.hints); this.stream = null; this.running = false; } async start() { try { this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); const video = document.getElementById(this.videoId); video.srcObject = this.stream; await video.play(); this.running = true; this.loop(); } catch (err) { this.onError(err); } } loop() { // 内部循环,复用前文的标识逻辑 } stop() { if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; } this.running = false; } }这样,任何页面只要new ScanHelper({ onResult })再start()就能扫码,配置项都收敛在一个类里。项目里十几个页面要加扫码功能时,这种封装能省大量重复劳动。
4. 实测踩坑记录:为什么你的扫码在某些手机上就是不行
4.1 摄像头打不开?按顺序排查
我踩过的第一类坑是摄像头完全打不开。排查顺序一般是:
- 页面是不是 HTTPS?不是 HTTPS 的话,getUserMedia 直接被拒。
- 是不是有别的应用占用了摄像头?电脑端尤其常见,视频会议没关。
- 之前拒绝过权限?浏览器设置里把该站点的摄像头权限重置。
- 企业微信、钉钉等 WebView 环境,需要先在宿主 App 里确认摄像头权限。
- 部分 PDA 设备有专属浏览器内核设置,需要允许"不安全内容"。
遇到过一台安卓 PDA,同一条码用系统相机能扫、用网页就是不行,最后发现是厂商浏览器默认把摄像头权限关了,在系统设置里打开才解决。这种设备定制问题,光看代码永远排查不出来。
4.2 识别率低:Hint、分辨率、光线一个都不能少
识别率低是最常见的抱怨。我的调优顺序是:
DecodeHintType.TRY_HARDER = true,这个必须开,它会付出更多计算来增加解码成功率。- 设置
POSSIBLE_FORMATS,告诉解码器只找哪些格式,能排除很多干扰。 - 分辨率控制在 720p 到 1080p 之间,太高反而容易产生噪点。
- 保证环境光线均匀,不要把强光直接打到条码上造成反光。
- 调整扫码距离,一维码通常建议 10 到 30 厘米,二维码可以更远一点。
一个典型的 POSSIBLE_FORMATS 配置:
hints.set(DecodeHintType.POSSIBLE_FORMATS, [ BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.ITF ]);如果你只需要二维码,就别把一维码加进去,能减少很多误判。之前有客户说扫普通商品码老失效,我一看他把所有格式都加进去了,摄像头稍微一晃就识别到错误的条码,误报率直接翻倍。
4.3 帧率与 CPU:别让扫码把浏览器跑烫
性能问题主要来自"每帧都解码"。实测 720p 图像一次 ZXing 解码在普通手机上耗时约 60 到 200 毫秒,老设备更慢。如果每帧都跑,CPU 会持续满载,手机会发烫,视频画面也可能卡顿。
我的做法是:
- 识别间隔控制在 200 到 400 毫秒,人眼几乎感觉不到延迟,CPU 占用大幅下降。
- canvas 保持和 video 同尺寸,但建议限制最大宽度不超过 1280,防止高像素手机输出过大图像。
- 如果 PDA 设备性能差,可以进一步降低 canvas 绘制尺寸到 640 宽,识别率损失很小。
- 页面切换到后台时自动 stop,用
visibilitychange事件监听,避免后台继续解码耗电。
有一次客户反馈扫码页面连续用半小时后手机烫得厉害,我排查后发现是循环里每帧都在解码,改成 300 毫秒间隔后问题立刻消失。
4.4 iOS、Android、老 WebView 的兼容性差异
这块最折磨人,我列出实测结论:
- iOS Safari 和 iOS 微信:总体稳定,但
playsinline必须加。 - Android 微信:不同版本的内核差异很大,老版本 X5 浏览器对 getUserMedia 支持不稳定,需要做降级方案。
- 部分安卓 WebView 默认关闭摄像头权限,需要在原生代码里设置
WebChromeClient.onPermissionRequest授权。 - 小米、华为等 PDA 定制 ROM,权限弹窗可能被系统拦截,用户找不到授权入口,建议页面里放一份"如何开启摄像头权限"的引导说明。
内存释放也要注意:stop()时不仅要停止视频轨道,还要把srcObject置空,否则部分设备会出现下一次打开时摄像头被占用的问题。
5. 进阶玩法:连续扫码、多码混扫与降级兜底
5.1 连续扫码场景的去重与防误触
仓库盘点经常需要连续扫多个条码。直接循环解码会导致同一码被扫好几遍。我的去重策略是:
- 记录上次识别结果。
- 解码结果与上次相同,且在冷却时间内(1 到 2 秒),直接忽略。
- 客户可选择"确认后进入下一次扫描",或者"自动连续扫描"。
在识别回调里加个冷却变量:
let cooldown = false; function onResult(text) { if (cooldown) return; cooldown = true; handleText(text); setTimeout(() => { cooldown = false; }, 1200); }连续扫码的关键是给操作员一个明确的反馈节奏,扫码成功后的"嘀"声提示比页面上的文字变化更有用。我一般会在回调里同时触发声音和震动(如果设备支持 Vibrate API),收银场景效率能提升不少。
5.2 一维码和二维码混扫时怎么配 Hint
仓库和门店的 SKU 标签经常一维码、二维码并存。此时 POSSIBLE_FORMATS 里把常见格式都放进去即可,但要意识到搜索空间变大,识别速度会略有下降。
建议:
- 如果条码数量多,先用二维码格式集,二维码识别失败时再用一维码格式集去试。
- 或者干脆不设置 POSSIBLE_FORMATS,让 MultiFormatReader 全格式尝试,但实测在嘈杂背景里误报率会增加。
我之前在服装门店项目里遇到的情况是:吊牌上既有 EAN-13 商品码,又有内部二维码,操作员不区分直接扫。我最后选择了全格式解码,同时把 TRY_HARDER 打开,识别速度虽然慢了一点,但操作员不用关心条码类型,整体效率反而更高。
5.3 浏览器不支持 getUserMedia 时的降级
总有些老设备、特殊 WebView 不支持实时摄像头,但业务不能停。我的兜底方案是:
- 提供"从相册选择图片"入口,用
<input type="file" accept="image/*">让用户拍一张或选一张,再走静态图片识别。 - 如果是公司内部 App,可以注册 JSBridge,调用原生的扫码模块,把结果回传给 H5 页面。
静态图识别的核心代码:
async function decodeFromFile(file) { const bitmap = await createImageBitmap(file); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const result = decodeFromImageData(imageData, canvas.width, canvas.height); return result ? result.getText() : null; }createImageBitmap不是所有浏览器都支持,老项目可以用new Image()+URL.createObjectURL替代。对于个别扫描枪模拟键盘输入的场景,也可以在页面上放一个输入框,监听 keydown 事件,把扫描枪当成"快速键盘"来用。这类硬件的降级方案虽然原始,但在某些老旧环境里反而是最稳的。
5.4 提升"扫一扫"体验的小细节
最后补几个提升体验的小技巧:
- 扫码成功后用
AudioContext生成一声短促的"嘀"反馈,比依赖音频文件更省资源。 - 画一个扫描线动画遮罩,让用户知道摄像头对准哪里。
- 弱光环境下,如果设备有闪光灯,可以尝试点亮。但注意 Web 端对闪光灯的控制很弱,通常只能靠视频轨道上的高级约束,支持度有限,别抱太大期望。
- 视频画面上叠加半透明取景框,引导用户把条码放进框内再识别,能明显提升首次识别成功率。
这些都是锦上添花,先把核心链路做稳定,再来加这些效果。我之前见过一个项目,界面和动画做得非常华丽,结果基础识别率只有七成,用户用一次就放弃了。体验优化的前提是核心功能靠谱。
最后说句实在话。这种扫码插件项目,真正花时间的从来不是把 demo 跑通,而是把"在真实设备上稳定可用的阈值"调出来。我经历过好几个项目,开发机一切正常,一上 PDA 就扫码失败,排查到最后要么是分辨率设置过高、要么是 TRY_HARDER 没开、要么是某个 WebView 卡住了权限请求。所以我建议你写代码时就把兼容性分支和错误提示留好,上线前拿几台不同品牌的手机和 PDA 各扫几十次,把识别率和 CPU 占用都记下来。如果后续遇到什么问题,欢迎在评论区把设备型号、浏览器版本和报错信息发出来,我们继续一起排查。
本文还有配套的精品资源,点击获取