本文基于一个 Electron + Vue 项目的实战经验整理而成。核心难点不在「怎么收发数据」,而在于 serialport 是带原生 C++ 插件的 npm 包,在 Electron 里直接
import会编译失败、白屏或报Cannot find module。下面按「编译 → 打包 → 用法 → 实战」四步讲清。
一、背景:前端为什么能和串口打交道
串口(COM 口 / tty)本来是系统层的活儿,浏览器出于安全限制是碰不到的。能让我们用 JavaScript 操作串口,主要靠两条路:
- Node.js
serialport模块 + Electron
这是本文的主角。serialport 内部包含用 C++ 写的原生插件(绑定了操作系统底层的串口 API),因此必须在 Node / Electron 环境里运行,并且需要针对当前运行的 Electron 版本重新编译。
- 浏览器 Web Serial API
Chromium 系浏览器在 HTTPS 或 localhost 下可以通过 navigator.serial 直接收发串口数据,无需原生模块。但它有两大限制:
- ① 只支持 Chromium 内核;
- ② 纯网页环境、拿不到 Electron 主进程能力。
如果做的是 Electron 桌面应用,通常还是走方案 1 更稳。
本文聚焦方案 1:在 Electron + Vue 工程里用 serialport 与外接设备通信。
二、环境与原生模块编译
serialport 内含 C++ 原生插件,需要本地编译时依赖一套 C++ 工具链。常见平台(Windows / macOS / Linux 主流架构)安装时 prebuild-install 会自动下载编译好的 .node,npm install serialport 后基本直接能用,无需手动编译。
只有预编译不可用(网络问题、特殊架构、锁了很老的 serialport)时,才需要本地编译工具链:
- Python 3.x(加入 PATH;极老的 serialport 曾要求 2.7)
- Visual Studio 勾选「使用 C++ 的桌面开发」工作负载(装默认路径)
Electron 自带 Node 的 ABI 与系统 Node 不同,给系统 Node 装好的原生插件不能直接给 Electron 用,需对准 Electron 版本重编:
// electron-builder 项目:package.json 加 postinstall,install 后自动重编
{ "scripts": { "postinstall": "electron-builder install-app-deps" } }
# 通用方案
npx electron-rebuild
日常开发基本不用手动跑 node-gyp。
三、打包配置:externals 排除原生模块
打包器(Webpack / Rollup / Vite)处理不了原生 .node,强行打包会白屏或 Cannot find module。解决办法统一是:把 serialport 加进 externals,让运行时由 Electron 直接从 node_modules 以原生模块加载。用到的 @serialport/parser-* 也要一起排除。
3.1 vue-cli 项目
// vue.config.js
pluginOptions: {
electronBuilder: {
externals: ['serialport', '@serialport/parser-readline'],
}
}
3.2 Vite 项目
externals 写在 vite.config.js 引入的插件(devPlugin / buildPlugin)内部的 rollupOptions.external:
// devPlugin.js(节选)
rollupOptions: {
external: [
"electron",
"serialport", // ← 原生模块排除打包
"@serialport/parser-delimiter",
...builtinModules,
],
}
注意:
app.allowRendererProcessReuse = false是老教程写法,Electron 11 起已移除,新版本不用配。靠上面的 externals 即可让渲染进程直接require('serialport')。
四、serialport 基本用法(核心)
串口逻辑可写在主进程,也可(serialport 10.x 起)直接写在渲染进程。下面以 10.x / 12.x 通用写法为例。
版本差异:v10+ 用
const { SerialPort } = require('serialport')再new SerialPort({...});v8/v9 老项目直接const serialport = require('serialport'),serialport本身当构造器:new serialport(portName, { baudRate: 9600 }, false)。老版本常用port.setEncoding('hex')让data直接吐十六进制字符串,v10+ 同样支持。
4.1 列出可用串口
const { SerialPort } = require("serialport");
SerialPort.list()
.then(ports => ports.forEach(p => console.log(p.path, p.manufacturer)))
.catch(err => console.error(err));
同一端口在列表里可能重复,遍历时注意去重;Linux 用
sudo安装带--unsafe-perm。
4.2 构造参数
new SerialPort(options) 常用选项:
| 选项 | 说明 | 默认 |
|---|---|---|
path |
端口号(COMx / /dev/ttyX) | — |
baudRate |
波特率(须与设备一致) | — |
dataBits |
数据位 5/6/7/8 | 8 |
stopBits |
停止位 1/1.5/2 | 1 |
parity |
校验 none/even/odd/… | none |
autoOpen |
构造时是否自动打开 | true |
4.3 打开 / 关闭
const port = new SerialPort({ path: "COM3", baudRate: 9600, autoOpen: false });
port.open(err => (err ? console.error("打开失败:", err) : console.log("打开成功")));
port.close(err => (err ? console.error(err) : console.log("已关闭")));
4.4 接收数据(两种模式)
// flowing 模式:监听即触发,data 是 Buffer
port.on("data", data => console.log("收到:", data));
// paused 模式:需主动 read()
port.on("readable", () => console.log(port.read()));
常用事件:open / error / close / data / drain。
4.5 发送数据(注意 drain)
port.write("AT+START\r\n", err => err && console.error("发送失败:", err));
// write 返回不代表已发到设备,真正发完看 drain
port.drain(err => !err && console.log("发送完成"));
write 可传字符串 / Buffer / 字节数组,并指定编码(如 'hex')。
4.6 分包解析(解析器全家桶)
串口数据常分段到达,需用解析器拼包(均从 @serialport/parser-* 引入,记得加进 externals):
const { SerialPort } = require("serialport");
const { ReadlineParser } = require("@serialport/parser-readline");
const { ByteLengthParser } = require("@serialport/parser-byte-length");
const { DelimiterParser } = require("@serialport/parser-delimiter");
const { InterByteTimeoutParser } = require("@serialport/parser-inter-byte-timeout");
const port = new SerialPort({ path: "COM3", baudRate: 9600 });
port.pipe(new ReadlineParser({ delimiter: "\r\n" })) // 按行
.on("data", line => console.log("一行:", line));
port.pipe(new ByteLengthParser({ length: 8 })) // 按固定字节数(定长帧)
.on("data", chunk => console.log("8 字节:", chunk));
port.pipe(new DelimiterParser({ delimiter: ";" })) // 按分隔符(二进制协议更合适)
.on("data", chunk => console.log("分包:", chunk.toString()));
port.pipe(new InterByteTimeoutParser({ interval: 2000 })) // 静默超时出包(无结尾符的流)
.on("data", chunk => console.log("出包:", chunk));
定长 hex 帧用 ByteLengthParser 最省事;二进制协议用 DelimiterParser(按帧头切片)比 ReadlineParser 更通用。
五、生产实战(通用示例)
下面以一个「外接设备」为例,演示真实项目里常见的几个模式。把其中的硬件 ID、帧格式、业务解析换成你自己的即可。
5.1 按硬件 ID 定位设备(不写死 COM 口号)
const PNP_ID = "VID_XXXX&PID_XXXX"; // 替换成你设备的实际硬件 ID
function findPort() {
return SerialPort.list().then(ports => {
if (process.platform === "win32") {
return ports.find(item => item.pnpId?.includes(PNP_ID));
}
const pid = PNP_ID.split("PID_")[1];
return ports.find(item => item.productId?.includes(pid));
});
}
5.2 帧协议:帧头 + DelimiterParser
设备每帧以固定帧头起手(示例 AA 55 88 11),用 DelimiterParser 按帧头切片:
const { DelimiterParser } = require("@serialport/parser-delimiter");
const parser = port.pipe(new DelimiterParser({ delimiter: Buffer.from([0xaa, 0x55, 0x88, 0x11]) }));
parser.on("data", frame => {
// frame 是一帧完整数据,按你的业务协议解析即可
});
5.3 发送 + drain
function send(data, encoding = "hex") {
return new Promise((resolve, reject) => {
if (!port.isOpen) return reject("串口未打开");
port.write(data, encoding);
port.drain(err => (err ? reject(err) : resolve(true)));
});
}
5.4 拔插检测 + 状态机
设备可能拔线,用轮询 + 状态机兜底:
let state = "err"; // init / run / stop / err
setInterval(async () => {
try {
const info = await findPort();
if (state === "err") {
await new Promise(r => port.open(() => r()));
state = "run";
}
} catch {
state = "err"; // 拔掉后回到 err,插上重新初始化
}
}, 200);
收到数据后按你的业务协议解析(如转成具体数值、拼成文件等),不在本文范围。
六、渲染进程如何调用串口
方式 A:主进程 + IPC(职责清晰)
渲染进程不直接 require('serialport'),通过 ipcMain.handle + 预加载脚本 contextBridge 暴露方法,由主进程代发。原生模块始终只在主进程,渲染进程只管 UI。
方式 B:渲染进程直接 require(serialport 10.x 起可行)
前提是按第三节把 serialport 加进 externals,运行时由 Electron 直接加载原生模块,渲染进程即可 import 使用。更省事,适合中小型项目。
两种方式不矛盾:前者利于把串口逻辑收口在主进程,后者开发更快。
七、常见问题
node-gyp/MSBUILD报错:缺 Visual Studio「C++ 桌面开发」工作负载或 Python 不在 PATH。- 白屏 /
Cannot find module 'serialport':没把 serialport 加进externals,被打包器错误打包。 - 能加载但不通信:核对
baudRate/ 停止位 / 校验位是否与设备一致,用串口调试助手交叉验证硬件。 npm i其它包后 serialport 报错:Windows 经典坑,装好 serialport 后备份node_modules/serialport,损坏时替换回来。
八、完整示例:可复用的串口模块(新项目直接抄)
前面各节是分散的要点,这里收口成一个类加两种接线方式。复制到新项目后只改两处就能跑:① 设备的硬件 ID;② parseFrame 里的帧解析逻辑。其余(找设备、开关、发送、分包、拔插)都不用动。
8.1 封装 SerialDevice 类
// serial-device.js —— 复制到新项目,按需改 PNP_ID 与 parseFrame 即可
const { SerialPort } = require("serialport");
const { DelimiterParser } = require("@serialport/parser-delimiter");
const PNP_ID = "VID_XXXX&PID_XXXX"; // ① 换成你设备的实际硬件 ID
class SerialDevice {
constructor({ baudRate = 9600, frameHeader = [0xaa, 0x55, 0x88, 0x11] } = {}) {
this.baudRate = baudRate;
this.frameHeader = frameHeader; // 设备每帧的起始字节
this.port = null;
this.parser = null;
this.state = "err"; // init / run / stop / err
this._timer = null;
}
// 跨平台按硬件 ID 找设备,不写死 COM 口号
async findPort() {
const ports = await SerialPort.list();
if (process.platform === "win32") {
return ports.find(p => p.pnpId && p.pnpId.includes(PNP_ID));
}
const pid = PNP_ID.split("PID_")[1];
return ports.find(p => p.productId && p.productId.includes(pid));
}
async open() {
const target = await this.findPort();
if (!target) throw new Error("未找到设备");
this.port = new SerialPort({ path: target.path, baudRate: this.baudRate, autoOpen: false });
return new Promise((resolve, reject) => {
this.port.open(err => (err ? reject(err) : resolve()));
});
}
// 收到完整一帧后回调,frame 是 Buffer
onData(cb) {
this.parser = this.port.pipe(
new DelimiterParser({ delimiter: Buffer.from(this.frameHeader) })
);
this.parser.on("data", frame => cb(this.parseFrame(frame)));
}
// ② 按你的业务协议解析帧(定长 slice 指定区间,变长按帧尾/长度域算),返回业务数据
parseFrame(frame) {
return frame; // TODO: 换成你设备的解析逻辑
}
send(data, encoding = "hex") {
return new Promise((resolve, reject) => {
if (!this.port || !this.port.isOpen) return reject("串口未打开");
this.port.write(data, encoding);
this.port.drain(err => (err ? reject(err) : resolve(true)));
});
}
// 拔插检测:轮询设备,拔掉回 err,插上自动重连
startWatch(interval = 200) {
this._timer = setInterval(async () => {
try {
const info = await this.findPort();
if (!info) {
this.state = "err";
return;
}
if (this.state === "err") {
await this.open();
this.state = "run";
}
} catch {
this.state = "err";
}
}, interval);
}
close() {
clearInterval(this._timer);
this._timer = null;
if (this.port && this.port.isOpen) this.port.close();
this.state = "stop";
}
}
module.exports = SerialDevice;
baudRate / dataBits / stopBits / parity 按设备修改;parseFrame 里写你的帧格式解析。拿到这个类,下面决定它跑在哪个进程。
8.2 方式 A:主进程 + IPC(推荐,职责清晰)
原生模块放主进程,渲染进程只管 UI。三段代码对上即可:
主进程(background.js / main.js):
const { ipcMain } = require("electron");
const SerialDevice = require("./serial-device");
const device = new SerialDevice();
device.onData(frame => win.webContents.send("serial:data", frame)); // 主动推给页面
ipcMain.handle("serial:open", () => device.open());
ipcMain.handle("serial:send", (_, data) => device.send(data));
ipcMain.handle("serial:close", () => {
device.close();
});
预加载脚本(preload.js)用 contextBridge 把方法暴露给页面:
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("serial", {
open: () => ipcRenderer.invoke("serial:open"),
send: data => ipcRenderer.invoke("serial:send", data),
close: () => ipcRenderer.invoke("serial:close"),
onData: cb => ipcRenderer.on("serial:data", (_, frame) => cb(frame)),
});
Vue 组件里直接用:
// 打开设备
await window.serial.open();
// 发送(hex 帧)
await window.serial.send("AA5588110001CCCC", "hex");
// 收数据
window.serial.onData(frame => console.log("收到:", frame));
8.3 方式 B:渲染进程直接 import(更省事)
只要按第三节把 serialport 加进了 externals,渲染进程就能直接 import 那个类,不用走 IPC:
import SerialDevice from "@/serial/device"; // 路径按你项目结构放
const device = new SerialDevice();
await device.open();
device.onData(frame => {
/* 直接更新 Vue 响应式数据 */
});
device.startWatch(); // 启用拔插检测
Vite 渲染进程是 ESM,把
serial-device.js改成export default class SerialDevice { ... }即可import;主进程仍是 CJS,用require+module.exports。
两种方式不矛盾:前者把串口逻辑收口在主进程、好维护;后者开发更快,适合中小型项目。
参考文档: