ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue扫码枪输入方案:HID键盘、全局监听与串口模式

Vue扫码枪输入方案:HID键盘、全局监听与串口模式 凌晨两点在仓库对账一批货扫了两百多单结果发现有十几条单号末尾少了一位数字。排查了半天代码最后发现是扫码枪扫得太快前端用input配 300ms 防抖把同一串字符拆成了两次触发。这个坑说大不大但如果你没意识到扫码枪在浏览器眼里到底是什么东西它就会反复咬你。这篇文章讲的是在 Vue 项目里怎么把扫码枪或扫描仪扫出来的数据稳稳当当地拿到input框里。核心不是某个 API 的用法而是搞清楚扫码枪的输入本质之后怎么设计一套不会丢字符、不会被人手输入误触发、能覆盖多输入框场景的方案。不管你是刚入门 Vue、只会写v-model的新手还是做过几年项目、被扫码场景反复折磨过的老兵下面这些内容应该都能直接拿去用或者少走几步弯路。1. 扫码枪在浏览器眼里只是打字很快的键盘想解决问题先得扔掉一个错误预期浏览器和 Vue 根本不知道你手里拿的是一把扫码枪。对操作系统和浏览器来说绝大多数扫码枪就是一个 HID 键盘设备它做的事情就是替我敲键盘。你听到的滴一声本质上是一串字符被极快地灌进了当前焦点所在的输入目标里。1.1 HID键盘模式字符流与结束符的真相大部分扫码枪出厂默认就是键盘模式也叫 HID 模式扫到条码后它会把这串字符对应的按键码逐字发送给操作系统。比如扫到SN20240516A001系统收到的就是一串连续的keydown、keypress事件最后再补一个回车或者 Tab。整个过程从几十毫秒到一两百毫秒不等快到你肉眼几乎看不到字符一个个蹦出来。这里有两个容易被忽略的事实。第一扫码枪发送的是按键不是文本。这意味着它经过的是操作系统的键盘布局和输入法那一层。如果你的电脑开着中文输入法并且处于中文状态扫码枪发过来的英文字母和数字很可能被输入法截胡变成候选词或者干脆丢失。第二扫码枪的结束是靠一个约定好的字符来标记的通常是回车Enter、Tab也可能是自定义的CR、LF、CRLF。这个结束符对前端判断一次完整扫码结束了没有至关重要。提示判断一次扫码是否结束靠的不是字符间隔多长而是那把枪有没有传出你预设的结束符。时间间隔只是辅助判断手段主判据永远是结束符。我在项目里遇到过一款枪出厂设置结束符是 Tab。前端一直监听回车结果扫了半天啥都不触发。后来用记事本扫了一下看到光标跳到下一个格才反应过来它发的是 Tab。所以调试扫码枪的第一步永远是先开一个记事本或者浏览器的空白输入框扫一枪看看它到底吐出来什么东西、以什么结尾。1.2 直接绑input为什么会丢数据新手最容易写出来的代码是这样input v-modelcode inputhandle /然后在handle里加个防抖想着人打字会有停顿防抖一下就能拿到完整串。这个思路在扫码枪面前基本是失效的。原因很直接。防抖的逻辑是停止输入一段时间后才执行可扫码枪的字符间隔可能只有 5 到 20 毫秒你要是把防抖设成 300 毫秒第一次扫码确实能攒成完整串。但如果用户连续扫两枪两枪之间只隔了 200 毫秒防抖就会把两枪的数据合并成一串或者干脆触发两次但每串都不完整。更麻烦的是人手输入的干扰用户在输入框里手动敲了几个字符停顿超过防抖时间触发了业务逻辑然后扫码枪又扫进来一串两者混在一起。还有一个更隐蔽的丢数据场景输入框没聚焦。扫码枪往系统里灌字符但当前焦点不在你的 input 上字符全被灌到了别的地方比如地址栏、别的组件、或者直接丢了。用户反馈我扫了没反应其实是他切换了页面或者点了别处焦点丢了。所以如果你打算依赖某个 input 的焦点就必须处理焦点何时丢失、如何找回这一整套逻辑否则线上一定出问题。我在一个收银项目里见过最省事的写法整个页面就一个常驻的隐藏 input靠autofocus和页面点击时手动.focus()来维持焦点。这个方案能用但一旦页面里有别的输入框比如手动改数量扫码就被抢走。后面会讲更稳的做法。2. 用时间差把人手输入和机器扫码分开既然输入框和扫码枪在事件层面没区别那这一串字符是人敲的还是机器扫的就成了核心命题。业内最常见的判断依据就是机器扫的字符间隔极短且均匀人敲的间隔大且忽快忽慢。围绕这个特征可以设计一套相当稳的判定逻辑。2.1 相邻keydown的时间戳判定法核心思路是给每个字符记录它到达的时间戳一旦相邻两个字符的时间差超过阈值就把之前的缓冲清空认为这是一次新的输入。等到结束符回车到达时如果缓冲区里还有内容就判定为一次有效扫码。let buffer let lastTime 0 const THRESHOLD 30 // 毫秒 function onKeydown(e) { const now performance.now() // 距离上一次按键超过阈值说明是新一轮输入清空缓冲 if (now - lastTime THRESHOLD) { buffer } lastTime now if (e.key Enter) { if (buffer) { handleScan(buffer) // 一次完整扫码 } buffer e.preventDefault() // 拦掉回车避免触发其他默认行为 return } // 只收集可见字符忽略 Shift、CapsLock 等功能键 if (e.key.length 1) { buffer e.key } }这里有几个细节值得掰开说。用performance.now()而不是Date.now()是因为前者精度是亚毫秒级后者在某些环境下精度只有毫秒甚至更低对判断几十毫秒的间隔不够准。e.key.length 1这个判断用来过滤功能键因为 Shift、Control、CapsLock 这些键的key值是类似Shift这样的多字符串而字母数字的key长度都是 1。用e.key而不是e.keyCode是因为keyCode已经废弃而且在大小写、符号的处理上很容易出岔子。还有一点拦截回车用e.preventDefault()很关键。否则回车可能会触发表单提交、按钮点击或者输入框的默认换行行为导致整个页面跳走。我做第一个扫码项目时就忘了这茬扫码一枪页面直接提交表单刷新了排查了半天才定位到是回车事件。2.2 阈值定在多少不同型号的实测节奏阈值这个数字没有绝对标准得看你的枪和现场环境。太大会把两次快速连扫合并太小会把一次扫码拆成两段。我一般先设 30ms然后在真实设备上测。粗糙的做法是在 keydown 里把时间差打出来扫几枪看数据分布。你会发现很典型的两个区间机器扫码的字符间隔大多在 3 到 20ms 之间而人手动打字通常大于 60ms。所以阈值取 30 到 50ms 之间基本能把两者分开。有些老枪或者蓝牙枪因为传输延迟间隔能到 40ms 甚至更多那就得往上调。设备类型典型字符间隔建议阈值备注有线 USB 扫码枪3~15ms30ms最常见稳定蓝牙扫码枪20~45ms50ms延迟波动大老旧枪/低端枪15~40ms40ms建议实测人手键盘输入60ms 以上不适用会被阈值切分注意不要只用时间间隔判断是不是扫码。有些人打字飞快也会误触发。最稳的组合是结束符 时间间隔 长度校验三重判断长度校验比如单号至少 8 位、符合某种格式。有个容易踩的坑是蓝牙枪的延迟抖动。同一把蓝牙枪信号好的时候间隔 20ms信号差的时候能飙到 60ms这时候阈值设小了就会把一串单号切成两段。我的处理办法是把阈值稍微放宽到 50ms同时要求必须以结束符结尾才算数这样即使中间被误判切分最后也能靠结束符和长度校验兜底。3. 能在Vue里直接抄的全局扫码监听实现搞清楚了判定逻辑接下来就是怎么在 Vue 里搭一套干净、可复用、不干扰正常交互的实现。我推荐的方式是全局监听window的键盘事件而不是把逻辑绑死在某个input上再用组合式函数把它封装起来。3.1 监听window代替聚焦input的理由为什么监听window而不是某个输入框核心原因是避免焦点问题。扫码枪往哪灌字符取决于当前焦点在哪。如果焦点在你的 input 上绑input可以工作但你会面临焦点保持、多个输入框抢焦点、用户误点导致失焦等一系列问题。而window上的keydown事件是全局的只要页面在前台、焦点在页面内扫码枪的字符就一定流经window不管焦点具体在哪个元素上。这个方案有一个副作用要提前想清楚它会把用户在页面上的所有键盘输入都捕获。所以你必须能区分这次输入是扫码还是用户在某个输入框里正常打字。这正是上一节那套时间差判定的用武之地。如果用户在一个搜索框里慢慢打字字符间隔远大于阈值缓冲会被不断清空最后不会触发扫码逻辑只有一秒钟内灌进来十几二十个字符并回车才会被识别为扫码。还有一种折中方案监听window但先判断当前活动元素document.activeElement是不是某个允许触发扫码的输入框。这个思路适合页面里有多个业务输入框只有其中一个才接受扫码的场景。具体的取舍下面讲焦点分流时会展开。3.2 封装成组合式函数的完整代码把它封装成useScanner组合式函数逻辑和视图解耦哪个组件要用直接 import 就行。下面是我项目里实际用的一版精简实现。// composables/useScanner.js import { onMounted, onUnmounted } from vue export function useScanner(options {}) { const { threshold 30, // 字符间隔阈值毫秒 endKeys [Enter], // 结束符可能有多种 minLength 1, // 有效扫码的最小长度 filter, // 可选清洗函数 (raw) cleaned onScan, // 扫码成功回调 onError, // 无效数据回调 } options let buffer let lastTime 0 function handleKeydown(e) { const now performance.now() if (now - lastTime threshold) { buffer } lastTime now if (endKeys.includes(e.key)) { e.preventDefault() e.stopPropagation() if (buffer.length minLength) { const raw buffer const result filter ? filter(raw) : raw if (result) { onScan onScan(result, raw) } else { onError onError(raw, filtered) } } else if (buffer.length 0) { onError onError(buffer, too-short) } buffer return } if (e.key.length 1) { buffer e.key } } onMounted(() { window.addEventListener(keydown, handleKeydown, true) }) onUnmounted(() { window.removeEventListener(keydown, handleKeydown, true) }) }用的时候就是这样import { useScanner } from /composables/useScanner useScanner({ threshold: 30, minLength: 6, onScan: (code) { form.value.orderNo code // 拿到结果后自动聚焦下一个输入框或者直接提交 }, })有几个点值得强调。事件监听用第三参数true也就是捕获阶段监听。这样即使某个子组件在冒泡阶段调了stopPropagation你的全局监听依然能收到。反过来在结束符处理里我们自己也stopPropagation避免回车同时触发别的逻辑。filter这个口子是留给数据清洗的。扫码枪有时会带上前后缀比如有些枪默认会在条码前加个字符作为键盘布局前缀。filter里可以统一做清洗和校验返回 falsy 就把这次数据判为无效。onMounted和onUnmounted里记得成对地加和移除监听否则组件销毁后监听还在会引发内存泄漏和重复触发。这个错误在单页应用里特别典型路由切走了旧组件的扫码逻辑还在跑扫一枪触发两次回调。3.3 多个输入框与多个扫码设备的焦点分流真实业务里很少有整个页面只有一个输入框这么理想的情况。更常见的是表单里有七八个字段只有其中一两个接受扫码其他都是手输。这时候全局监听就要配一套焦点分流逻辑。我的做法是给需要接收扫码的输入框加一个统一的数据属性比如>function shouldHandle() { const el document.activeElement if (!el) return true const acceptAny document.querySelector([data-scan-accept-any]) if (acceptAny) return true return el.matches([data-scan-targettrue]) }如果当前焦点在一个手输字段上就跳过扫码处理让用户正常输入如果焦点在扫码字段或者页面上没有焦点body就按扫码逻辑走。这样既能保证扫码功能又不会把用户在数量框里敲的数字误判成扫码。如果业务是扫一枪自动跳转到下一个待填字段这种流程有点像快递分拣那种连续扫描那就更简单每次扫码成功后用一个字段顺序数组决定下一个焦点落到哪主动调nextInput.value.focus()。这样用户可以完全不碰鼠标一把枪从头扫到尾。4. 上线后才暴露的那些坑前面讲的都是能在开发阶段跑通的逻辑但真正让你加班到深夜的往往是那些只有在真实用户、真实系统、真实设备环境下才会冒出来的问题。这一节把几个我亲身踩过、并且被反复问到的坑整理出来。4.1 中文输入法把扫码内容吃掉这是最经典、也最容易被忽略的一个。前面说过扫码枪走的是键盘布局这一层如果你的系统开着中文输入法并且处于中文状态扫码枪发来的字母数字会被输入法当作拼音吞掉弹出候选词你的keydown监听拿到的可能是乱码或者干脆收不到。这个问题在开发机上不一定复现因为开发同学往往默认是英文输入法。但用户机器上不一定。解决方案有几条路让用户扫码前切到英文输入法但这是把责任甩给用户不靠谱。监听compositionstart和compositionend输入法组合期间的字符特殊处理。但扫码枪的输入太快经常在输入法还没反应完就结束了处理起来很别扭。更彻底的做法是在页面层面屏蔽输入法对扫码的干扰比如用一个始终处于英文状态的隐藏输入框专门接收扫码用户可见的输入框只做展示。这个方案最稳代价是要额外维护焦点同步。我个人更倾向于第三条路尤其是表单复杂、输入框多的场景。专门一个隐藏的input负责接收所有扫码字符扫完把结果写回对应的业务字段然后清空这个隐藏框。因为它是readonly之外的纯英文接收器不受输入法影响。4.2 前后缀与校验位的正则清洗扫码枪扫出来的原始字符经常不是你想要的纯净单号。它可能带前后缀、带不可见控制字符、带校验位或者因为键盘布局转换把某些符号弄乱了。我见过最离谱的是同一个条码在 A 电脑上扫出来是纯数字在 B 电脑上扫出来末尾多了个\r因为两把枪的结束符设置不同。清洗逻辑我一般写成可配置的管道function cleanScan(raw) { let s raw // 去掉不可见控制字符 s s.replace(/[\x00-\x1F\x7F]/g, ) // 去掉常见的扫码枪前缀比如某些型号的前导字符 s s.replace(/^\]C1/, ) // 只保留业务需要的字符集比如单号是字母数字 s s.replace(/[^A-Za-z0-9-]/g, ) // 校验长度 if (s.length 6 || s.length 32) return return s.toUpperCase() }正则这块有两个经验。一是不要写得太贪婪比如把-也过滤掉结果单号里本来就有的分隔符丢了。二是清洗后一定要做长度和格式校验宁可判为无效让用户重扫也不要把脏数据写进业务字段。脏数据一旦入库清理成本远高于当场重扫。4.3 焦点被自动填充、弹窗、快捷键抢走全局监听虽然不依赖焦点但如果你用的是聚焦input的方案那焦点问题就是无穷无尽的坑。浏览器密码管理器会在页面加载时自动聚焦到账号密码框某些弹窗、通知、或者第三方组件会在弹出时把焦点拿过去用户按了 Tab 键焦点跑到别的元素上甚至用户点了页面上的空白区域document.activeElement就变成了body。我遇到过最阴间的一个 bug扫码成功后弹了一个操作成功的提示框提示框自动获取焦点用户接着扫第二枪字符全进了提示框业务完全收不到。这类问题的解法只有两个方向要么用全局监听彻底摆脱焦点依赖要么在每次扫码结束后主动把焦点交还给目标输入框并在弹窗关闭、页面切换时重新同步焦点。前者更省心后者适合对焦点行为有强控制的复杂表单。4.4 结束符不只有回车一种前面反复提过结束符这里再补一层。扫码枪的结束符设置可以有很多种无结束符、回车CR、换行LF、回车换行CRLF、Tab甚至是某个自定义字符。而且不同品牌、不同型号的默认值不一样有的还跟固件版本有关。代码里千万不能只硬编码判断回车。写成可配置数组把常见的几种都覆盖上const END_KEYS [Enter, Tab]如果现场设备实在特殊结束符是个你没法用e.key直接匹配的字符比如某些枪发的是\x03那你只能退回到全局监听字符流在缓冲里检测特定字符。这也是为什么第 3 节的实现里我把endKeys设计成可配置的而不是写死。提示调试结束符最快的办法是打开浏览器的控制台在keydown里console.log(e.key, e.keyCode)扫一枪看清楚每一个键是什么。这一步能省掉后面大量的猜测。5. 不想受键盘模式限制串口与蓝牙的替代路线键盘模式虽然通用但它有个绕不过去的缺陷它和普通键盘共享同一条输入通道所以你必须在是不是扫码这个判断上做文章而且永远躲不开输入法、焦点这些干扰。如果你的场景对稳定性要求极高可以考虑让扫码枪切到串口模式用浏览器直接读取设备数据。5.1 Web Serial API 直读串口模式扫码枪现在主流浏览器已经支持 Web Serial API可以直接和串口设备通信。把扫码枪设置成串口模式很多工业枪支持比如切换成 USB 虚拟串口或者 RS232 转 USB然后在 Vue 里通过navigator.serial打开端口读取数据。async function connectSerial() { const port await navigator.serial.requestPort() await port.open({ baudRate: 9600 }) const reader port.readable.getReader() const decoder new TextDecoder() while (true) { const { value, done } await reader.read() if (done) break const text decoder.decode(value) // text 就是这次扫码的原始数据 handleScan(text) } }串口模式最大的好处是数据干净且独立。它不经过键盘布局和输入法不存在焦点问题一个扫码枪对应一个端口物理上就不可能和键盘输入混淆。波特率要和枪的设置对上常见的还有 115200、38400设置错了读出来就是乱码。代价也有。第一首次使用需要用户授权选择设备弹一个浏览器权限框体验上多一步。第二串口模式和键盘模式通常不能同时用切了模式就得重新配置。第三部署环境要对浏览器版本有要求。所以我一般把串口模式留给扫码是核心业务、量大、要求零误判的场景普通场景用全局键盘监听就够了。5.2 键盘模式与串口模式的取舍清单到底选哪种把下面这张表对着自己的场景过一遍基本就有答案了。维度键盘模式串口模式兼容性几乎全平台即插即用依赖浏览器 Web Serial 支持数据纯净度会受输入法、焦点影响完全独立无干扰部署复杂度低插上就能用需配置设备和权限判断是人是机需要时间差算法不需要天然区分适合场景表单录入、后台管理高频扫描、工控现场我的建议是后台管理系统、ERP、表单类项目优先用全局键盘监听加时间差判定成本低、见效快如果是生产线、分拣线这种一天扫上万次、错一次就是损失的场景认真考虑串口模式前期的配置成本会用稳定性还回来。聊到扫码枪初始化设置顺带说一句很多问题的根不在代码而在枪本身。结束符、大小写、前后缀、键盘布局这些都在枪的设置里调厂商给的设置手册扫码配置条码有时候比改十行代码管用。遇到奇怪现象记不住教训用记事本扫一枪看原始输出永远是排查的第一步。回到最开始那个凌晨对账的坑后来的修复方案其实很简单去掉input上的防抖改成全局监听加 30ms 时间差加回车结束符三重判断问题当场消失。真正值钱的经验不是那几行代码而是那句扫码枪只是个打字很快的键盘——想明白这一点后面所有的设计取舍都顺了。如果你现在手里正好有个扫码相关的需求先把记事本打开扫一枪看看它到底吐了什么出来比读十篇文档都有用。
返回列表