ARTICLE DETAIL

资讯详情

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

Vue项目扫码枪对接:HID键盘输入与全局监听实战

Vue项目扫码枪对接:HID键盘输入与全局监听实战 扫码枪这东西看着简单真接到 Vue 项目里才发现坑一个接一个焦点丢了、回车吞了、数据被截成两段、中文输入法弹出候选框把光标的输入拦下来……我前后在仓储、门店、产线三个不同类型的项目里都接过扫码枪踩过的坑基本能凑成一本小册子。这篇就把 Vue 项目里对接扫码枪、把条码数据稳定送进 input 框这件事从头到尾讲清楚——不管你是刚学 Vue 的新手还是做过几个项目但一直用keyup.enter硬扛的老手都能从里面找到能直接抄的代码和能省半天工时的思路。先说结论性的判断绝大多数扫码枪在默认状态下就是一个打字特别快的键盘它不装驱动、不连串口扫一下就把条码字符逐个敲出来末尾再补一个回车。理解这一点后面所有方案都是围绕怎么在 Vue 里稳稳接住这一串高速键盘输入展开的。1. 先搞清楚扫码枪到底往电脑里送了什么1.1 HID 键盘模式你遇到的 95% 都是它USB 扫码枪插上电脑不装任何驱动就能用扫码时光标所在的位置会直接出现一串字符——这说明它枚举成了一个 HID 键盘设备。厂商在这个小小的枪体里塞了一块芯片把解码出来的条码内容按键盘码表逐个上报给操作系统操作系统再当作普通按键分发给当前焦点的窗口。这里有两个细节决定了后面的技术方案。第一它是逐个字符上报的不是一次性丢一个字符串过来第二每个字符之间的间隔极短通常在 5 到 30 毫秒之间而人手打字再快也在 80 毫秒以上。这个时间差就是我们在 Vue 里区分人在打字和枪在扫码的唯一可靠依据。很多新手会问扫码枪扫出来的数据能不能直接被某个 API 拿到不能。它走的是系统输入栈浏览器层面看到的和用户手敲键盘完全一样你没有办法从事件对象里判断这是不是扫码枪发的。所以一切识别逻辑本质上都是行为识别而不是设备识别。1.2 串口模式、虚拟串口和蓝牙 SPP 的区别扫码枪不是只有 HID 一种输出方式。翻开说明书霍尼韦尔、新大陆、民德这类常见品牌的设置手册都类似你会看到几个模式USB HID 键盘、USB 虚拟串口VCP、RS232 真串口、蓝牙 HID、蓝牙 SPP。USB 虚拟串口模式下扫码枪在系统里表现为一个 COM 口Windows或/dev/ttyACM0之类的设备节点Linux数据以字节流形式进来需要程序主动去读。它的好处是能做设备级区分——你有三把枪接一台机器HID 模式下根本分不清是谁扫的串口模式下每个口对应一把枪天然隔离。坏处是前端拿不到要么用 Electron / Node 起个本地服务读串口再转发给页面要么用浏览器原生的 Web Serial API。Web Serial API 现在 Chrome 89 以上、Edge 89 以上都支持但有个硬性前提页面必须是 HTTPS 或者 localhost。我实测过一个门店项目局域网用 HTTP 访问的时候navigator.serial直接是 undefined换成自签 HTTPS 后立刻正常。读取逻辑大概是这样async function openSerialScanner() { if (!(serial in navigator)) { throw new Error(当前浏览器不支持 Web Serial) } const port await navigator.serial.requestPort() // 扫码枪串口模式默认基本都是 9600 8N1 await port.open({ baudRate: 9600, dataBits: 8, stopBits: 1, parity: none }) const reader port.readable.getReader() const decoder new TextDecoder() while (true) { const { value, done } await reader.read() if (done) break // 串口模式一般不会自动补回车需要自己按协议切分 console.log(decoder.decode(value)) } }需要说明的是串口模式下的结束符要看枪的配置有的配了 CR有的配了 CRLF有的什么都不配——这就是为什么用串口方案时经常会遇到两条数据粘在一起的问题必须在应用层做帧切分。1.3 为什么大部分 Vue 项目还是老老实实用 HID答案很现实改造成本。HID 模式零配置、零依赖、跨平台插上就能跑串口模式要配驱动、要处理权限、要处理端口被占用在浏览器里还要考虑 HTTPS。所以除非你有明确的多枪隔离或者必须拿到原始字节流的需求否则 HID 键盘模式是最优解。我一般给团队的建议是这样先按 HID 做一个能跑通的版本把焦点管理、结束符识别、数据校验这三个核心问题解决掉等到真的出现多设备冲突或者需要防止误输入的场景再考虑切串口。不要一上来就上串口那会把一个两小时的需求拖成两天。2. 三种接收方案怎么选不踩坑2.1 方案一单 input 聚焦接收最省事也最脆最直觉的做法是页面上放一个 input加载时自动聚焦扫码枪扫完数据自然就进去了回车触发提交。代码看着非常干净template input refscanRef v-model.trimcode placeholder请扫码 keyup.entersubmit / /template script setup import { ref, onMounted } from vue const scanRef ref(null) const code ref() onMounted(() { scanRef.value?.focus() }) function submit() { if (!code.value) return console.log(拿到条码:, code.value) code.value // 提交完立刻把焦点抢回来否则下一次扫码就丢了 scanRef.value?.focus() } /script这方案的致命伤在于焦点两个字。用户随手点了页面任何一处焦点就没了弹一个确认框焦点也没了路由跳转再回来焦点还是没了。所以它只适合那种整页只有一个输入框、用户不会碰别的地方的极简场景比如扫码登录页。如果一定要用这个方案必须加个兜底监听 blur 后延迟重新聚焦。但这里有个反噬——用户如果想点旁边的按钮你会把焦点抢回来导致按钮点不动。所以正确的做法是加一个扫描中的状态开关只在开启时才抢焦点const scanning ref(true) function onBlur() { if (!scanning.value) return setTimeout(() scanRef.value?.focus(), 0) }2.2 方案二全局 keydown 监听实战中最稳真正在生产环境里跑得住的方案是在 document 上挂全局 keydown 监听用时间间隔把扫码枪的输入从用户的正常打字里捞出来。这样子页面根本不关心焦点在哪儿用户点哪里都不影响扫码输入框只是个展示结果的地方。核心判断逻辑就三条相邻字符间隔小于阈值我一般设 50ms、累积长度超过最小值、遇到结束符或者超时就把 buffer 提交出去。后面第 3 节我会给出完整的组合式函数实现。这个方案唯一的代价是要小心误伤——用户正常打字的时候不能被当成扫码。所以阈值的选择很关键设太松会误判设太紧会漏掉。50ms 这个数字是我在三种不同型号的枪上测下来比较稳的中间值具体怎么调后面会讲。2.3 方案三前后缀 时间窗口多设备复杂场景如果现场有多把扫码枪或者要跟人手的键盘输入共存可以考虑用前缀识别。扫码枪通常支持配置自定义前缀Prefix和后缀Suffix比如让仓库那把枪扫出来的数据前面带WH-门店那把带ST-。前端收到输入后先看前缀匹配不上就直接丢弃。配置前缀这件事需要在纸质的设置手册上找到对应的设置条码用枪依次扫一遍常见的顺序是进入设置 → 扫描前缀设置条码 → 扫描要作为前缀的字符条码 → 保存退出。不同品牌的流程不一样霍尼韦尔的通常在手册的User Guide - Prefix/Suffix章节。这里必须提醒一句改配置之前一定记下原始状态很多枪恢复出厂设置也要扫专门的条码一旦把某个关键配置扫乱了没有手册在手会非常麻烦。下面是三种方案的横向对比你可以按现场情况直接对号入座对比项单 input 聚焦全局 keydown前后缀 时间窗口实现难度极低中等中等偏高焦点依赖强依赖无无能否区分人手输入不能能靠速度能靠前缀多枪区分不能部分能能适用场景扫码登录、单输入页收银、拣货、报工多工位共用终端主要风险焦点丢失导致漏扫阈值设置不当误判配置复杂、易被扫乱3. 手写一个扫码枪组合式函数3.1 设计的核心用时间间隔给人手和枪划一条线整个方案的心脏就是间隔判定。扫码枪的字符间隔在 5 到 30ms人手在 80ms 以上两者中间有一段很宽的安全区。把阈值设在 50ms理论上既不会把人的输入误判成扫码也不会把枪的输入漏掉。但实际项目里我还加了两层保险。第一层是最小长度太短的输入比如 2 个字符即便速度再快也不当作扫码结果第二层是结束判定优先看有没有结束符回车或 Tab没有的话用超时兜底——最后一个字符进来之后 80ms 内没有新字符就认为一次扫描结束了。这样做的好处是兼容性极强有的枪配了回车后缀有的没有有的枪扫完会补一个 Tab有的只补 CR。不管哪种逻辑都能接住。3.2 完整代码实现下面这个useScanner我在两个项目里直接用了稍微改改就能搬// composables/useScanner.js import { onMounted, onBeforeUnmount } from vue export function useScanner(options {}) { const { maxInterval 50, // 相邻字符最大间隔(ms)超过则视为人手输入 minLength 3, // 有效条码最短长度 endTimeout 80, // 结束超时(ms) onScan () {}, // 扫描完成回调 } options let buffer let lastTime 0 let timer null function reset() { buffer lastTime 0 if (timer) { clearTimeout(timer) timer null } } function flush() { const code buffer.trim() reset() if (code.length minLength) { onScan(code) } } function handleKeydown(e) { const now Date.now() // 结束符回车 / Tab两者是扫码枪最常用的后缀 if (e.key Enter || e.key Tab) { if (buffer now - lastTime maxInterval * 3) { // 阻止回车触发表单默认提交、Tab 切焦点 e.preventDefault() e.stopPropagation() flush() } return } // 只处理可打印字符忽略 Shift / Ctrl / 方向键等 if (e.key.length ! 1) return // 间隔过大说明不是同一轮扫描直接重开 if (now - lastTime maxInterval) { buffer } buffer e.key lastTime now // 超时兜底没有结束符的枪靠这个收尾 if (timer) clearTimeout(timer) timer setTimeout(flush, endTimeout) } onMounted(() { // 第三个参数 true 表示捕获阶段避免被子元素 stopPropagation 拦掉 document.addEventListener(keydown, handleKeydown, true) }) onBeforeUnmount(() { document.removeEventListener(keydown, handleKeydown, true) reset() }) }几个容易忽略的点addEventListener的第三个参数用了true走捕获阶段。原因是有时候页面上某个第三方组件在 keydown 里做了stopPropagation冒泡阶段就收不到了走捕获能绕开。另外flush里先reset再回调是为了防止回调里又触发扫码导致状态串。如果你还在 Vue 2把onMounted / onBeforeUnmount换成mounted / beforeDestroy就行逻辑一行都不用改。3.3 在组件里接入并处理业务拿报工场景举例扫一个工单号扫完去后端查工单信息template div classscan-panel div classscan-box span classlabel当前工单/span span classvalue{{ currentCode || 等待扫码 }}/span /div p v-iferrorMsg classerror{{ errorMsg }}/p /div /template script setup import { ref } from vue import { useScanner } from /composables/useScanner const currentCode ref() const errorMsg ref() let lastScan let lastScanTime 0 useScanner({ minLength: 6, onScan(code) { const now Date.now() // 同一张码 300ms 内重复上报直接忽略防抖 if (code lastScan now - lastScanTime 300) return lastScan code lastScanTime now errorMsg.value if (!/^WO\d{8}$/.test(code)) { errorMsg.value 工单号格式不对${code} return } currentCode.value code fetchWorkOrder(code) }, }) async function fetchWorkOrder(code) { // 具体请求逻辑按你项目里的封装来 } /script这里我特意加了防重复逻辑。原因是有部分扫码枪在连续扫描或者按键抖动时会把同一条码上报两次表现为页面闪一下两次。300ms 的时间窗过滤掉重复上报比在服务端做幂等要轻量得多。3.4 正则过滤与数据清洗扫码枪出来的原始数据不总是干净的。有些型号会带上不可见字符有些在 HID 和串口之间切换后会遗留\r\n还有的会因为键盘布局问题把某些符号映射错。所以进业务逻辑之前先过一层清洗function normalize(raw) { return raw // 去掉所有控制字符0x00-0x1F 和 0x7F .replace(/[\u0000-\u001F\u007F]/g, ) // 去掉首尾空白 .trim() } const PATTERNS { // EAN-1313 位纯数字 ean13: /^\d{13}$/, // Code128可打印 ASCII长度 1-48 code128: /^[\x20-\x7E]{1,48}$/, // 自定义工单号 workOrder: /^WO\d{8}$/, } function matchType(code) { for (const [type, re] of Object.entries(PATTERNS)) { if (re.test(code)) return type } return null }这里有个真实教训我最早用/^[A-Za-z0-9]$/做校验结果现场有一批条码里带了短横线-全部被判定为非法。后来改成先清洗、再按类型匹配只有明确知道格式的场景才做严格校验其他一律放行。校验的目的是拦住脏数据不是拦住业务。4. 用 input 框展示结果时的几个细节坑4.1 v-model 与原生事件的关系很多人用 input 的时候会同时写v-model和keyup.enter然后发现回车触发提交时v-model里的值有时候还差最后一个字符。原因是v-model在 input 元素上绑定的是input事件而扫码枪的每个字符都会触发一次 input 事件回车本身不产生 input 事件所以正常情况不会有问题——但如果你的枪后缀配的是 TabTab 会先让 input 失焦某些浏览器下最后一次 input 事件会延迟就出现了少一个字符。稳妥的做法是不要在提交逻辑里依赖v-model的实时值而是把值在input里手动同步出来或者干脆用第 3 节的全局监听方案绕开 input 事件这套机制。另外提醒一句如果 input 外层包了form回车会触发浏览器默认提交行为、导致页面刷新必须加submit.prevent。4.2 中文输入法激活时的干扰这是国内项目特别容易撞上的问题。当 input 处于中文输入法激活状态时键盘事件会被输入法先截获keydown 的e.key可能变成ProcesskeyCode是 229——这意味着扫码枪的字符在到达你的逻辑之前就被输入法吃掉了一部分。处理办法有三个层次。最省事的是在页面上提示用户扫码时请切换到英文输入状态这在工位机上很常见。第二层是用compositionstart / compositionend事件标记输入法状态在组合期间暂停扫码累积let composing false input.addEventListener(compositionstart, () { composing true }) input.addEventListener(compositionend, () { composing false }) // 在 keydown 处理里加上 if (composing) return第三层最彻底给 input 加inputmodenone或者直接在移动端用 readonly 输入框接收把软键盘彻底关掉。在 PDA 或者安卓工控机上软键盘弹出来遮挡半个屏幕是家常便饭用 readonly 加手动聚焦是最稳的input v-modelcode readonly clickfocusHidden /4.3 焦点管理别让输入写到浏览器地址栏里全局监听方案有个隐患如果你的页面没有焦点在任何输入元素上扫码枪的字符会打到 document 上正常情况没事但如果用户之前点过浏览器地址栏再回来焦点在地址栏那这一串字符就直接进了地址栏你的页面什么都收不到。所以工程上我一般会加一个常驻的幽灵输入框一个视觉上隐藏但可以聚焦的 input页面加载后聚焦它并且在 window 的 focus 事件里重新聚焦。注意不要用display: none隐藏元素无法获得焦点得用position: fixed; opacity: 0; width: 0; height: 0这种视觉隐藏的方式。/* 视觉隐藏但可聚焦不要用 display:none */ .ghost-input { position: fixed; top: -100px; left: -100px; width: 1px; height: 1px; opacity: 0; }4.4 结束符配置与回车丢失扫码枪的结束符是可以在硬件层面配置的。默认大多数是回车CR也有一些出厂配置成 Tab 或者不加任何后缀。如果你在项目里发现数据能进来但永远不触发提交八成是枪没有配结束符。判断方法很简单把光标放到记事本里扫一下看看是不是自动换行了。换行说明有回车不换行说明没有。没有的话要么用设置条码把后缀配成 CR要么在前端依赖超时兜底——这也是我在useScanner里同时保留两条收尾路径的原因。还有一个反过来的坑有些枪配了 CRLF回车 换行前端的keydown里会先收到 Enter然后又收到一个\n。\n的e.key是Enter还是LineFeed不同浏览器实现不一致有可能被当成字符塞进 buffer。所以清洗那一步很关键\n必须在 normalize 里被干掉。5. 常见问题速查与排查思路5.1 问题速查表下面这张表是我整理的现场急救包遇到问题先按症状对号入座症状大概率原因处理办法扫码后页面毫无反应焦点不在页面上 / 全局监听没注册加幽灵输入框检查 addEventListener 是否在捕获阶段数据少一两位结束符触发时最后一次 input 事件延迟不依赖 v-model 实时值或改用全局监听一次扫码触发两次枪上报重复 / 按键抖动加 300ms 时间窗去重数据里混进换行符枪配了 CRLF 后缀normalize 里清除控制字符中文输入法下丢字符输入法截获 keydown监听 composition 事件或强制切英文扫码后页面刷新了input 在 form 内触发默认提交加 submit.prevent回车上触发了两遍逻辑Enter 和超时兜底同时命中flush 里先 clearTimeout 再回调多个输入框抢焦点每个框都做了自动聚焦只保留一个幽灵框其他框不自动聚焦5.2 几个我实际踩过的坑第一个坑来自扫码枪的多码连扫。有些支持连续扫描模式的枪不松扳机的话会把多条条码连着发过来中间的回车反倒成了分隔符。这时候如果你的逻辑是回车即提交会瞬间提交好几条。解决办法是关掉枪的连续扫描模式或者在应用层加一个队列把短时间内的多次提交排队处理。第二个坑是键盘布局。有一批条码内容包含特殊符号而现场电脑的键盘布局被设成了非美式布局导致-、_、这些字符映射错位扫出来的内容和实际不一样。这个坑排查起来极痛苦因为你在记事本里测试是对的到了业务电脑上就错。后来我的做法是统一要求系统用美式键盘布局并且在设置界面里打印当前布局让用户确认。第三个坑是页面路由切换。单页应用切路由的时候onBeforeUnmount会移除监听新页面挂载后重新注册——如果新页面也用了useScanner很容易出现上一个页面的回调还在响应的情况。原因是忘记在卸载时移除监听或者移除时传的函数引用不一致。用组合式函数封装就天然避免了这个问题因为注册和移除用的是同一个函数引用。5.3 硬件侧的几条设置建议虽然咱是前端但有些问题真得从枪上解决比在代码里打补丁划算得多。第一正式上线前一定让实施人员用设置条码把后缀统一配成 CR回车。统一了后缀前端逻辑就只需要处理一种情况代码复杂度直接减半。第二关掉不需要的码制。很多枪默认同时开了十几种码制包括一些已经废弃的这会导致误读。只保留现场实际使用的两三种识别速度和准确率都会提升。第三把蜂鸣器和指示灯打开。这是给现场操作员最直接的反馈——扫成功响一声扫失败响两声。很多扫了没反应的投诉其实是扫到了但用户不知道。第四做好设备台账。同一个项目里用不同型号的枪配置流程完全不一样我见过一个仓库混用了四个品牌的枪每次上新人都要重新教学一遍。把每把枪的型号、当前配置、恢复出厂的方法记在文档里省的是未来的时间。最后分享一个我在实际项目里养成的习惯不管方案看起来多简单都先花十分钟用记事本做一次裸测——插上枪打开记事本扫十次看有没有丢字符、有没有换行、有没有重复。这一步能提前排除掉 80% 的硬件侧问题剩下的才是真正需要写代码解决的部分。等到代码写完再回头怀疑硬件排查成本会翻好几倍。
返回列表