做了几年收银系统相关的桌面应用,我一直觉得最麻烦的不是界面也不是数据库,而是和外设打交道。之前有个项目要对接电子秤,第一反应是网上找方案,结果发现串口通信的资料要么是单片机的,要么是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-cli3+electron+serialport这套组合,本质上是让后端式的硬件逻辑能在前端团队的技能范围内被消化掉,既保住开发效率,又能触达硬件层。
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-rebuild
serialport不是纯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 | 帧头 | 0x02 |
| 1 | 状态 | bit0表示是否稳定 |
| 2-7 | 重量数据 | 6字节BCD编码,单位kg |
| 8 | 单位 | 0x01表示kg |
| 9 | 校验和 | 帧头发到单位字节的累加和 |
校验和的算法是从帧头到单位字节,逐字节累加,取低八位,和最后一字节比对。不一致就丢弃整帧。重量是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 内部维护 Map<String, 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, timeout=1) 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:i+2], 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开发给不了的体验。后面如果有机会,我也想再整理一下蓝牙秤和以太网秤的接入方案,毕竟现在很多新门店已经开始淘汰串口设备了。