☰
收银系统对接电子秤:Electron+Vue串口通信实战
2026/10/14 11:44:03 网站建设 项目流程

收银系统对接电子秤这件事,看起来就是“读个串口把重量填进去”,但真正做起来,远比想象中要折腾。尤其是当你选择用 Web 技术栈来写收银前台,而不是传统的 C/S 桌面客户端时,串口这关就是第一道坎。我这边在一套基于 vue-cli3 + electron + serialport 的收银系统里,完整走通了电子秤的串口通信对接,包括设备识别、数据解析、稳定判断、异常重连,以及最后的打包分发。这篇内容就把整个过程的思路、代码、坑和排查技巧一次性讲清楚,给准备做同样对接的人省点时间。

1. 项目背景与技术选型思路

1.1 需求本质拆解

先聊需求本身。收银台放一台电子秤,商品放上去,系统自动读取重量,再乘上单价就能算出金额。人工去数重量再敲键盘,速度慢还容易出错。所以核心需求很简单:实时、稳定地从串口拿到电子秤输出的重量数据。

但简单背后有一层隐含约束:收银系统是给门店用的,前端需要好看、容易改界面、方便加营销活动,后端要对接商品库、订单库。传统 WinForm/WPF 写起来效率低,而且界面改起来麻烦。这个时候 Web 技术栈的优势就很明显了,但 Web 页面本身跑在浏览器沙箱里,根本没有权限访问串口。所以必须引入一个桌面宿主,把串口访问能力包进来。

这个项目里选的就是 Electron。它内置了 Node.js 运行时,能用 serialport 这类原生模块直接操作串口。前端依然用 Vue 全家桶来写界面,通过 Electron 的 IPC 机制跟主进程通信,拿串口数据。

1.2 技术路线对比与选择依据

技术选型的时候我其实对比了几条路:

方案优点缺点结论
C# WinForm/WPF 直连串口实现简单,SerialPort 类现成UI 开发效率低,跨平台困难不选
Electron + Vue + serialportUI 灵活,Node 生态强,跨平台原生模块打包有坑,体积偏大最终选用
网页 + 本机 WebSocket 转发服务不用 Electron要常驻一个中间进程,部署多一套,串口权限放本地服务也容易被杀软误报不选
Chrome App NaCl/PNaCl曾经可行Chrome 已废弃该能力直接排除

选 Electron 还有一个实际原因:串口访问必须走主进程。渲染进程是沙箱化的,直接 require('serialport') 大概率报错。主进程天然有 Node 全部能力,串口的打开、监听、关闭都放主进程,渲染进程只管通过 IPC 收数据、展示、交互,职责清晰,以后换成别的通信方式(比如网络秤、蓝牙秤)也好抽象。

1.3 整体架构与数据流设计

整个系统的数据流是这样的:

  • 电子秤通过串口线(或 USB 转串口)连接收银电脑。
  • Electron 主进程用 serialport 打开对应 COM 口,持续监听 data 事件。
  • 收到原始 Buffer 后,主进程按秤的协议解析出重量值。
  • 解析结果通过webContents.send推送给渲染进程。
  • Vue 组件收到重量后,更新界面上电子秤区域的数据,并与当前商品单价计算金额。

这里有一个容易被忽略的设计点:串口数据不能直接丢给渲染进程去解析。因为解析逻辑往往要维护上下文状态,比如缓存半包数据、拼接多帧内容,放主进程里处理更合适,渲染进程只接收最终的重量字符串,逻辑简单也不会卡 UI。

2. 环境准备与项目初始化实操

2.1 基于 vue-cli3 创建项目并集成 Electron

项目是用@vue/cli 3初始化的。我这边推荐直接用vue-cli-plugin-electron-builder这个插件来集成 Electron,它会帮你处理开发时的热更新、主进程文件打包、以及 electron-builder 的配置整合,比自己手动搭省很多事。

vue create pos-system cd pos-system vue add electron-builder

插件装完后,项目里会多出background.js(主进程入口)和electron-builder相关的配置项。默认情况下npm run serve是纯 Web 跑法,npm run electron:serve是带 Electron 壳的开发模式,npm run electron:build是打包。

一个关键点:整个项目里我只保留了 Vue 的渲染逻辑,串口的全部操作都写在主进程background.js和它引用的模块里。这样生产环境下渲染进程加载的是打包后的静态资源,主进程跑的是 Node 环境,两边完全隔离。

2.2 serialport 的安装与原生化适配

serialport 是 Node 的原生模块,它依赖node-gyp编译 C++ 代码。直接在 Electron 项目里npm install serialport只是装完了,真正运行时大概率报NODE_MODULE_VERSION不匹配的错误,这是因为 Node 原生模块的 ABI 版本必须和 Electron 内置的 Node ABI 版本一致。

解决办法是用@electron/rebuild重新编译。

npm install @electron/rebuild --save-dev npx electron-rebuild -f -w serialport

我在实际项目里还遇到一个更省事的方案:serialport 新版提供了@serialport/bindings-cpp预编译二进制,通过 prebuild 机制可以直接下载对应 Electron 版本的编译产物,只要网络能访问预编译下载源,就免去本地编译的麻烦。但为了保险,我还是把 electron-rebuild 的命令写进了 package.json 的 scripts 里,部署到新机器时跑一遍,确保万无一失。

2.3 主进程与渲染进程的 IPC 通信基础设置

主进程里开一个 IPC 处理通道,用ipcMain.handle暴露方法给渲染进程调用,同时用webContents.send主动推送串口数据。

// background.js(简化示例) const { app, BrowserWindow, ipcMain } = require('electron') const { SerialPort } = require('serialport') ipcMain.handle('serial:list', async () => { return SerialPort.list() }) ipcMain.handle('serial:open', async (event, options) => { // 打开串口逻辑 }) ipcMain.handle('serial:close', async () => { // 关闭串口逻辑 })

渲染进程里通过window.electronAPI调这些方法。我用 preload 脚本暴露安全接口,避免在渲染进程直接开 Node 能力。

// preload.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { listPorts: () => ipcRenderer.invoke('serial:list'), openPort: (options) => ipcRenderer.invoke('serial:open', options), closePort: () => ipcRenderer.invoke('serial:close'), onData: (callback) => { ipcRenderer.on('serial:data', (event, data) => callback(data)) } })

这里要特别强调:preload 里不要直接暴露 ipcRenderer 本体给页面,只暴露包装好的函数,避免页面脚本拿到完整 IPC 权限。这是 Electron 官方的安全建议,实际也确实是必须遵守的底线,不然一旦页面被注入脚本,攻击面就太大了。

3. 电子秤串口通信的核心实现

3.1 串口参数配置与硬件连接问题

串口参数是电子秤对接最容易出错的地方。不同的秤出厂默认参数不同,但绝大多数商用电子秤是这么一组常用配置:

参数常见值说明
波特率9600 / 4800大部分秤默认 9600,老款可能是 4800
数据位8几乎都是 8
停止位1偶尔有 2,极少见
校验位None部分秤支持偶校验,但默认 None
流控None无硬件流控

连接方式上,如果电脑主板自带 DB9 串口,直接用直连线接电子秤的串口;如果是 USB 转串口线,则需要装驱动。市面上常见的是 CH340 和 CP2102 芯片方案,建议优先买 CP2102 的线,兼容性和驱动稳定性更好,CH340 便宜但偶尔会遇到驱动冲突。

接线还有一个非常实际的坑:电子秤那边通常是 DB9 母头,电脑串口是 DB9 公头,需要的是直通线还是交叉线取决于秤的说明。不少秤的串口是 2、3、5 三线制,直接买一根 "RS232 直通线" 大概率没错,但最好第一次连接时用串口调试助手发指令验证一下,别上来就写代码。

在代码里打开串口时,要先把参数做成可配置项放到设置界面,因为不同门店可能用不同型号的秤。

const openWeightScalePort = (portPath, options = {}) => { return new Promise((resolve, reject) => { const port = new SerialPort({ path: portPath, baudRate: options.baudRate || 9600, dataBits: options.dataBits || 8, stopBits: options.stopBits || 1, parity: options.parity || 'none', autoOpen: false }) port.open((err) => { if (err) return reject(err) resolve(port) }) }) }

这里用autoOpen: false,先创建实例再手动open,方便在打开前挂好事件监听,避免数据在监听前就到达导致丢失。

3.2 电子秤通信协议解析

不同品牌电子秤的串口协议差别很大,但可以归纳为两类常见模式:

模式一:主动连续输出模式

秤上电后只要有重量变化,就主动往外吐数据。典型的数据帧格式是:

STX + 重量ASCII + 单位 + 校验 + ETX

比如某品牌秤输出:

02 4E 2B 30 30 30 2E 35 30 30 6B 67 0D 03

解析规则是:STX(0x02)开头,接着是N(表示正常重量),然后是+000.500kg这样的 ASCII 文本,最后是 CR(0x0D)和 ETX(0x03)。这种情况下,直接用文本方式解析就行,核心判断是找到开头和结尾。

模式二:请求响应模式

上位机先发一帧指令,秤收到后再返回当前重量。例如某协议要求发送00 0E 01,秤返回00 0E 02 30 30 30 2E 31 32 35 6B 67,中间那段 ASCII 就是重量文本。

我在实际项目里同时兼容了两种模式,通过一个配置开关来切换。解析的时候,主进程维护一个缓冲区,把收到的 Buffer 追加进去,然后不断查找合法的帧结构,提取出重量字符串。

function parseWeightBuffer(buffer, parserConfig) { const { startMarker, endMarker } = parserConfig let weight = null // 找起始符 const startIdx = buffer.indexOf(startMarker) if (startIdx === -1) return { weight, consumed: 0 } // 找结束符 const endIdx = buffer.indexOf(endMarker, startIdx) if (endIdx === -1) return { weight, consumed: 0 } const frame = buffer.slice(startIdx, endIdx + 1).toString('ascii') const match = frame.match(/([+\-]?\d+\.\d{2,3})/) if (match) weight = parseFloat(match[1]) return { weight, consumed: endIdx + 1 } }

注意 buffer 里可能有半个包的情况,所以解析完要保留未消费部分,等下一段数据拼接后再解析一次。这个逻辑是所有串口解析的基础,一旦漏掉,就会出现 "重量时有时无" 的诡异问题。

给一个实际项目中的协议配置结构参考:

const scaleProtocol = { model: 'example-scale', mode: 'active', // 或 'request' startMarker: Buffer.from([0x02]), endMarker: Buffer.from([0x0d, 0x03]), charset: 'ascii', requestCommand: Buffer.from([0x00, 0x0e, 0x01]) }

把这套配置也做成前端可编辑的设置项,存到本地配置文件里。因为门店换一台不同品牌的秤,改动不应该需要重新发布版本。

3.3 主进程串口监听与数据转发

主进程的核心工作模式:

function startListening(port, onDataCallback) { const parseState = { buffer: Buffer.alloc(0) } port.on('data', (chunk) => { parseState.buffer = Buffer.concat([parseState.buffer, chunk]) const result = parseWeightBuffer(parseState.buffer, currentProtocol) if (result.consumed > 0) { parseState.buffer = parseState.buffer.slice(result.consumed) } if (result.weight !== null) { onDataCallback(result.weight) } }) }

解析出重量后,通过mainWindow.webContents.send('serial:data', { weight, timestamp: Date.now() })推送到渲染进程。推送时要带上时间戳,这个时间戳在渲染进程里用于判断数据是否过期——如果连续几秒没收到新数据,界面要能提示 "秤连接异常" 而不是显示一个静止的旧重量,那是很危险的误导。

推送频率也要控制一下。有些秤的输出频率很高,每秒十几次,如果每次 push 都触发 Vue 的响应式更新,会有不必要的性能开销。我在主进程里做了节流,同一个重量值如果与上一次的差异小于 2g,并且距上次推送不超过 500ms,就合并推送一次。这样界面上的重量数字跳动的体验也更稳定。

3.4 渲染进程的重量展示与收银联动

渲染进程拿到的是主进程解析后的干净数据,Vue 这边的工作就纯粹多了。一个组件负责接收数据并更新显示,另一个业务逻辑负责把当前的重量和选中商品关联起来。

// Vue 组件中 onMounted(() => { window.electronAPI.onData((payload) => { const { weight, timestamp } = payload if (Date.now() - timestamp < 3000) { currentWeight.value = weight updateAmount() } }) })

商品计算逻辑是这样的:用户在界面上选中一个商品(比如苹果,单价 8.8 元/kg),把商品放到秤上,重量数据自动刷新,然后金额 = 重量 x 单价自动算出来。整个过程完全不需要收银员手动输入重量。

这里有一个重要的交互细节:重量稳定判断。电子秤刚放上商品时,数据会在一个区间内抖动,直接取当前值会让金额也跟着抖。我做了一个简单但有效的稳定判断:连续 3 次解析到的重量值,两两之间的差值都小于 2g,才认为重量稳定,此时更新显示并允许录入数据库。每次放上新商品后要重新触发这个判断。

function isStableWeight(newWeight) { const history = recentWeights.value history.push(newWeight) if (history.length > 3) history.shift() if (history.length < 3) return false const diff1 = Math.abs(history[0] - history[1]) const diff2 = Math.abs(history[1] - history[2]) return diff1 <= 2 && diff2 <= 2 }

这个判断逻辑简单可靠,比复杂的滤波算法更容易理解和维护。

界面显示上,我用了一个大号的数字面板展示当前重量,颜色会根据稳定状态变化:稳定为绿色,不稳定为橙色,无数据显示灰色。收银员一眼就能看出当前状态,不需要去看任何日志。

4. 打包发布与常见问题排查

4.1 electron-builder 打包配置与原生模块处理

打包这一步,很多人在 dev 模式下跑通了,结果一打包就废。核心原因是 native 模块在 asar 压缩包内无法直接加载,需要处理。

electron-builder 配置里有一个关键项:

{ "asarUnpack": [ "**/node_modules/@serialport/**", "**/node_modules/serialport/**" ] }

这样 serialport 相关的原生文件会被解包到app.asar.unpacked目录,运行时从那里加载。

完整的vue.config.js或 package.json 里 electron-builder 关键配置参考:

{ "electronBuilder": { "builderOptions": { "appId": "com.example.pos", "productName": "POS-System", "asar": true, "asarUnpack": [ "**/node_modules/@serialport/**", "**/node_modules/serialport/**", "**/node_modules/@msgpack/msgpack/**" ], "files": [ "dist/**/*", "electron/**/*" ], "win": { "target": "nsis" }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true } } } }

需要特别注意的是,files 的配置不能乱写。很多人习惯用"files": ["**/*"]把所有东西都打进去,结果把开发时的 node_modules 全部塞进安装包,体积膨胀到几百 MB 不说,还可能因为包含不同平台的二进制文件导致运行报错。

我实际的做法是:只把dist(渲染进程产物)和electron(主进程代码)以及必要的依赖声明放进去,electron-builder 会基于dependencies字段自动收集生产依赖。这里就要求 serialport 相关库必须放在dependencies而不是devDependencies,否则打包时压根不会带上。

4.2 高频踩坑实录与排查技巧

这部分是我最想写的,因为都是实际测试时一条条趟出来的经验。

坑一:serialport在 Electron 里报模块版本不匹配

现象:dev 模式启动后,主进程报Error: The module '...' was compiled against a different Node.js version。

排查:运行npx electron-rebuild -f -w serialport,如果不行,删掉 node_modules 和 package-lock.json 重新安装再 rebuild。

坑二:串口列表获取为空

现象:SerialPort.list()返回空数组。

原因:多数情况是驱动没装好,或者设备没插稳。检查设备管理器里是否出现了 COM 口;如果是 USB 转串口,看一下是不是识别成了未知设备。另外,在 Windows 下有些免驱方案实际是 RNDIS 网络设备,不是真正的串口。

坑三:数据乱码

现象:收到的数据是乱码,或者能看懂一部分但夹杂奇怪的字符。

原因:波特率不对、校验位不对、数据位和停止位不对。用串口调试助手逐个试参数,找到最稳定的组合。还有一个冷门可能:电子秤设置了打印输出模式而不是连续输出模式,走的是另一个通讯参数。

坑四:能读到数据但解析不出重量

现象:调试工具能看到 02 4E ... 0D 03 这样的十六进制帧,但代码里正则匹配不到重量。

原因:数据帧里可能包含 BCC 校验码或者扩展 ASCII 字符,正则需要改。也有可能是秤输出的重量格式是+00.500而你的正则要求必须是0.500。建议先把返回的原始 Buffer 转成十六进制打印出来,对着协议文档逐字节核对。

坑五:安装在门店机器上后串口打不开

现象:开发机上一切正常,安装到客户电脑上就报Access denied或Port not found。

原因:Windows 下可能是权限问题,也有可能是 64 位/32 位驱动不匹配;Linux 下通常是当前用户不在dialout用户组里。另外安装包的位数也要注意,如果打包成 32 位程序运行在 64 位系统上,驱动路径可能会有兼容问题。

坑六:称重界面偶发卡顿

现象:串口高频推送数据时,渲染进程 UI 偶尔卡顿。

原因:主进程推送频率太高,或者 Vue 组件里对重量对象的响应式追踪太深。解决方案是刚才说的节流,以及给重量显示区域用独立的非响应式变量(比如直接 DOM 更新)减少 Vue 的 diff 开销。

坑七:关闭应用时串口没释放

现象:应用退出后,串口还被占用,重启应用提示端口被占用。

原因:Electron 应用退出时没有正确调用port.close()。需要在app.on('before-quit')事件里做关闭操作。程序崩溃恢复不了的情况下,建议在serial:open之前先尝试打开,如果报错就提示用户重新插拔设备或重启应用。

坑八:Linux 下打包后运行找不到串口设备

现象:Ubuntu 上打包完运行,SerialPort.list()能找到设备,但打开时报/dev/ttyUSB0: No such file or directory。

原因:udev 规则问题。需要给当前用户加dialout组权限,或者写一个 udev 规则允许普通用户访问。这个在门店环境如果用了瘦客户机,很容易碰到,建议发布前就做好测试。

4.3 一套稳妥的现场调试流程

最后分享一个我每次到新门店调试设备都会走一遍的流程:

第一步,硬件确认。用串口调试助手连接目标 COM 口,确认驱动正常、能收发数据。这一步能把软件问题排除掉。

第二步,协议确认。用调试助手看电子秤返回的原始数据格式,确认是主动输出还是请求响应。如果是请求响应,把要发送的指令记下来。

第三步,软件配置。在收银系统的设置界面填入 COM 口号、波特率、协议模式,保存后重启通信模块。

第四步,桌面稳定性测试。连续放上商品、取走商品,模拟收银 10 分钟,确认重量显示稳定、金额计算正确、没有偶发不更新。

这套流程看着简单,但能避免至少一半的无谓排查时间。串口通信项目最忌讳的就是直接从代码开始调试,因为你永远不知道硬件、接线、驱动、协议配置哪个环节出了问题,一步步隔离才能快速定位。

5. 实际体验中的几个经验补充

讲了这么多实现细节,最后补几条我在实际做这个项目时比较深的感受。

一个是成本考量。Electron 应用体积确实大,安装包经常上百 MB,启动后占用内存 200~300 MB,对门店那种配置不高的收银机来说,多少有点心疼。所以在渲染进程里我尽量用了轻量级的 UI 库,关闭了不必要的动画和背景模糊效果,收银场景要的就是响应快、稳定,花哨的界面意义不大。

另一个是可靠性优先。收银系统的业务连续性要求很高,串口通信一旦出问题,秤就不能用了,收银员只能手输重量,效率直线下降。所以通信模块一定要做好自动重连和提示机制。我在主进程里加了一个心跳检测:如果超过 3 秒没有收到秤的任何数据,就认为通信异常,主动关闭串口重新打开,并推送一个提示给界面。这样很多临时的驱动假死、设备掉线的情况都能自动恢复,不用等收银员打电话报修。

还有一个是日志的重要性。串口通信不像 HTTP 请求,出问题几乎没有调用栈可看。我在主进程里把所有串口的关键事件都写入日志文件:打开参数、连接成功、数据帧原始值、解析结果、异常信息。配合日志轮转避免文件无限增长。真到了现场排查的时候,这份日志能救命。

如果后续还要扩展,比较自然的演进方向是:把串口通信层抽象成独立的 Node 服务,通过本地 Socket 或 HTTP 供 Electron 和其他应用共用;或者把数据采集逻辑独立成子进程,主进程崩溃了采集仍然继续。不过这是后话,当前这套方案在真实门店已经稳定跑了好几个月,核心目标算是达成了。

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

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

立即咨询