简介:这份资源是一套基于 HTML5 与 JavaScript 的网页扫码插件源码包,面向需要在 Web 项目中集成条形码、二维码扫描能力的开发者,适合前端入门到中级水平。它解决的核心问题是让网页直接调用摄像头识别条码,省去原生开发或额外硬件。包内以 TypeScript 源码为核心实现,配合少量编译后的 JS 便于直接引入,同时提供多份 md 说明与更新日志、数十张 png、gif、jpeg 格式的条码类型示意图和扫描动图,还附带 json、yml 等构建与工程配置,以及 html 演示页面、React、Vue 等框架下的示例。全部内容共 88 个文件,压缩包约 9.27MB,结构按 src、examples、tests、third_party 等目录分层,便于按模块阅读。已有 2127 人学习下载。读者可据此理解摄像头权限申请、扫描区域初始化、结果回调与启停控制的完整链路,也能参考实验特性与兼容性文档、Webpack 构建脚本和测试用例,快速把扫码能力接入电商查货、设备配对、支付核验等场景。
1. 仓库门口那台平板:纯 html+js 扫一扫条形码和二维码能顶到什么程度
上周有人问我,仓库收货那台安卓平板能不能不装 App,只开个浏览器页面就把箱子上的条码录进系统。答案是可以的,一套 html+js 扫一扫条形码和二维码的插件就能办到——打开页面、授权摄像头、对着条码扫,结果直接回填到表单,全程不碰原生代码,也不用等应用商店审核。
这份插件做的事很朴素:用 getUserMedia 拿到后置摄像头,把视频帧按固定节奏抽到 canvas 上,再交给 JS 解码库逐帧尝试识别二维码和常见一维码,命中就回调一个带文本和码制格式的对象。它适合后台管理系统的扫码入库、H5 活动核销、门禁访客登记、临时写个小工具验证码源这类"页面里顺手扫一下"的场景;不适合要求毫秒级响应、或者必须完全离线跑在工业产线上的活儿。整份代码不依赖框架、不依赖构建工具,丢到静态服务器上就能跑,改起来也不用先啃一遍 webpack 配置。
2. 技术选型与目录结构:不打包、不依赖框架的扫码插件怎么搭
2.1 为什么绕开 BarcodeDetector 和各家 SDK
浏览器原生确实有个BarcodeDetector接口,听着很美好,实际用起来是玄学。Chromium 系在部分安卓机型上可用,iOS 上的 Safari 至今没有实现,Firefox 也没有;而且它支持的码制列表随平台变化,同一个页面在 A 手机能扫 EAN_13,到 B 手机就返回不支持。所以它只能当"检测到就加速、检测不到就降级"的可选通道,不能当主链路。
另一条常见的路是调用平台 SDK。微信内的scanQRCode需要公众号后台配置、域名校验、签名,还得用户就在微信里,并且它主要认二维码,一维码的识别能力很有限,出了微信就完全失效。打成原生 App 再嵌 WebView 成本更高,而这个需求往往只是管理系统里的一个小按钮,为它单独发一个包不值当。
所以插件走的是覆盖面最广的一条:getUserMedia取流 + 纯 JS 解码库。码制分工也清楚——二维码交给 jsQR,体积小、纯 JS、能顺带返回四个角点坐标,方便在预览上画识别框;一维码交给 ZXing 的MultiFormatReader,CODE_128、EAN_13、EAN_8、UPC_A、ITF、CODE_39 这些常见码制都能覆盖,超市商品码和物流面单码基本都在里面。两个库合计压缩后两百多 KB,对管理系统这类场景完全可接受。
2.2 目录结构与文件职责
这份插件没有构建步骤,所有文件按职责平铺,改哪个文件一眼能找到:
scan-plugin/ ├── index.html # 演示页,含扫码框、结果面板、开关按钮 ├── src/ │ ├── scanner.js # 主类,负责生命周期、循环调度、结果回调 │ ├── camera.js # 摄像头管理:开流、切换、关闭、手电筒 │ ├── decoder.js # 解码调度:抽帧、灰度转换、jsQR / ZXing 分流 │ ├── roi.js # 识别区域裁剪与坐标换算 │ └── feedback.js # 提示音(WebAudio)与震动 ├── vendor/ │ ├── jsQR.min.js # 二维码解码 │ └── zxing.min.js # 一维码 / 多码制解码 ├── css/ │ └── scanner.css # 遮罩、扫描框、扫描线动画 └── assets/ └── demo-qr.png # 演示用码图,方便没摄像头时测| 文件 | 职责 | 二次开发时最常改的地方 |
|---|---|---|
| scanner.js | 启停、循环节流、去重、回调派发 | 改回调结构、加埋点 |
| camera.js | 约束参数、设备枚举、track 管理 | 改分辨率、加前后摄切换 |
| decoder.js | 抽帧尺寸、灰度算法、码制分流 | 加新码制、换解码库 |
| roi.js | 按比例裁剪识别区域 | 条形码场景必调 |
| feedback.js | 成功提示 | 换成自定义音效 |
2.3 挂载方式:三行脚本起一个扫码器
插件的用法刻意做得像老式 jQuery 插件,不要求你用模块打包器:
<!-- index.html --> <div id="scan-box" style="width:100%;max-width:480px;aspect-ratio:4/3;"></div> <script src="vendor/jsQR.min.js"></script> <script src="vendor/zxing.min.js"></script> <script src="src/scanner.js"></script> <script> const scanner = new HtmlScanner('#scan-box', { formats: ['qr_code', 'code_128', 'ean_13'], // 想扫什么就开什么,开得越多越慢 continuous: false, // 单次模式,命中即停 facingMode: 'environment', // 后置摄像头 onResult(res) { // res = { text, format, points, cost } document.querySelector('#result').value = res.text; console.log('识别到', res.format, res.text, '耗时', res.cost, 'ms'); }, onError(err) { console.warn('扫码失败', err.code, err.message); } }); document.querySelector('#btn-start').onclick = () => scanner.start(); document.querySelector('#btn-stop').onclick = () => scanner.stop(); </script>这段代码的关键在formats和continuous两个参数。formats直接决定解码器每次要尝试的码制数量,开三种和开八种的单帧耗时能差一倍以上,扫码入库这种只认 CODE_128 的场景就别把二维码开着。continuous决定命中后是停还是继续,单次模式适合表单回填,连续模式适合盘点批量扫。
HtmlScanner的构造签名统一成(container, options),container支持选择器字符串或 DOM 元素,内部会往容器里插入<video>和<canvas>,以及一层半透明遮罩。这样做的好处是外部不需要关心元素结构,样式冲突也少——如果你项目里已经有一套 UI,把css/scanner.css里的遮罩层样式覆盖掉就行。
3. 核心链路拆解:一个视频帧从摄像头到识别结果的完整路径
3.1 摄像头启动:约束怎么写、失败了怎么退
getUserMedia是最容易翻车的一步,因为它同时受权限、安全上下文、设备能力三重影响。插件里的封装把约束写成"理想值 + 降级"两段式:
// src/camera.js class Camera { constructor(videoEl, opts = {}) { this.video = videoEl; this.facing = opts.facingMode || 'environment'; this.stream = null; } async open() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw { code: 'UNSUPPORTED', message: '当前环境不支持摄像头,检查是否为 HTTPS' }; } // 先按理想值要后置 + 720p,拿不到再降级,避免直接失败 const base = { audio: false, video: { facingMode: { ideal: this.facing }, width: { ideal: 1280 }, // 请求值,浏览器可能给更小的 height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } } }; try { this.stream = await navigator.mediaDevices.getUserMedia(base); } catch (e) { if (e.name === 'OverconstrainedError' || e.name === 'NotFoundError') { // 笔记本、无后置摄像头的设备,退回默认摄像头 this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); } else { throw { code: e.name, message: e.message }; // NotAllowedError 等 } } this.video.srcObject = this.stream; this.video.setAttribute('playsinline', 'true'); // iOS 不加这行会全屏播放 this.video.setAttribute('muted', 'true'); this.video.muted = true; await this.video.play(); // 部分浏览器必须显式 play,否则一直是黑屏 await this.waitForMetadata(); // 等 videoWidth 有值,否则抽帧拿到 0×0 return this.stream; } waitForMetadata() { return new Promise((resolve) => { if (this.video.videoWidth > 0) return resolve(); this.video.addEventListener('loadedmetadata', () => resolve(), { once: true }); }); } }facingMode用{ ideal: 'environment' }而不是直接写字符串,是因为写死字符串时,如果设备没有后置摄像头,浏览器会直接抛OverconstrainedError,整个扫码器就废了。用ideal相当于说"尽量给我后置,没有就算了"。同理width/height也写ideal,因为浏览器不保证给你 1280×720,安卓上很常见最后给到 640×480,插件后续的抽帧逻辑不能假设固定尺寸。
waitForMetadata这一步是血泪经验。视频流拿到srcObject之后,videoWidth并不是立刻就有值的,尤其在移动端有几十到几百毫秒的延迟。如果这时候就开始抽帧,drawImage画出来是空白,解码器每次都返回 null,表现为"摄像头开着但就是扫不出来",排查半天以为是解码库的问题。
3.2 抽帧采样:把整幅画面缩到解码友好的尺寸
解码器吃的是ImageData,直接从 1280×720 里取,每帧要处理近百万像素,中低端手机扛不住。插件的做法是先按最大宽度缩放到工作分辨率:
// src/decoder.js const MAX_WIDTH = 640; // 解码工作宽度,经验值:近距扫码 640,远距可提到 960 function grabFrame(video, canvas) { const vw = video.videoWidth; const vh = video.videoHeight; if (!vw || !vh) return null; const scale = Math.min(1, MAX_WIDTH / vw); // 只缩不放,避免小分辨率被拉糊 const w = Math.round(vw * scale); const h = Math.round(vh * scale); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } // willReadFrequently 会走 CPU 后备存储,getImageData 会快很多 const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(video, 0, 0, w, h); return ctx.getImageData(0, 0, w, h); }MAX_WIDTH取 640 是试出来的平衡点。降到 320 时,二维码的模块在缩小后糊成一片,jsQR的定位图案检测直接失败;提到 1280 时识别率确实上去了,但单帧耗时从 8ms 涨到 30ms 以上,帧率掉下来反而不如 640 稳。如果业务是"手机离条码一米远扫",把 640 改成 960,同时把continuous关掉减少频次,是更合适的组合。
willReadFrequently: true这个选项值得单独说。canvas 默认可能会把绘制内容放到 GPU,一旦你调用getImageData它就得把数据搬回 CPU,这个同步等待在移动端很贵。加上这个提示之后,浏览器会优先选 CPU 后备存储,实测安卓中端机单帧能省 3~5ms。
3.3 解码分流:二维码走 jsQR,一维码走 ZXing
两种码的识别思路完全不一样,混在一起写只会互相拖累,所以插件里分成两条支路,按formats决定走哪条:
// src/decoder.js function decodeOnce(imageData, formats) { // 支路一:二维码,jsQR 直接吃 RGBA 数据,还能返回角点 if (formats.includes('qr_code')) { const r = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' // 深底白码也试一次,多花约 2ms }); if (r) { return { text: r.data, format: 'qr_code', points: r.location }; } } // 支路二:一维码,先转灰度再喂给 ZXing const oneD = formats.filter(f => f !== 'qr_code'); if (oneD.length === 0) return null; const source = toLuminanceSource(imageData); const bitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(source)); const hints = new Map(); hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, oneD.map(toZXingFormat)); hints.set(ZXing.DecodeHintType.TRY_HARDER, true); // 慢一点,但歪斜/低对比度能救回来 const reader = new ZXing.MultiFormatReader(); reader.setHints(hints); try { const result = reader.decode(bitmap); return { text: result.getText(), format: result.getBarcodeFormat() }; } catch (e) { return null; // NotFoundException 是常态,每帧都可能失败,不要当错误上抛 } } // RGBA → 灰度,整数近似比浮点快 function toLuminanceSource(imageData) { const { data, width, height } = imageData; const out = new Uint8ClampedArray(width * height); for (let i = 0, j = 0; i < data.length; i += 4, j++) { out[j] = (data[i] * 77 + data[i + 1] * 150 + data[i + 2] * 29) >> 8; } return new ZXing.RGBLuminanceSource(out, width, height, true); }inversionAttempts: 'attemptBoth'是针对白底深色码的,比如有些电子票券是深色背景上印浅色二维码,不设这个参数永远扫不出来。代价是多跑一次检测,帧耗时增加 2ms 左右,可以接受。
TRY_HARDER是一维码场景的关键开关。条码天生细长,用户拿着手机扫的时候很少能摆得端端正正,倾斜十几度是常态。开启后 ZXing 会尝试更多扫描线角度,识别率明显提升,代价是单帧耗时翻倍。判断标准很简单:如果用户抱怨"要对得很准才能扫出来",就把它打开;如果抱怨"扫得太慢",就关掉并把MAX_WIDTH降一档。
MultiFormatReader每帧都new一个是有点浪费的,实际项目里可以把它提到循环外面复用,只重新setHints。插件在Scanner实例里缓存了一个 reader 实例,这里为了讲清逻辑才写成局部变量。
3.4 主循环:调度频率决定体验上限
循环本身不复杂,难点在"用什么节奏跑":
// src/scanner.js loop = () => { if (!this.running) return; // 页面切到后台就别扫了,白白耗电 if (document.hidden) { this.timer = setTimeout(this.loop, 300); return; } const t0 = performance.now(); const frame = grabFrame(this.video, this.canvas); if (frame) { const res = decodeOnce(frame, this.formats); if (res) { res.cost = Math.round(performance.now() - t0); this.emit(res); } } // interval > 0 走 setTimeout 限流,低端机推荐 60~100ms // interval = 0 走 rAF,跟着屏幕刷新,桌面端体验更好 if (this.interval > 0) { this.timer = setTimeout(this.loop, this.interval); } else { this.timer = requestAnimationFrame(this.loop); } };document.hidden这个判断看着不起眼,但很实用。用户扫码扫到一半接了个电话切走,页面在后台继续跑 rAF 的话,解码器还在满负荷工作,回到前台手机已经烫了,电量也掉了。切后台降频到 300ms 一次,既不彻底停(避免恢复时的额外开销),也不浪费算力。
interval和 rAF 二选一是个实际取舍。rAF 在桌面端是 60fps,看起来很流畅,但在安卓低端机上,解码耗时本身就有 30~50ms,rAF 排不上队,反而会造成掉帧和卡顿感。设成 80ms 的setTimeout虽然理论帧率低,但节奏稳定,用户体感更顺。这一段没有标准答案,得拿目标机型实测。
3.5 结果去重与资源回收
同一个二维码在视野里待三秒,如果不去重,回调会被触发几十次,表单被填几十遍,入库就是几十条重复记录:
// src/scanner.js emit(res) { const now = performance.now(); // 同一个文本在 dedupeMs 窗口内只报一次 if (res.text === this.lastText && now - this.lastAt < this.dedupeMs) { return; } this.lastText = res.text; this.lastAt = now; feedback(this.opts); // 震动 + 提示音 this.onResult(res); if (!this.continuous) this.stop(); // 单次模式,命中即停 } stop() { this.running = false; clearTimeout(this.timer); cancelAnimationFrame(this.timer); // 这一行不写,摄像头指示灯会一直亮着,用户以为被偷拍 this.stream?.getTracks().forEach(t => t.stop()); this.stream = null; this.video.srcObject = null; this.onStop?.(); }getTracks().forEach(t => t.stop())是必须写的。很多人只把srcObject置空,觉得视频不显示了就等于关了,实际上硬件采集还在跑,手机状态栏的绿点一直亮着,用户看到会很不安。这个坑在真机上第一眼就能发现,但在桌面浏览器上经常被忽略,因为很多笔记本摄像头没有物理指示灯。
4. 配置项与调参:把扫码器调成适合自己业务的形状
4.1 完整参数表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| container | string | Element | 必填 | 挂载容器,插件会往里插 video/canvas |
| formats | string[] | ['qr_code'] | 启用的码制,开得越多越慢 |
| facingMode | string | 'environment' | environment后置 /user前置 |
| resolution | {w,h} | 1280×720 | 请求分辨率,浏览器可能降级 |
| maxWidth | number | 640 | 解码工作宽度,直接影响识别距离 |
| interval | number | 0 | 0 用 rAF,>0 用毫秒节流 |
| continuous | boolean | false | 连续扫还是单次扫 |
| dedupeMs | number | 1500 | 同码去重窗口 |
| roi | {x,y,w,h} | 全画面 | 识别区域,取值为 0~1 的比例 |
| vibrate | boolean | true | 命中是否震动 |
| beep | boolean | true | 命中是否响提示音 |
| torch | boolean | false | 是否开启补光灯(需设备支持) |
| onResult | function | - | 识别成功回调,参数含 text/format/points/cost |
| onError | function | - | 错误回调,参数含 code/message |
这张表里真正需要动脑子调的是maxWidth、roi、interval三个,其余按业务语义配就行。
4.2 识别区域 ROI:条形码场景的必修课
二维码是方的,整幅画面丢进去找就行;条形码是细长的,四周全是无关内容,解码器要在整幅图里找那一小条,效率低还容易误判。合理做法是让用户把条码对准屏幕中间那条横带,只截取这一块送去解码:
// src/roi.js function cropROI(imageData, roi) { const x = Math.round(imageData.width * roi.x); const y = Math.round(imageData.height * roi.y); const w = Math.round(imageData.width * roi.w); const h = Math.round(imageData.height * roi.h); const out = new ImageData(w, h); for (let row = 0; row < h; row++) { const srcStart = ((y + row) * imageData.width + x) * 4; const dstStart = row * w * 4; out.data.set(imageData.data.subarray(srcStart, srcStart + w * 4), dstStart); } return out; } // 条形码推荐配置:横向占满,纵向只留中间 30% const BARCODE_ROI = { x: 0, y: 0.35, w: 1, h: 0.3 };裁剪之后像素量降到原来的三成,单帧耗时能省一半以上,同时因为排除了上下大片文字和图案的干扰,误识别率也明显下降。roi用 0~1 的比例而不是绝对像素,是为了让同一份配置在 720p 和 480p 的设备上都成立。
4.3 提示音与震动:不用加载音频文件
成功提示音最省事的做法是用 WebAudio 现场合成一个短音,省掉一个 mp3 请求,也不受自动播放策略限制(因为是由用户手势触发的扫码流程里播放的):
// src/feedback.js let audioCtx = null; function beep(freq = 1800, duration = 80) { // 复用同一个 AudioContext,反复 new 会耗尽资源 if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0.15, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration / 1000); osc.connect(gain).connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + duration / 1000); } function feedback(opts) { if (opts.vibrate && navigator.vibrate) navigator.vibrate(60); if (opts.beep) beep(); }1800Hz加80ms是试出来的组合,太低沉听不清,太长会拖慢连续扫的节奏。gain用exponentialRampToValueAtTime做淡出,避免播完出现"咔"的爆音。AudioContext一定要复用,每次new一个的话,连续扫几十次之后浏览器会报"too many AudioContexts"。iOS 上还要求第一次播放必须发生在用户手势的调用栈里,所以扫码按钮的点击事件里顺手audioCtx.resume()一次比较保险。
4.4 手电筒与前后摄切换
仓库、地下车库这类光线不足的场景,补光灯几乎是刚需。能力检测和调用分两步:
// src/camera.js async function toggleTorch(stream, on) { const track = stream.getVideoTracks()[0]; const caps = track.getCapabilities ? track.getCapabilities() : {}; if (!caps.torch) return false; // 设备不支持,静默失败即可 await track.applyConstraints({ advanced: [{ torch: on }] }); return true; } async function switchCamera(scanner, facing) { scanner.stop(); // 先停,否则两路流打架 scanner.opts.facingMode = facing; await scanner.start(); }getCapabilities不是所有浏览器都实现了,所以要判断存在性;applyConstraints的advanced写法是因为torch属于"高级约束",直接写在顶层会被忽略。切前后摄必须先把旧流停掉,不然在部分安卓机上会出现两路采集同时占用,新的getUserMedia直接卡住不返回。
5. 上线避坑排查:本地能跑、真机翻车的五个高频现场
5.1 上线前的三分钟自检
正式部署前把这几条过一遍,能挡掉大半问题。第一,页面是不是 HTTPS(或者 localhost)——getUserMedia在非安全上下文里直接不存在,navigator.mediaDevices会是undefined,控制台报错信息还很不直观。第二,vendor/下的两个库是不是真的加载成功了,有些项目用 CDN 引,内网环境下静默失败。第三,目标机型有没有实测过,Chrome 桌面能跑不代表微信内置浏览器能跑。
真机测试时建议打开插件的调试开关,把每帧耗时打到页面上。扫码卡不卡,很多时候不是解码算法的问题,而是某台设备抽帧就花了 40ms。看到cost稳定在 60ms 以上,就该考虑降maxWidth或者开roi了。
5.2 移动端差异对照
| 环境 | 主要差异 | 应对 |
|---|---|---|
| iOS Safari | 必须playsinline+muted,否则全屏播放或黑屏 | 属性写死,不依赖自动播放 |
| 微信内置浏览器 | video 会被提到顶层,盖住自绘 UI | 用 canvas 渲染预览,或加x5-video-player-type |
| 安卓低端机 | 解码耗时高,rAF 排不上队 | interval设 80~100ms |
| 部分国产浏览器 | getCapabilities缺失,手电筒无效 | 做特性检测,不支持就隐藏按钮 |
| 桌面 Chrome | 无后置摄像头,facingMode强约束会报错 | 用ideal,失败降级到默认设备 |
5.3 踩坑记录:现象、原因、解决
现象一:本地双击index.html用file://打开,点开始按钮毫无反应,控制台只有一行Cannot read properties of undefined。
原因:file://属于非安全上下文,navigator.mediaDevices直接不存在。这个报错位置在navigator.mediaDevices.getUserMedia,看起来像是代码写错了,实际是环境问题。
解决:起一个本地静态服务,用http://localhost:端口访问。要在手机上调试就得让手机访问开发机的 IP,这时候必须 HTTPS——局域网 IP 的 http 同样不算安全上下文。可以起一个带本地证书的开发服务器,把证书装到测试机上信任一次,之后一直能用。
现象二:iOS 上授权通过了,页面也显示"摄像头已开启",但视频区域一片黑。
原因:<video>缺playsinline和muted。Safari 的自动播放策略比桌面严格得多,没有muted的 video 不允许自动播放,没playsinline的会尝试进全屏播放器,嵌在页面里就是黑块。
解决:三个属性一起加——playsinline、muted、autoplay,并且在用户点击"开始扫码"之后显式await video.play()。不要在页面加载时就自动调play(),Safari 会拒绝。
现象三:用户抱怨"条码要对得很准、离得很近才能扫出来",同一张图上传识别却一次成功。
原因:实时链路的图像质量和上传图片差很远。摄像头自动对焦需要时间,用户举起来就扫,前几帧是虚的;再加上maxWidth缩到 640 之后,离得远时条码在画面里只占几十像素宽,一维码的条空比已经糊了。
解决:分两头改。一是把maxWidth提到 960 并开启TRY_HARDER,代价是单帧慢一点;二是在 UI 上给出明确引导——画一个固定的扫描框,文案写"请让条码填满框内",用户的姿势一对,识别率立刻上来。纯算法优化不如一句引导文案管用。
现象四:扫完退出扫码页,手机顶部摄像头指示灯还亮着,用户以为程序在后台偷拍。
原因:只调了video.pause()或者把srcObject置空,没有track.stop()。采集轨道还在跑,硬件就没释放。
解决:stop()里必须遍历stream.getTracks()逐个stop()。另外在pagehide和visibilitychange里也挂一次stop(),覆盖用户直接切走、按 Home 键、关标签页这些情况。这一条不做,应用商店审核和用户投诉都可能找上门。
现象五:同一个商品码扫一次,后端收到七八条入库记录。
原因:连续模式下没有去重,一个码在视野里待两秒,解码器每帧都能识别成功,回调就触发几十次。用户手速跟不上,看到的只是列表刷了一屏。
解决:加dedupeMs时间窗,同一个文本在窗口内只上报一次。另外把入库场景默认设成continuous: false,命中就停,用户主动再点一次才开始下一件商品,从流程上杜绝重复。去重窗口设 1500ms 是个经验值,太快会导致连扫两件相同商品时漏报,太慢又会重复。
6. 二次开发改造:把插件接进业务系统并验证效果
插件本身足够小,改造的三个方向基本覆盖了大多数需求。
第一个方向是封装成框架组件。以 Vue 为例,核心是把new HtmlScanner放进onMounted,把stop()放进onBeforeUnmount,其余交给 props 透传:
// ScanCode.vue import { onMounted, onBeforeUnmount, ref } from 'vue'; export default { props: { formats: { type: Array, default: () => ['qr_code', 'code_128'] }, continuous: { type: Boolean, default: false } }, emits: ['result', 'error'], setup(props, { emit }) { const box = ref(null); let scanner = null; onMounted(() => { scanner = new HtmlScanner(box.value, { formats: props.formats, continuous: props.continuous, onResult: (res) => emit('result', res), onError: (err) => emit('error', err) }); scanner.start(); }); // 组件卸载不停流,路由切走后摄像头灯还会亮着 onBeforeUnmount(() => scanner && scanner.stop()); return { box }; } };React 里对应的是useEffect返回清理函数,逻辑完全一样。这里最容易漏的就是清理,SPA 里边切路由边扫码,不写stop()的话切几次之后浏览器就开始报设备占用。
第二个方向是加"从相册选图识别"。很多场景用户手里已经有码的照片,重新对着屏幕拍反而糊。实现上复用decodeOnce就行:input[type=file]拿到 File,createImageBitmap解码成位图,画到 canvas,取ImageData,直接调用解码函数,不需要开摄像头。这段代码顺便还能当作扫码不成功时的兜底手段——用户扫不出来就让他拍一张,成功率往往更高。
第三个方向是加扫码历史与批量模式。连续模式下把结果 push 进数组,页面上渲染成列表,支持删行和批量提交。这里要注意给每条记录带上时间戳和码制格式,后期对账时没有格式信息很难排查是哪一类码出了问题。
验证方法上,我一般不看识别率百分比,而是看两个具体数字:单帧cost和"从举起到命中"的秒数。前者超过 60ms 就调参数(降maxWidth、收窄roi、关掉不用的码制),后者超过 3 秒就改交互(加扫描框、改引导文案、开手电筒)。测试用的码也别只用自己生成的标准二维码,打印一张皱巴巴的物流面单、贴上透明胶带、在暗光下扫,才是真实场景。
从那以后我每次接扫码类的活,第一步都是先拿目标机型把getUserMedia和track.stop()这两件事跑通,再谈解码和 UI——因为这两步不通,后面写得再漂亮都是白搭。希望帮到你。
本文还有配套的精品资源,点击获取