本地中间服务+WebSocket:网页实时读取HID设备数据的完整方案
2026/9/6 11:23:45 网站建设 项目流程

做硬件对接最怕的就是“看着简单,动手一堆坑”。前阵子给客户做扫码枪和键盘类 HID 设备的数据采集,需求很直白:设备插上电脑,网页里要实时看到按键和数据流。我第一反应是直接用 WebHID API,结果被浏览器兼容性、权限弹窗、高版本 Chrome 的限制轮流教育了一遍。折腾下来最终敲定的方案是:本地中间服务 + WebSocket 通信模式——本地跑一个小服务独占 HID 设备,把原始数据包翻译成 WebSocket 消息推给前端,前端也能反向发指令给设备。这篇文章我会从 HID 协议基础、方案选型、服务端和前端代码落地,再到实际踩坑记录,把整套打法完整拆给你。

这个方案适合谁?后端能写几行 Node.js、前端用过 WebSocket 的开发者,照着文章能在一个小时内跑通 demo;如果设备是自研硬件(比如 STM32 做的 HID+CDC 复合设备),文章里的协议设计和排查思路也能直接复用。

1. 内容整体设计与思路拆解

1.1 为什么 HID 设备对接要绕道本地中间服务

浏览器本身是不允许普通网页直接访问本机 HID 设备的,这是安全模型决定的。如果你强行做,库就会弹权限窗、用户得靠手势确认设备,体验非常碎。WebHID API 出现后,这个问题看似解决了——Chromium 系浏览器可以直接收发 HID 报表。但真用起来你会发现三件事:

第一是兼容性。Firefox 和 Safari 对 WebHID 的支持一直不完整,企业内部还能指定浏览器版本,对外业务根本没法赌。

第二是权限流程。WebHID 要求用户通过一次用户手势选择设备,之后会话里再访问还好,但如果页面刷新、或者页面在后台运行时设备重连,状态管理非常容易崩。关键是设备选中后的句柄是页面级的,浏览器一刷新就要重新授权,做后台数据大屏,每天起床第一件事是去点一次授权弹窗,这种体验给客户演示时特别掉价。

第三是设备访问深度。有些 HID 设备不仅上报数据,还要下发配置或者控制灯效。WebHID 对 Output Report、Feature Report 的支持在不同系统上表现不一致,遇到厂商自定义报表描述符,经常报错或静默丢弃。

所以本地中间服务不是绕路,而是绕开浏览器不靠谱的“直连能力”,用系统级 HID API 把设备管起来,再用浏览器天然支持、双向友好的 WebSocket 把设备和页面粘起来。

1.2 WebHID、浏览器插件、本地中间服务怎么选

除了本地中间服务,常见的还有两条路线,我做了个对比表:

对比项WebHID API浏览器插件本地中间服务 + WebSocket
浏览器兼容性仅 Chromium 系各浏览器插件体系不互通所有现代浏览器原生支持 WebSocket
权限体验每次会话要用户手势授权安装后基本常驻本地服务常驻,无需反复授权
设备访问深度部分 Output/Feature Report 受限取决于插件能力系统 HID API 全权限访问
多前端/后台统计差,页面级句柄较好好,可以同时服务多个页面
部署成本最低需要上架插件商店需要安装/启动一个小服务

现实中的业务很少是“只给一个固定 Chrome 页用”,更多面临多种浏览器、多台设备、后台统计、日志审计这些额外需求。浏览器插件虽然也能做到常驻后台,但 Safari、Edge、Chrome 的插件体系不统一,插件崩溃、代码审查、更新签名全是坑。我最后选择本地中间服务,是因为它能同时收数据、发指令、做权限控制、存日志,WebSocket 只是它暴露给前端的一个口子,后续接 MQTT、接 Webhook 都很自然。

1.3 整体架构和数据流向

整个链路分三层:

  • 设备层:USB HID 设备(键盘、鼠标、扫码枪、自定义 HID 设备),通过系统 HID 驱动暴露节点。Windows 上是\\.\HID#...,Linux 上是/dev/hidraw*
  • 中间服务层:本地跑一个 Node.js 进程,用node-hid枚举并打开目标设备,读取中断输入报表;同时开一个 WebSocket 服务,监听本机端口。
  • 前端层:浏览器页面连接ws://127.0.0.1:端口,订阅设备列表和hid.data消息流。

数据流向有两条。第一条是上行采集:设备 -> 系统 HID 驱动 -> node-hid 回调 -> JSON 封装 -> WebSocket 推送 -> 前端渲染。第二条是下行控制:页面按钮 -> WebSocket 消息 -> 服务端HID.write()-> 输出报表 -> 设备执行。选 WebSocket 而不是 HTTP 轮询,核心原因就是双向和低延迟。USB 键盘、扫码枪的数据事件往往只有几毫秒间隔,HTTP 轮询几百毫秒的延迟拿来做交互还行,拿来做实时数据流就是灾难;WebSocket 一个 TCP 连接双向复用,协议开销也低。

2. 核心细节解析与实操要点

2.1 HID 协议到底在说什么

HID 协议很多人一听就头大,其实对接阶段只需要抓四个东西:VID/PID 用于锁定设备身份,Usage Page 和 Usage 用于判断设备类型,报表描述符用于解释原始字节,端点类型决定读和写的方式。

设备枚举时,系统会拿到 VID(厂商 ID)、PID(产品 ID)和字符串描述符,这是你过滤设备最稳的指纹。而设备属于键盘、鼠标还是厂商自定义设备,要看接口描述符里的bInterfaceClass=0x03,以及报表描述符中的Usage Page。标准键盘通常是Usage Page=0x01, Usage=0x06,鼠标是Usage Page=0x01, Usage=0x02,很多国产扫码枪和自定义设备则喜欢用0xFF00这种厂商保留段。

报表描述符决定字节含义。拿标准键盘举个例子,输入报文一般是 8 个字节:第一字节是修饰键(Ctrl/Shift/Alt 等位标志),第二字节保留,后六个字节是同时按下的按键码。node-hid 的data事件里拿到的就是一个 Buffer,不做解析的话你只能看到[0, 0, 0, 0, 0, 0, 0, 0],这显然没有意义。对接时要先找厂商要协议文档,或者自己拿工具抓样本数据,按按键对比字节差异来反推。

还有一类很常见的坑是输出报文和 Feature Report。你想控制设备的 LED 灯、风扇、振动马达,或者给扫码枪切换模式,硬件必须预先在报表描述符里声明 Output 报表或 Feature 报表,软件层通过write()sendFeatureReport()下发。如果硬件没有声明,你写多少次都会被系统忽略。自研硬件尤其要注意:固件里报表描述符什么样,中间服务就按什么样解析,两端版本必须同步锁死,不然升级一个、没升级另一个,线上全是玄学问题。

2.2 读取 HID 设备的四类开源方案

  • Node.js:node-hid是目前最省事的库,底层封装 HIDAPI,一套代码跑 Windows/Linux/macOS。接口就三件事:HID.devices()枚举、new HID.HID(path)打开、device.on('data')收数据。做 Electron 桌面应用时尤其顺手。
  • C#:.NET生态里可以用HidSharp,NuGet 直接装,跨平台。如果做 WPF 上位机或 Windows 服务,这比 Node 更合适,配合ClientWebSocket和前端互通也不是问题。Win10+ 也可以尝试Windows.Devices.HumanInterfaceDevice,但那是 UWP 口子,用起来限制多一点。
  • Python:hidapi或者pyusb,适合快速验证、脚本采集、跑测试用例。性能一般但开发速度很快,做原型验证够用。
  • C/C++:直接用 hidapi 的原生库,适合把中间服务打包成系统守护进程,或者嵌入到自己的上位机框架里。

选型建议很实际:部署到别人电脑上、又不想装 Python 环境,就选 Node.js 或者编译成 exe 的 C#;团队如果是 C/S 架构老手,C# 甭犹豫;只做内部自动化测试,Python 最快。不要因为“前端是网页”就默认后端也必须用 Node,中间服务的语言完全可以按团队存量技术栈来,反正对前端暴露的都是 WebSocket。

2.3 热插拔、多设备与权限,最容易翻车的三个细节

node-hid本身不带热插拔事件回调,但它每次HID.devices()都会重新枚举一遍系统设备。稳妥做法是中间服务起一个 1 到 2 秒的定时器,反复枚举设备指纹列表,跟内存里的设备表比对:新增就打开,消失就关闭句柄并广播状态。这样灵敏度虽然到秒级,但绝大多数 HID 设备热插拔场景完全够用。

权限方面,Windows 上打开标准 HID 设备基本免驱,但某些厂商自定义设备如果有 WinUSB 驱动绑定,node-hid 可能打不开,需要在设备管理器里确认驱动类型。Linux 上访问/dev/hidraw*需要 udev 权限,常见做法是加一条规则把目标 VID/PID 的设备放权:

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c539", MODE="0666"

macOS 要在 Info.plist 里声明 USB 权限,不然第一次访问会静默失败。

还有一个容易被忽视的点:同一个物理设备可能在系统里暴露成多个 HID 节点。比如带多媒体按键的键盘,设备管理器里能看到两个 HID 键盘设备;无线接收器经常虚拟出两三个节点。这时候千万别按“哪个名字像键盘就用哪个”,直接用 VID/PID 加 Usage Page 过滤,再通过实拉样本数据判断哪个节点有真实数据。

3. 实操过程与核心环节实现

3.1 本地中间服务怎么选型:工程结构与依赖

我选的组合是 Node.js +node-hid+wsws是一个很轻量的 WebSocket 库,不依赖浏览器环境,服务端用起来几乎零心智负担。工程结构我习惯拆成四块:设备管理模块(枚举/打开/关闭/热插拔)、协议解析模块(把原始 Buffer 按设备协议翻译成 JSON)、WebSocket 服务模块(连接/鉴权/心跳/广播)、配置模块(端口、PID 过滤规则、日志级别)。

端口选择也要讲究。别用 6000、6666、4444 这类历史遗留“敏感端口”,有些系统或中间件会占用,浏览器层面也有一些端口限制。我自己习惯用 8800~8900 里的一个,比如 8899,启动时检测占用,被占了自动换个端口,并把实际端口写到一个本地配置文件里,前端启动时先拉配置再连,这套设计在换电脑部署时特别省心。

3.2 消息协议先设计好,后端和前端才不打仗

WebSocket 通信内容如果是随便发字符串,后期维护一定乱。我建议一开始就定义好 JSON 消息格式,至少包含typedeviceIddatats四个字段。上行消息和服务端通知分开:

  • 客户端 -> 服务端:subscribe(订阅设备)、send(向设备写数据)、ping(应用层心跳)。
  • 服务端 -> 客户端:device.list(设备列表)、hid.data(上行采集数据)、device.error(设备异常)、send.ack(指令确认)、send.error(指令失败)、pong

设备唯一标识不推荐用 VID/PID 拼字符串,因为同型号多个设备会冲突。优先用 node-hid 返回的path,它是系统级路径,能区分同一个厂家同一个型号的不同设备。

设计send消息时务必带上msgId。前端发指令是异步的,不带消息 ID,你根本没法区分当前收到的send.ack是回应哪条指令。实测在扫码枪切换模式这种指令发出后,设备要几十毫秒甚至几百毫秒才会响应,没有msgId的协议在复杂交互下根本没法调。

3.3 服务端代码落地:设备枚举、数据转发、指令下发

下面是一个可以直接跑的最小实现,我在关键位置加了注释:

const HID = require('node-hid'); const { WebSocketServer } = require('ws'); const WS_PORT = 8899; const wss = new WebSocketServer({ host: '127.0.0.1', port: WS_PORT }); // 所有前端连接 const clients = new Set(); // devicePath -> HID 实例 const deviceHandles = new Map(); function broadcastAt(ws, obj) { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(obj)); } } function broadcastAll(obj) { const text = JSON.stringify(obj); for (const ws of clients) { if (ws.readyState === ws.OPEN) ws.send(text); } } function isTargetDevice(info) { // 按项目实际过滤,这里匹配键盘和厂商自定义设备 return ( (info.usagePage === 0x01 && info.usage === 0x06) || info.usagePage === 0xff00 ); } function listDevices() { return HID.devices() .filter(isTargetDevice) .map((d) => ({ deviceId: d.path, vendorId: d.vendorId, productId: d.productId, product: d.product, manufacturer: d.manufacturer, usagePage: d.usagePage, usage: d.usage, })); } function openAllDevices() { const devices = HID.devices().filter(isTargetDevice); for (const info of devices) { if (deviceHandles.has(info.path)) continue; try { const dev = new HID.HID(info.path); dev.on('data', (buf) => { broadcastAll({ type: 'hid.data', deviceId: info.path, data: Array.from(buf), ts: Date.now(), }); }); dev.on('error', (err) => { broadcastAll({ type: 'device.error', deviceId: info.path, message: err.message, }); }); deviceHandles.set(info.path, dev); console.log('设备已打开:', info.path); } catch (err) { console.error('打开设备失败:', info.path, err.message); } } } wss.on('connection', (ws) => { clients.add(ws); broadcastAt(ws, { type: 'device.list', data: listDevices() }); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch (e) { return; } if (msg.type === 'send' && msg.deviceId) { const dev = deviceHandles.get(msg.deviceId); if (dev) { try { dev.write(msg.data); broadcastAt(ws, { type: 'send.ack', deviceId: msg.deviceId, msgId: msg.msgId || '', }); } catch (err) { broadcastAt(ws, { type: 'send.error', deviceId: msg.deviceId, msgId: msg.msgId || '', message: err.message, }); } } else { broadcastAt(ws, { type: 'send.error', deviceId: msg.deviceId, msgId: msg.msgId || '', message: 'device not found', }); } } }); ws.on('close', () => { clients.delete(ws); }); ws.on('error', () => { clients.delete(ws); }); }); // 热插拔轮询 setInterval(() => openAllDevices(), 2000); console.log(`HID Bridge 服务已启动: ws://127.0.0.1:${WS_PORT}`);

这个版本把设备句柄做成了全局单例,任意设备只打开一次,数据同时广播给所有前端。相比“每个客户端各自打开设备”的做法,优势很明显:设备不被多个进程抢,Windows 上不会因为句柄冲突导致事件丢失。

细看dev.write(msg.data),这里要求data是数组或 Buffer。向标准键盘设备写数据通常没有意义,但向带 RGB 灯效的设备、带振动马达的游戏手柄、可配置的扫码枪,这就是下发指令的主路径。msgId在这里不是摆设,前面说的指令确认全靠它。

3.4 前端 WebSocket 对接:连接、订阅与断线重连

前端代码我习惯封装成一个HIDBridgeClient类,页面里只管监听事件,不裸写WebSocket细节:

class HIDBridgeClient { constructor(url = 'ws://127.0.0.1:8899') { this.url = url; this.ws = null; this.retry = 0; this.listeners = {}; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.retry = 0; this.ws.send(JSON.stringify({ type: 'subscribe', devices: ['all'] })); this.emit('open'); }; this.ws.onmessage = (event) => { let msg; try { msg = JSON.parse(event.data); } catch (e) { return; } this.emit(msg.type, msg); }; this.ws.onclose = () => { const delay = Math.min(1000 * 2 ** this.retry, 5000); this.retry += 1; setTimeout(() => this.connect(), delay); }; this.ws.onerror = () => { this.ws.close(); }; } on(type, cb) { if (!this.listeners[type]) this.listeners[type] = []; this.listeners[type].push(cb); } emit(type, data) { (this.listeners[type] || []).forEach((cb) => cb(data)); } sendToDevice(deviceId, data, msgId = '') { this.ws.send(JSON.stringify({ type: 'send', deviceId, data, msgId })); } }

重连策略用了指数退避:第一次失败等 1 秒,第二次 2 秒,最大 5 秒,避免服务端重启过程中前端疯狂重连把端口打爆。页面里使用时:

const bridge = new HIDBridgeClient(); bridge.on('device.list', (msg) => renderDeviceList(msg.data)); bridge.on('hid.data', (msg) => handleHidData(msg));

handleHidData里拿到的msg.data是个数组,按设备厂商的协议文档解析成按键、扫码内容或者传感器数值。如果同一个设备数据量大,前端渲染要做节流,比如每 100ms 批量刷新一次 DOM,不要每条消息都直接操作 DOM,不然页面会卡成 PPT。

4. 常见问题与排查技巧实录

4.1 WebSocket 连不上的常见原因与验证方法

和前端页面“连不上本地服务”相关的报错,80% 不是代码问题,而是环境问题。

第一种常见场景:访问的页面是 HTTPS,但本地中间服务是裸ws://127.0.0.1:8899。浏览器会把ws://当作混合内容拦截,控制台会报“Mixed Content”相关错误。这不是 Chrome 高版本“无法启用 WebSocket”,而是安全策略。解决办法有三种:页面也用http://localhost访问;本地服务升级成wss://;或者把页面和本地服务部署在同源下(比如都用http://127.0.0.1:8899/web打开控制台)。

第二种常见场景:服务没启动,或者端口被防火墙拦了。验证方法很粗暴,先浏览器直接访问http://127.0.0.1:8899,如果拒绝连接,基本可以断定是服务没起来;如果页面存在但 WebSocket 连接失败,再加一层判断——看一下服务端控制台有没有打印“前端页面已连接”。如果服务端没有任何日志,那消息就没到服务端,往防火墙、局域网 IP、代理设置方向排查。

第三种场景:端口被人占了。本地开发环境经常有各种软件占用端口,启动服务时要做端口检测,失败就换端口并同步到前端的配置读取逻辑。

4.2 连接中途断开的排查思路:从一条报错说起

搜索热词里有一条很典型的报错:stream disconnected before completion: websocket closed by server before response。这条信息常见于某些 WebSocket 客户端的请求/响应模型里,意思是请求还没收到响应,服务端就把连接关了。

websocket closed by server before response通常有几个原因:

  • 服务端主动断开:业务逻辑里触发了ws.terminate()或异常未捕获导致进程退出。
  • 请求频率太高:客户端每秒发几十条send,后端处理不过来或者缓冲区满,最后触发服务端强制断开。
  • 反向代理超时:中间服务如果挂在 Nginx 后面,proxy_read_timeout设置太短,长时间没有消息流动时代理会顺手把连接掐掉。
  • 半开连接:设备或网络层面已经断了,但服务端不知道,继续向一个“僵尸连接”写数据,触发异常后关闭。

排查在线路上的建议:

  1. 服务端日志里把 WebSocket 生命周期打全:连接建立、收到消息、发送消息、连接关闭,每一条都带时间戳和连接 ID。
  2. 浏览器开发者工具 Network 面板里看 WebSocket 帧,确认断开前最后一条消息是什么方向、什么内容。
  3. 服务端用ws库的ping/pong机制保活。定时向所有客户端发协议级 ping,如果某个客户端超过 N 秒没有回 pong,就主动终止这条连接。WebSocket 协议层的心跳比应用层的手动消息更可靠,占用的带宽可以忽略。
  4. 部署反向代理时,务必开启 Upgrade 头,并调大超时时间。

Nginx 相关配置参考:

location /ws { proxy_pass http://127.0.0.1:8899; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

4.3 HID 设备识别不到、权限不足与复合设备问题

设备管理器里出现两个HID Keyboard,不代表系统坏了。绝大多数情况是复合设备有多个 HID 接口:一个负责主键盘按键,一个负责多媒体键或厂商自定义功能;无线接收器也经常虚拟出多个键盘节点。对接时不要靠设备管理器猜,要看你程序里枚举出来的 VID、PID、Usage Page。真实的坑是:两个节点的 VID/PID 完全一样,但一个有数据一个没数据,这时只能逐个打开试数据。

STM32 做 HID+CDC 复合设备是另一个高频话题。用 CubeMX 配置时,USB 设备类选择 HID 和 CDC,生成的描述符里会包含多个接口。Windows 枚举复合设备时,容易出现设备被识别成两个 HID Keyboard 或者 CDC 虚拟串口不出现的情况。排查重点:

  • 确认 USB 配置描述符里bMaxPower、端点地址和中断轮询间隔设置合理;
  • 复合设备连接后先看设备管理器里是否多出“USB 输入设备”“HID-compliant vendor-defined device”等节点;
  • 如果设备节点存在但 node-hid 枚举不到,大概率是报表描述符里Usage Page被系统归类到别的地方,尝试用0xFF00厂商自定义段。

Linux 下权限不足打开设备时报cannot open device,先看用户是否在plugdev组,再看 udev 规则是否命中:

lsusb udevadm info -a -n /dev/hidraw0

然后根据输出的idVendoridProduct写规则,改完执行sudo udevadm control --reload-rules && sudo udevadm trigger

设备打开后没有数据,优先排查轮询间隔。HID 中断端点有bInterval字段,1ms 对应 1000Hz 上报频率,10ms 就只剩 100Hz。某些低成本扫码枪出厂把轮询间隔设得很大,导致前端看起来“延迟很高”,这不是软件的锅,要看设备的描述符。

4.4 数据丢包、乱序与性能调优

WebSocket 本身是 TCP 之上的可靠传输,不会出现网络层面的乱序。但 HID 设备是 USB 报文,如果设备缓冲区或者系统 HID 栈队列溢出,确实会丢包。症状是前端看到按键少了几个、扫码内容偶尔缺一位、游戏手柄的摇杆跳变。

最有效的缓解手段是重发机制。对键盘、扫码枪这类数据,靠主机端重发不现实,因为设备不会保留历史报文,只能从源头优化:换一个上报间隔更小的设备、或者让固件端每包带序列号,前端检测到跳号时主动报警,至少能发现“丢了多少”。对于可以重新下发模式设置的设备,指令类消息增加超时重试,发送失败就重发三次,并给出用户可见的提示。

如果单台设备数据量极大(比如高分辨率传感器),中间服务转发时要考虑背压。broadcastAll在客户端网速慢时会堆积在发送缓冲区,简单做法是加一个固定频率的批处理:每 50ms 把累积的数据合并成一帧 JSON 广播,牺牲一点实时性,换整条链路的稳定。

热插拔的隐患也要提:设备被拔走瞬间,打开着的 HID 句柄下一次data事件会触发error,服务端必须捕获这个异常并从deviceHandles里移除,否则同一个path重新插入时会因为残留句柄导致打不开新设备。我在代码里用了一个小小的内存表,每次热插拔轮询都会清理异常句柄,这条逻辑务必加上。

5. 后续扩展与我的实操体会

5.1 扩展思路:多客户端、鉴权与业务联动

如果不止一个页面要连服务,把clients从 Set 改成 Map,每个连接记录它的订阅设备、最后活跃时间、客户端类型。这样既能实现按需推送,也能在服务端做连接数限制。安全加固方面,本地服务永远只监听127.0.0.1,不要监听0.0.0.0;如果需要跨设备使用,尽量通过系统防火墙限制来源 IP,并在 WebSocket 握手阶段校验 Origin 和 token。

配置管理可以借鉴 OBS WebSocket 的做法:把端口、鉴权 token、设备过滤规则都放在一个可视化面板里,前端先通过 HTTP 拉取配置再建立 WebSocket 连接。版本迭代时,前端和服务端的协议版本号要对上,不匹配就友好提示升级,而不是让用户对着白屏猜。

自研硬件的联动值得多说一句:如果你在设备端想把 Fn 键这类非标准按键从主机发下去,标准 HID 协议里没有通用做法。Fn 键通常是固件内部的组合逻辑,上位机没法直接发一个标准的 Fn Usage,你需要和设备厂商约定一个自定义输出报表,比如[0x05, 0x84, 0x01]表示“模拟按下 Fn 组合键”,固件收到后再自行映射。这个约定必须写进协议文档,并且固件和中间服务同时迭代,否则你辛苦写的前端下发逻辑在下一版固件上直接失效。

5.2 我的实操体会

这套“本地中间服务 + WebSocket 通信模式”我前后重构过三个版本,最深刻的感受是:中间服务本身真的不难,难的是把设备识别、热插拔、断线重连、消息协议这些边缘情况当成正式功能来设计。你越是图省事,越会在现场翻车。

我现在接任何 HID 设备对接项目,第一步永远是先写好消息协议和日志框架,再动手写业务逻辑。日志里每一条设备打开、连接建立、消息收发都带时间戳,这能省下至少一半的排障时间。如果还在犹豫该用 WebHID 还是本地中间件,我的建议很明确:涉及多种浏览器、多台设备、后台统计或者自研固件,闭眼选本地中间服务;只是一个实验性页面、设备固定、浏览器固定,WebHID 也能凑合。但凡是给客户做的正式项目,别赌浏览器老哥的脾气,本地服务这条路线稳得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询