做了几年收银系统相关的桌面应用我一直觉得最麻烦的不是界面也不是数据库而是和外设打交道。之前有个项目要对接电子秤第一反应是网上找方案结果发现串口通信的资料要么是单片机的要么是C#的真正落地的“前端工程师也能看明白”的electron方案少之又少。折腾了几天踩了无数坑之后我决定把整个过程完整写下来——从vue-cli3初始化项目、集成electron到serialport安装、串口枚举、协议解析再到收银界面实时显示重量、自动填充金额全链路讲清楚。不管你是第一次接触串口还是已经在用electron做桌面应用这篇文章应该都能让你少走几天的弯路。1. 为什么收银系统用vue-cli3、electron和serialport这套组合1.1 收银场景里电子秤是刚需但桥接方案一直很尴尬超市、生鲜店、熟食档口的收银流程里电子秤不是可选项是硬需求。称重商品要按重量计价这个重量数据如果靠人工录入效率低还容易出错。理想状态是收银台软件直接读取电子秤的当前重量输入单价后自动算金额全程不用碰键盘。问题在于传统收银软件大多是C/S架构的桌面程序用C#或者Delphi写的居多。而现在很多团队做新项目前端技术栈已经Web化了vue、react用得很顺手再回去写WinForms业务逻辑会非常痛苦。用浏览器Web页面又碰不到串口除非用ActiveX之类的老旧方案体验极差。electron的出现刚好补上了这个缺口——它本质是一个桌面应用壳子内部跑的是Chromium外部又能拿到Node.js的全部能力包括调用原生模块和系统资源。这就意味着一个前端团队可以用自己最熟悉的vue技术栈写出一个能直接操作串口的桌面收银程序。1.2 这套组合下每个成员的分工vue-cli3负责搭出前端工程的骨架包括vue全家桶、组件化、打包构建这些Web开发体验的底座。electron负责提供桌面运行时环境把渲染进程里的UI和主进程里的Node.js能力连接起来。serialport是整套链路里最核心的库它封装了不同操作系统的串口读写接口让Node.js可以像打开文件一样打开串口、收发字节数据。这三者的职责边界非常清晰vue页面负责展示和交互electron主进程负责管理串口生命周期和转发数据serialport负责最底层的串口通信协议。业务数据在渲染进程里处理硬件数据在主进程里处理中间用IPC搭桥。这种分层设计的好处是如果哪天要换硬件方案——比如从串口电子秤换成蓝牙秤——只需要改动主进程的通信模块页面几乎不用动。1.3 为什么不选纯Web方案或纯原生方案纯Web方案比如浏览器Web Serial API其实现在很多浏览器也支持串口了但在实际收银场景里有个致命问题不同收银终端的浏览器版本、内核差异太大了而且Web页面没有完整的桌面能力收银小票打印、钱箱控制这些还是得靠桌面程序。如果做成纯Web系统跑在浏览器里用户一批量部署就会遇到各种莫名其妙的问题。纯原生方案当然也能做但开发成本和维护成本高。收银系统不只是秤一个功能还有商品管理、会员、订单、报表等一大堆业务模块用原生技术栈开发这些的效率比vue低不少。而且团队如果都是前端出身强行转原生开发学习和排错的成本都会陡增。vue-cli3electronserialport这套组合本质上是让后端式的硬件逻辑能在前端团队的技能范围内被消化掉既保住开发效率又能触达硬件层。2. 工程初始化在vue-cli3项目里把electron嵌入进去2.1 用vue-cli3创建项目并接入electron工具链这个环节网上教程很多但完整讲下来的不多。我这边实际操作是先用vue create命令初始化项目勾选了router、vuex这些基础插件。然后通过vue-cli-plugin-electron-builder插件把electron集成进来。插件的优势在于它同时管理了electron的启动、打包、开发环境热更新等全套流程比手动接入electron要省很多事。vue create pos-system cd pos-system vue add electron-builder跑完vue add electron-builder之后项目里会多出一个background.js文件这就是electron的主进程入口。package.json里也会多出几个脚本命令比如npm run electron:serve开发模式启动、npm run electron:build打包产物。这里补充一个重点vue-cli-plugin-electron-builder默认会把electron的主进程文件编译到dist_electron目录下和渲染进程分开打包。开发模式下它会自己拉起electron窗口渲染进程走vue的开发服务器主进程直接加载本地的入口文件。整体体验很像webpack的热更新只是多了个桌面壳。2.2 主进程、渲染进程和preload脚本的架构设计electron应用里最核心的边界就是主进程main和渲染进程renderer。主进程运行在Node.js环境能做系统级的事情渲染进程其实就是浏览器页面默认情况下没有Node.js API。串口这种硬操作必须在主进程做然后通过IPC把数据告诉渲染进程。为了保证通信安全我用了contextBridge的方式在preload脚本里暴露一个精简的API给页面调用。不要直接把nodeIntegration打开虽然在开发环境图方便开着也行但生产环境风险比较大页面一旦被注入脚本整个Node.js能力都暴露了不太安全。// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(serialAPI, { listPorts: () ipcRenderer.invoke(serial:list), openPort: (config) ipcRenderer.invoke(serial:open, config), closePort: () ipcRenderer.invoke(serial:close), writeData: (data) ipcRenderer.invoke(serial:write, data), onData: (callback) { const listener (_event, data) callback(data); ipcRenderer.on(serial:data, listener); return () ipcRenderer.removeListener(serial:data, listener); }, });页面上通过window.serialAPI.listPorts()就可以枚举串口。主进程里的ipcMain.handle注册对应的处理函数。这种做法的好处是页面只知道“有这个API”完全不知道串口底层怎么实现后续替换硬件方案时页面无感知。2.3 serialport原生模块的编译问题——electron-rebuildserialport不是纯JavaScript库它有C原生绑定编译目标要和当前electron的Node.js ABI版本对齐。如果直接用npm install装完就用十有八九启动时会报“NODE_MODULE_VERSION mismatch”之类的错误。原因就是Node.js的版本和electron内置的Node版本不一样二进制模块对不上。我用的是electron-builder自带的依赖安装后自动编译机制。在package.json里加上scripts: { postinstall: electron-builder install-app-deps }这样每次npm install之后会自动重新编译原生模块让它们匹配electron的ABI版本。如果遇到编译报错通常需要先安装node-gyp的依赖Windows上还包括Python和Visual Studio的C工具链。其中serialport常用版本配合electron-builder的install-app-deps已经能稳定完成编译整体体验这几年好了很多。3. 串口通信核心实现从设备枚举到电子秤数据解析3.1 串口枚举、目标设备识别和打开串口写这个功能之前要先回答一个问题系统里有好几个串口设备怎么知道哪个是电子秤实际项目里常见的做法是枚举所有串口做成一个下拉列表让用户手动选择然后保存配置到本地。如果要求更高一点可以尝试自动探测——遍历候选串口逐个发送读取指令看哪个端口能返回合法的电子秤帧格式。我实现的时候用了serialport的list接口它在不同平台返回的端口信息字段有差异所以做了统一过滤const { SerialPort } require(serialport); async function listScalePorts() { const ports await SerialPort.list(); return ports .filter((port) { if (process.platform win32) return /^COM\d$/.test(port.path); if (process.platform linux) { return /^\/dev\/tty(USB|S|AMA)\d/.test(port.path); } if (process.platform darwin) return /^\/dev\/cu\..*/.test(port.path); return false; }) .map((port) ({ path: port.path, manufacturer: port.manufacturer || })); }打开串口的时候要传一串参数除了路径之外最重要的就是波特率、数据位、停止位和校验位。电子秤一般波特率是9600数据位8停止位1无校验。但也有老款秤用2400波特率的所以这个配置还得做成可选的不能写死。const port new SerialPort({ path: selectedPort, baudRate: 9600, dataBits: 8, stopBits: 1, parity: none, autoOpen: false, });serialport的open默认是异步的打开成功后会触发open事件。打开时如果报“Resource busy”或者“Access denied”基本就是端口被其他程序占用比如某个串口调试工具还开着或者上一次程序没正常释放。这个在后面的踩坑部分我会再展开。3.2 数据监听、拆包和二进制解析电子秤的数据上报模式通常分两种一种是主动连续模式秤自己每隔几百毫秒发一帧数据另一种是应答模式软件发一条指令下去秤才回复一次。我接的那台X秤是连续模式每次输出一帧固定长度的数据帧头0x02帧尾0x03中间是状态字节、重量字节和校验字节。serialport打开后监听data事件就能拿到原始字节port.on(data, (buffer) { // buffer 是 Buffer 类型的字节流 // 需要做粘包、拆包处理 });这里有个关键的点串口数据不是按“帧”一次准确到达的。一次data事件里可能只有半帧下一帧才补齐也可能一帧带着半截下一帧。所以必须做缓冲区重组。简单做法是维护一个pendingBuffer收到数据先追加然后循环查找完整的帧。const FRAME_HEAD 0x02; const FRAME_END 0x03; let pendingBuffer Buffer.alloc(0); function appendData(chunk) { pendingBuffer Buffer.concat([pendingBuffer, chunk]); const frames []; let startIndex pendingBuffer.indexOf(FRAME_HEAD); while (startIndex ! -1) { const endIndex pendingBuffer.indexOf(FRAME_END, startIndex 1); if (endIndex -1) break; // 帧尾还没到继续等下一块数据 const frame pendingBuffer.subarray(startIndex, endIndex 1); frames.push(frame); startIndex pendingBuffer.indexOf(FRAME_HEAD, endIndex 1); } // 保留未处理完的尾巴 const lastFrameEnd frames.length ? pendingBuffer.lastIndexOf(FRAME_END) 1 : 0; pendingBuffer pendingBuffer.subarray(lastFrameEnd); return frames; }这个模型能应对大部分“一帧跨多次data”和“一次data含多帧”的情况。粘包处理好之后再做逐帧校验。X秤的帧格式大概是这样的偏移含义说明0帧头0x021状态bit0表示是否稳定2-7重量数据6字节BCD编码单位kg8单位0x01表示kg9校验和帧头发到单位字节的累加和校验和的算法是从帧头到单位字节逐字节累加取低八位和最后一字节比对。不一致就丢弃整帧。重量是BCD编码6个字节相当于12位十进制数字前三位的组合代表整数部分和三位小数。function parseScaleFrame(frame) { // frame 已包含帧头和帧尾 if (frame.length ! 10) return null; let sum 0; for (let i 0; i frame.length - 1; i) { sum (sum frame[i]) 0xff; } if (sum ! frame[frame.length - 1]) return null; const isStable (frame[1] 0x01) 0x01; let bcdStr ; for (let i 2; i 7; i) { bcdStr String((frame[i] 4) 0x0f); bcdStr String(frame[i] 0x0f); } // 假设前9位是整数部分最后3位是小数部分单位kg const integerPart parseInt(bcdStr.slice(0, bcdStr.length - 3), 10); const decimalPart parseInt(bcdStr.slice(-3), 10); const weight integerPart decimalPart / 1000; return { isStable, weight, raw: frame.toString(hex) }; }这里的BCD解析要注意每个字节的高4位和低4位代表一位十进制数比如0x12代表数字12而不是十进制的18。容易搞错的地方是把整个Buffer直接toString或者当十六进制数parseInt那样重量就会完全不对。这是我第一版代码就踩过的坑后来对照串口调试工具的Hex输出才想明白。3.3 应答模式的指令发送与数据驱动方式如果是应答模式的电子秤就需要先发送读取指令。常见指令需要参考电子秤的产品手册。比如某系列秤支持Modbus协议读取当前重量要发送// 读取保持寄存器地址0x0000长度1个寄存器 const command Buffer.from([0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0a]); port.write(command);发送完之后等着数据回来回应帧也要做CRC校验。CRC16的算法在网上有很多现成实现这里要注意Modbus的CRC是多字节的低位在前。我把常见实现封装成了一个函数放在独立的工具模块里方便复用和单测。function crc16Modbus(buffer) { let crc 0xffff; for (let i 0; i buffer.length; i) { crc ^ buffer[i]; for (let j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xa001; } else { crc crc 1; } } } return crc; }应答模式的好处是帧结构通常是标准的Modbus格式解决半包粘包问题可以直接从寄存器个数推断帧长度不需要等帧尾逻辑更清晰。缺点是每次读取重量都要主动发指令不能被动等待所以业务层要加一个定时轮询器一般每秒轮询两次到三次就够用。4. 整合进收银界面实时重量显示与业务联动4.1 在vue页面里消费串口数据的链路主进程解析好重量数据之后通过webContents.send把数据推给渲染进程。渲染进程在preload API里注册了onData回调一有数据就更新vue组件里的data。这里需要注意分发频率如果电子秤是连续模式每秒可能来几十次数据vue的setData不能每次都触发复杂计算否则界面会卡顿。我的做法是在接收层做一个节流只保留最近一次重量值用requestAnimationFrame驱动UI更新确保界面刷新是平滑的。// 渲染进程里的一个组合API import { ref, onBeforeUnmount } from vue; export function useScale() { const weight ref(0); const isStable ref(false); const connected ref(false); const onData (data) { if (data.type weight) { weight.value data.weight; isStable.value data.isStable; } }; const cleanup window.serialAPI.onData(onData); onBeforeUnmount(() { cleanup(); }); return { weight, isStable, connected }; }4.2 重量稳定后的自动计价流程收银的体验和业务规则要对齐。实际项目里如果电子秤一直飘那收银员不可能每次都等数字完全定住再去敲键盘。我的设计是先判断isStable字段电子秤稳定标志位为真且重量不为零才允许触发自动计价如果不稳定界面上的重量数字显灰色并且显示“等待稳定”提示。计价流程是收银员先在商品列表里选中某个称重商品输入或选择单价然后观察秤上放好商品稳定后界面自动计算金额。为了防误触我不会在重量刚稳定的瞬间就提交订单而是在界面上先给出一个“应收金额”让收银员确认后按回车或点击确认再落单。这样兼顾了效率和容错。function handleStableWeight(w) { if (currentProduct currentProduct.billingMode byWeight) { computedAmount.value (w * currentProduct.price).toFixed(2); } }4.3 串口断开、异常数据的业务兜底业务层面必须处理“秤坏了”“线松了”“秤离线”这些异常。我的处理是在主进程定时发送心跳检测或依赖数据超时判断如果在3秒内没有收到任何数据帧就认为通信异常。此时渲染进程收到disconnect事件界面上弹出一个明显的断连提示并且禁止自动计价继续执行防止收银员在无重量数据时手工录入出错。这个超时机制一开始我放在渲染进程做通过setInterval判断后来发现渲染进程在窗口最小化时会被浏览器节流导致判断不稳定。最后我把超时检测放在了主进程里主进程是Node环境不存在页面节流的问题检测到超时后主动发送状态事件给页面。5. 打包发布与生产环境部署绕过那些文档没写透的暗坑5.1 electron-builder打包配置与serialport原生模块的处理打包时要特别注意serialport这类原生模块不能被asar的默认逻辑压成不可执行的形式。electron-builder官方对原生模块有处理默认会把原生.node文件解压到app.asar.unpacked目录。我们要确保builderOptions里没有把native modules排除掉并且在asar配置里设置asarUnpack让serialport相关文件保持可执行。我最终的配置大概是这样的// vue.config.js module.exports { pluginOptions: { electronBuilder: { nodeIntegration: false, contextIsolation: true, builderOptions: { asar: true, asarUnpack: [**/node_modules/serialport/**, **/node_modules/serialport/**], win: { target: [nsis], }, }, }, }, };Windows下打包完的exe如果遇到控制台报错找不到serialport模块多半是asarUnpack配置路径不对或者rebuild没跑成功。可以先在本机用electron-builder --dir只生成未压缩目录版本调试确认原生模块能加载再打正式安装包。5.2 Windows串口权限和驱动层面的问题Windows上串口权限一般比Linux宽松但有些老式一体机或收银机的主板串口需要装驱动才能被系统识别为COM口。生产环境如果用的是USB转串口线驱动就更加关键。某国产USB转串口芯片我这边就不点名了方案上常见的那几款在Windows更新后偶尔会被禁用驱动签名表现为设备管理器里能看到端口但打开失败。处理办法通常是安装官方驱动并且提示用户在部署时不能禁用Windows的驱动签名强制验证。Linux下面主要是用户权限问题。默认情况下普通用户没有权限打开/dev/ttyUSB0需要把运行收银程序的用户加入dialout组sudo usermod -a -G dialout $USER这句话在部署文档里要写清楚不然程序一打开串口就报EACCES非常劝退。5.3 多秤场景和串口配置管理有些门店不止一台秤。后厨称重区一台秤收银台一台秤还可能有散称区第二台。我的方案是在主进程里维护一个serialManager支持连接多个串口对象每个串口对应一个逻辑秤ID。渲染进程的秤面板可以选择当前绑定哪个秤ID。配置持久化用JSON文件存到electron的userData目录第一次启动时自动检测可用端口并引导用户绑定。// serialManager 内部维护 MapString, SerialPort const scaleInstances new Map(); function addScale(id, config) { if (scaleInstances.has(id)) return; const port new SerialPort(config); port.on(data, (chunk) { const frames frameBuffer.push(id, chunk); frames.forEach((frame) { const parsed parseScaleFrame(frame); if (parsed) { mainWindow.webContents.send(scale:data, { id, ...parsed }); } }); }); scaleInstances.set(id, port); }多秤管理如果前期不做后面会非常痛苦因为门店的设备配置不是一套模板能覆盖的。6. 调试工具与经验技巧串口开发没有想象中那么神秘6.1 用虚拟串口配合串口调试工具模拟电子秤开发初期不需要真机也可以撸代码。Windows上有虚拟串口工具可以创建一对互联的COM口比如COM3和COM4。再配合可以自定义发送数据的调试工具在另一端模拟电子秤定时发送一帧数据。这个方式让我在没有秤的情况下先把收银台界面、自动计价、断连判断全部验证了一遍。调试工具这边我一般会开两个窗口一个Hex发送窗口用来模拟设备上报一个字节接收窗口看自己程序发出去的指令。对比两边的数据就能确认协议解析是否正确。真机到位之后再在真机上验证一遍这样排错成本最低。我用虚拟串口模拟X秤的帧时会写一段Python脚本每秒循环发送一帧构造好的数据数据重的BCD码用随机数生成这样还能顺便验证重量变化是否触发界面的实时刷新import serial, time, random ser serial.Serial(COM4, 9600, timeout1) def build_frame(stable: bool, weight_int: int, weight_dec: int): bcd f{weight_int:03d}{weight_dec:03d} data_bytes bytes([int(bcd[i:i2], 16) for i in range(0, 6, 2)]) status 0x01 if stable else 0x00 frame bytes([0x02, status]) data_bytes bytes([0x01, 0x03]) checksum (sum(frame)) 0xff return frame bytes([checksum]) while True: weight random.randint(100, 5000) frame build_frame(random.choice([True, False]), weight // 1000, weight % 1000) ser.write(frame) time.sleep(0.3)这个脚本同样适用于生产环境验收测试接上真机后跑一遍把输出的十六进制数据日志留下来后续排查问题有个基线。6.2 十六进制日志和协议逆向的日常习惯对接电子秤这类串口设备第一件事永远是看协议文档。但现实是很多秤的协议文档写得极其模糊甚至只有几页纸关键字段说明都缺失。这种情况下用串口调试工具抓取设备上报的原始Hex流自己逆向字段含义是基本功。我习惯在serialport的数据回调里把收到的原始Buffer打印成Hex字符串并且标注时间戳同时保留解析后的结果。这样出问题的时候能回溯到底层数据而不是只看到产品层面的“重量不对”。port.on(data, (chunk) { console.log([${new Date().toISOString()}] RX:, chunk.toString(hex)); // ... 后续处理 });长期跑下来这个日志文件就是最好的协议验证工具。字段对齐能直接用HEX编辑器打开设备手册上的样例数据慢慢比对比盲调盲试高效得多。6.3 常见协议的规律总结做过的秤多了之后我发现称重设备的协议其实套路很固定。帧头通常是0x02或0xAA这类特殊字节帧尾是0x03或0x0D0A重量字段要么BCD码要么二进制补码单位为kg或g精度从1g到0.001kg不等。状态字节多半会带一个稳定标志位可能还有零位标志、超载标志。拿到一个新秤协议先对照这个规律去排查能少走不少弯路。如果协议是Modbus格式那更要熟悉寄存器表。电子秤的Modbus通常是用03功能码读保持寄存器重量存在连续两个寄存器里32位浮点或整型。浮点要注意字节序是大端还是小端这也是初学者经常栽的地方。总结与收尾之前想说的几个经验上面这一整套做完我的最直接感受是串口通信并没有想象中那么底层可怕。只要把串口枚举、数据帧重组、协议解析、IPC转发这几层理清楚收银对接电子秤就是一个可复用的技术方案。而在实际项目中做得好的部分往往不是代码多炫而是边界条件处理得够不够细——秤没连上怎么办、数据不稳定怎么办、多台秤怎么区分这些才是门店收银场景真正关心的问题。如果你正在做类似的系统我的建议是第一先花时间搞清楚你的秤是连续模式还是应答模式这决定了整个数据流模型是“被动接收”还是“主动轮询”。第二把设备配置做成可修改的不要写死在代码里。不同门店、不同批次硬件的串口参数经常不一样。第三一定做一个完整的调试页面能看到串口号、波特率、连接状态、实时数据流和日志。这个页面不会给收银员用但对开发和远程排障非常重要。等这些基础打牢了你会发现不光是电子秤扫码枪、小票打印机、钱箱甚至一些工业设备对接的套路都是类似的。串口通信能力的价值不是只解决眼前这一个需求而是让你的桌面应用真正能“触及物理世界”这是单纯的Web开发给不了的体验。后面如果有机会我也想再整理一下蓝牙秤和以太网秤的接入方案毕竟现在很多新门店已经开始淘汰串口设备了。