简介:一套基于纯HTML与JavaScript实现的条形码识别方案,面向Web前端开发者和刚接触扫码应用的学习者,无需后端服务或第三方框架,打开HTML页面即可在浏览器中完成条码识别,适合离线工具、教学演示及日常商品条码管理等轻量化场景。压缩包共11个文件,包括5个JS脚本、4个HTML页面和2份Markdown说明文档,整体体积约90KB;其中HTML页面从最简单的扫码版到图片上传、视频识别进阶版均有覆盖,JS脚本按功能拆分,方便按需调试和二次开发。目前已有767人学习下载。资源内保留了完整项目结构与说明文档,既有中文说明也有英文版本,可帮助读者快速了解识别流程与模块作用;从零基础版本到优化准确度版本,逐步展示条形码识别与图像处理的改进思路,适合边学边改,也可直接作为网页应用中的扫码模块嵌入使用。
1. 条形码识别其实可以完全在浏览器里完成
仓库盘点、前台收银、图书借阅这类场景里,扫码通常意味着买一台扫码枪,或者在手机上装个App。但如果你只在内部系统里用,且不想经过服务器转一道,纯HTML+JS的方案已经足够:浏览器直接调摄像头,识别EAN、Code 128、QR这类条形码,识别结果原地出现在页面上。这意味着零安装、数据不出内网、也不存在图片上传到第三方服务的隐私问题。Chrome 83之后在桌面端就带上了BarcodeDetector,Android端和iOS端的现代浏览器也在逐步补齐。这篇文章就顺着纯HTML+JS这条路,从拍照识别开始,一直写到实时扫码、性能调优和兼容兜底。
2. 浏览器原生条形码识别能力的选型与最小实现
2.1 原生BarcodeDetector、ZXing与扫码枪的取舍
纯前端识别条码,现在有三条路:用浏览器自带的BarcodeDetector、引入ZXing的编译版前端库、以及最古老的“用键盘模拟输入的扫码枪”。扫码枪本质上是HID设备,按下触发键后把条码内容按键盘事件逐个“敲”出来,它不需要任何识别代码,但必须依赖额外硬件,且无法判断这个码是不是合法条码。
常见做法是优先BarcodeDetector,理由很直接:零依赖、离线可用、识别速度在桌面Chrome上相当快。它支持的格式覆盖了绝大多数业务场景:
| 格式 | 说明 | 典型场景 |
|---|---|---|
| code_128 | 密度高,支持全ASCII | 物流单、库存标签 |
| code_39 | 老牌工业码 | 资产标签 |
| ean_13 / ean_8 | 零售商品码 | 超市、图书 |
| upc_a / upc_e | 北美商品码 | 零售POS |
| qr_code | 矩阵码 | 电子票、跳转链接 |
| data_matrix | 小面积矩阵码 | 电子元件追溯 |
| pdf417 | 堆叠式二维码 | 登机牌、证件 |
JS的BarcodeDetector在实现上属于Shape Detection API的一部分,底层调用了系统的MediaPipe能力,并不需要网页具备特殊的权限,但浏览器必须运行在安全上下文中,也就是HTTPS或localhost。这一点后面会反复遇到,很多“为什么我本机能跑、手机打不开”的问题,根源都在这里。
2.2 用getUserMedia拍照识别的最小命令
先做一个最简版本:用户点击按钮,拉起摄像头,拍一帧,然后识别这一帧里的条形码。这个流程是“静态识别”,代码量最小,也最容易理解。
<video id="camera" autoplay playsinline muted></video> <canvas id="snapshot" style="display:none"></canvas> <button id="scanBtn">拍照识别</button> <pre id="result"></pre> <script> const video = document.getElementById('camera'); const canvas = document.getElementById('snapshot'); const resultBox = document.getElementById('result'); // 拉起后置摄像头,优先环境摄像头 async function openCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }); video.srcObject = stream; await video.play(); } // 拍照:把当前帧画到画布,然后调用BarcodeDetector识别 async function captureAndDetect() { canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); const detector = new BarcodeDetector(); // 默认启用全部格式 const codes = await detector.detect(canvas); if (codes.length > 0) { resultBox.textContent = codes.map(c => c.rawValue).join(', '); } else { resultBox.textContent = '未识别到条码'; } } document.getElementById('scanBtn').addEventListener('click', captureAndDetect); openCamera(); </script>这段代码有几个关键参数值得说。facingMode: { ideal: 'environment' }中的ideal表示“尽量满足但不强制”,如果设备没有后置摄像头会自动降级到前置,不会直接抛错。video.play()不是必须的,但在某些浏览器里先显式调用一次可以避免画面出现黑屏等待。canvas.width取的是video.videoWidth而不是CSS宽度,因为摄像头原始分辨率通常大于页面显示尺寸,用CSS尺寸会导致画布被缩放,丢失条码细节。
new BarcodeDetector()不传参时,Chrome会启用它支持的所有格式。这在识别速度上会略有损耗,并且对某类码误识别的概率会升高。更合理的做法是显式声明业务需要的格式,比如new BarcodeDetector({ formats: ['code_128', 'qr_code'] })。从实际经验看,零售场景只开ean_13时误报率会大幅下降。
2.3 识别返回的结构和格式检查
detect()返回的是一个Promise,resolve后的数组里每个元素包含rawValue、format、boundingBox以及cornerPoints。rawValue是码携带的字符串,format是识别出的码制。这给了开发一个校验机会:如果业务里只允许Code 39,那么就算BarcodeDetector识别出了EAN码,也要人工丢弃。JS里直接用codes.find(c => c.format === 'code_39')过滤即可。
这里有个值得注意的坑:rawValue不一定是纯数字。Code 128和QR码可以携带任意ASCII字符,所以不要在别处写死“条码就是数字”的逻辑。如果后续要把结果发到后端,建议先encodeURIComponent再拼到URL里,避免特殊字符截断参数。
3. 从拍照到实时视频流识别
3.1 为什么拍照识别不够用
静态识别看着能跑,但真实场景里没人愿意按一次按钮拍一次照。收银台拿商品扫一下要立即出结果,仓库盘点要拿着手机对着货架来回扫,这种“对着就能出结果”的体验只能靠持续视频流识别实现。实时识别的本质是:重复“取帧—识别—展示结果”这个循环,直到识别成功或用户主动停止。
这里有一个需要一开始就明确的点:不要每一帧都跑识别。BarcodeDetector虽然快,但全分辨率下来一帧也要几十毫秒,在低端Android上甚至上百毫秒。如果使用requestAnimationFrame逐帧驱动,会把CPU打满,同时页面卡死。常见做法是控制识别频率,或者只在摄像头画面变化足够大时才识别。
3.2 用requestAnimationFrame实现连续扫码
下面这个例子把识别逻辑放进循环里,并加了一个简单的帧间隔控制。代码的关注点不是“炫技”,而是让CPU只在合理频率下工作。
const DETECT_INTERVAL = 150; // 每150ms识别一次,约6-7帧/秒 let lastDetectTime = 0; let streaming = true; function createDetector() { // 明确指定格式,能显著提升识别速度 return new BarcodeDetector({ formats: ['code_128', 'ean_13', 'qr_code'] }); } async function scanLoop() { const detector = createDetector(); while (streaming) { const now = Date.now(); if (now - lastDetectTime >= DETECT_INTERVAL) { try { const codes = await detector.detect(video); if (codes.length > 0) { const code = codes[0]; handleResult(code); // 识别成功后的回调 break; // 扫到就停,避免重复弹出 } } catch (err) { console.error('识别出错:', err.name, err.message); } lastDetectTime = Date.now(); } // 强制让出主线程,避免页面僵死 await new Promise(r => setTimeout(r, 0)); } } function stopScan() { streaming = false; }关于DETECT_INTERVAL这个参数,可以做一个简单的时钟:150毫秒是经验折中值。小于100毫秒在低端机上会明显发热掉帧,大于250毫秒则会感觉“迟钝”,条码在镜头前滑过去还没识别到。如果你的摄像头分辨率是1280×720,100ms以上的间隔基本能保证端到端延迟不超过300ms,用户体感上就是“秒出”。
另一个容易被忽略的参数是video标签的尺寸。BarcodeDetector.detect()直接接收video元素作为输入是允许的,但Chrome内部会按视频原始分辨率采样。如果你在页面上把video的CSS强制缩小到200px宽,识别精度会下降得厉害,因为源帧本身没有变,传到detect里仍然是大图,但这会造成一种视觉错觉——“我在屏幕上看到的图就是识别用的图”。正确的做法是:CSS上可以任意缩放给用户看,但要保证视频源分辨率不低于640×480。
3.3 结果去重与停顿判定
实时循环里最烦的问题不是识别不到,而是同一个条码被连续识别三次,弹三个框。去重逻辑有两种常见做法:一是记录上次条码值,在短时间内(比如1.5秒)对相同值不重复上报;二是在识别成功后的回调里立即停掉循环,也就是前段代码里break的方式。
断点式识别适合“扫一个打一个”的场景,但如果是盘点场景,用户希望连续扫多个不同的码,就不能break。这时用一个简单的时间戳去重表即可:
let lastCodes = {}; function dedupe(code) { const now = Date.now(); if (lastCodes[code] && now - lastCodes[code] < 1500) { return; // 1.5秒内重复的码直接丢弃 } lastCodes[code] = now; handleResult(code); }lastCodes用一个普通对象而非Map,原因是这里不需要遍历,且字符串键完全够用。把去重时间设到1500ms是经过观察的:手持扫描时,同一个码在画面中停留时间通常在0.6~1秒,识别帧率6Hz意味着同一个码会被识别4~9次。1.5秒既不会漏掉“扫完A立刻扫B”的场景,也能把重复事件压下去。
4. 识别性能调优与低质量影像的坑
4.1 画布降采样是双刃剑
前面提到要保证视频源分辨率,但反过来,有时你反而需要主动缩小画面再识别。低端手机上,摄像头默认输出1080p甚至更高,全帧识别不仅慢,而且条码在画面里占像素比例很大时,识别器反而容易丢失边界。常见做法是让用户把条码放在画面中心,取出中心区域的一小块画面,缩放到约480px宽度再做识别。
function cropCenterFrame(video, targetWidth = 480) { const srcW = video.videoWidth; const srcH = video.videoHeight; const size = Math.min(srcW, srcH); // 取最长边做正方形裁剪 const sx = (srcW - size) / 2; const sy = (srcH - size) / 2; canvas.width = targetWidth; const scale = targetWidth / size; canvas.height = Math.floor(size * scale); const ctx = canvas.getContext('2d'); ctx.drawImage(video, sx, sy, size, size, 0, 0, canvas.width, canvas.height); return canvas; }这段代码把视频帧从中间裁出一个正方形,再等比缩放到480px宽。这么做的好处有两点:一是排除了画面四周的干扰元素(比如反光的桌面、摄像头边缘的暗角);二是大幅减少了detect的输入像素数。注意这里裁的是原始分辨率下的坐标,不是CSS像素,所以在裁剪前必须用video.videoWidth计算,否则位置会偏移。
降采样也不是越低越好。条码在画面中如果占了不足50px宽,缩小后条纹会被抹平,识别率断崖式下跌。我的建议是:目标宽度控制在400-600px之间,且不要对识别的结果加成导出逻辑,因为你裁掉的部分可能正好是条码空白区,会影响条码定位。
4.2 帧率、功耗与Camera独立线程调度
BarcodeDetector在可用时会走硬件加速,但它仍是异步的,不会阻塞渲染线程。问题是JS侧的摄像头预览本身一直在消耗解码能力,长时间运行下,低端机的发热是真实存在的。应对手段除了控制识别频率外,还可以在做识别前把摄像头分辨率降下来,而不是保持1080p持续运行。
async function reopenWithLowerResolution() { if (video.srcObject) { video.srcObject.getTracks().forEach(track => track.stop()); } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); video.srcObject = stream; }这条思路是:先用默认高清流做预览,等用户点击“开始扫码”时,再降低分辨率重新打开摄像头。注意切换时要先stop()掉旧track,否则Android上会短暂黑屏甚至报错。720p对于1D条码识别完全够用,对于QR码也够,真正的长途是别来跑4K识别。截图识别用videoWidth超过1920的场景在实际项目中极少。
4.3 暗光、反光与运动模糊的影像预处理
条码识别对画质的要求比人脸识别更苛刻。暗光下噪点增加,解码头容易把条纹之间的空隙填平;强反光则会让白色区域过曝,条码的黑条在二值化后连成一片。
常见的应对技巧是给video元素叠加一个灰度+高对比度的CSS滤镜。不要小看这一招,它不改变视频流本身,但BarcodeDetector在部分Chrome实现里读取的就是绘制出来的画面,滤镜效果会生效。
<style> video#camera { filter: grayscale(1) contrast(1.3) brightness(1.1); } </style>grayscale(1)把彩色信息去除,可以消除彩色印刷背景的干扰;contrast(1.3)拉大黑白差距;brightness(1.1)补偿暗光。这三者叠加的效果在EAN码这种黑白相间的码上特别明显。但要注意滤镜也有副作用:如果条码印刷在黄色或红色底上,灰度化后可能会和深色条融为一体。此时应去掉grayscale,只保留contrast。
对运动模糊,代码层面能做的有限。可以在DETECT_INTERVAL保持50ms左右,同时把识别改为“取这一帧之前缓存的一帧”——因为视频帧是流式的,连续帧之间存在半帧到一帧的延迟,能抵消部分拖影。但根本解法还是提示用户放慢手速。可以在页面上放一行小字“请将条码对准屏幕正中,保持静止0.3秒”,这比任何算法都管用。
4.4 多码并存时的置信度评估
一张画面上出现多个条码时,detect()返回的数组顺序并不是按清晰度排序的,它倾向于按在画面中的空间位置(从上到下、从左到右)返回。如果业务要求“取最清晰的”,不能直接取codes[0]。
BarcodeDetector返回的boundingBox是DOMRect对象,包含width和height。经验法则是:包围盒越接近正方形且面积越大,说明码在画面中占得越多,识别可信度越高。可以按面积做一次排序:
codes.sort((a, b) => { const areaA = a.boundingBox.width * a.boundingBox.height; const areaB = b.boundingBox.width * b.boundingBox.height; return areaB - areaA; }); const best = codes[0];这个排序有个边界情况:EAN码本身是扁长的,QR码是方形的,两者的“最优”形状不同。如果你同时识别这两种码,纯面积排序有时会把画面边缘的大面积QR码排在中央的小EAN码前面。在此基础上再加一个中心距离权值会更稳:
const centerX = video.videoWidth / 2; const centerY = video.videoHeight / 2; const first = codes.findIndex(c => { const box = c.boundingBox; return Math.abs(box.x + box.width / 2 - centerX) < box.width; }); const target = codes[first === -1 ? 0 : first];这段逻辑的含义是:优先选择“水平中心方向与画面中线重叠”的码,如果没有完全重叠的,再退回面积最大的。实际扫码时,用户天然会把条码往画面中间放,这只用了一条度数公式,但它解决了一个经常出现在真实项目里的选择困难。
5. 原生API不支持的兜底与本地验证技巧
5.1 引入ZXing离线库做降级
BarcodeDetector在桌面Firefox和部分老旧Chromium内核的WebView里会直接不存在,此时new BarcodeDetector()会抛出TypeError。项目既然以zip包形式交付,说明有离线运行的可能,不能依赖CDN。建议把@zxing/library编译产物下载到本地js目录,在原生API缺失时自动降级。
<script src="./js/zxing.min.js"></script> <script> function createDetectFallback() { const formats = ['CODE_128', 'EAN_13', 'QR_CODE']; return { async detect(canvas) { const codeReader = new ZXing.BrowserMultiFormatReader(); const result = await codeReader.decodeFromCanvas(canvas); return [{ rawValue: result.getText(), format: result.getBarcodeFormat().toLowerCase() }]; } }; } function getDetector() { if ('BarcodeDetector' in window) { return new BarcodeDetector({ formats: ['code_128', 'ean_13', 'qr_code'] }); } return createDetectFallback(); } </script>ZXing的BrowserMultiFormatReader内部实现了Canvas识别,不依赖Web Worker,单文件就能离线跑。注意它的格式枚举是大写带下划线的(CODE_128),而原生API是小写带下划线的(code_128),在统一封装层里要做一次映射,否则业务代码判断格式时会踩坑。ZXing对1D条码的识别率在低光下略优于原生API,但速度稍慢,作为降级方案足够体面。
5.2 离线的“造码”验证链路
没有真实商品条码时,可以用JSBarcode在本地生成测试条码。做法是在一个单独页面里把测试值渲染成Canvas,然后打印出来贴在纸盒上,或者直接在屏幕上用另一个显示器显示给摄像头识别。注意屏幕反射通常比纸质严重,识别距离要拉开到15cm以上。
<script src="./js/JsBarcode.all.min.js"></script> <canvas id="bc"></canvas> <script> JsBarcode("#bc", "6901234567892", { format: "EAN13", width: 2, height: 80, displayValue: true }); </script>用手机摄像头识别这个屏幕上的码,如果失败了,先检查显示器刷新率是否造成条纹闪烁,把画面亮度调低再试。更稳的方法是把它打印在A4纸上,哑光纸优于光面纸。
5.3 最后一步:把识别结果接到业务流里
识别只是开胃菜,业务系统要的是结果落地。一个最省事也足够稳的接法是:识别成功后把结果写入隐藏input,手动触发一次blur事件,让前端框架的v-model或表单控件自动同步;如果后端接口是REST风格,直接拼进URL跳转详情页也可以。
function handleResult(code) { // 用URL跳转把码值带给详情页,注意URI编码 const target = `/product/${encodeURIComponent(code.rawValue)}`; if (!location.origin.includes('localhost')) { // 非本地环境,直接跳转 location.href = target; } }需要注意的一点是:识别结果里可能带有校验位(EAN13最后一位),后端如果要做主键查询,最好由后端按码制规则剥离,而不是前端slice(0,12),因为Code 128的校验算法完全不同,通用的“去掉最后一位”会把正确输入搞坏。把原始rawValue和format一起提交,后端按格式处理,才是正确姿势。
本文还有配套的精品资源,点击获取