前端 JavaScript 实现与外接设备之间的串口通信(Electron + Vue + serialport)


本文基于一个 Electron + Vue 项目的实战经验整理而成。核心难点不在「怎么收发数据」,而在于 serialport 是带原生 C++ 插件的 npm 包,在 Electron 里直接 import 会编译失败、白屏或报 Cannot find module。下面按「编译 → 打包 → 用法 → 实战」四步讲清。

一、背景:前端为什么能和串口打交道

串口(COM 口 / tty)本来是系统层的活儿,浏览器出于安全限制是碰不到的。能让我们用 JavaScript 操作串口,主要靠两条路:

  1. Node.js serialport 模块 + Electron

这是本文的主角。serialport 内部包含用 C++ 写的原生插件(绑定了操作系统底层的串口 API),因此必须在 Node / Electron 环境里运行,并且需要针对当前运行的 Electron 版本重新编译。

  1. 浏览器 Web Serial API

Chromium 系浏览器在 HTTPS 或 localhost 下可以通过 navigator.serial 直接收发串口数据,无需原生模块。但它有两大限制:

  • ① 只支持 Chromium 内核;
  • ② 纯网页环境、拿不到 Electron 主进程能力。

如果做的是 Electron 桌面应用,通常还是走方案 1 更稳。

本文聚焦方案 1:在 Electron + Vue 工程里用 serialport 与外接设备通信。

二、环境与原生模块编译

serialport 内含 C++ 原生插件,需要本地编译时依赖一套 C++ 工具链。常见平台(Windows / macOS / Linux 主流架构)安装时 prebuild-install自动下载编译好的 .nodenpm 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,损坏时替换回来。

八、完整示例:可复用的串口模块(新项目直接抄)

前面各节是分散的要点,这里收口成一个类加两种接线方式。复制到新项目后只改两处就能跑① 设备的硬件 IDparseFrame 里的帧解析逻辑。其余(找设备、开关、发送、分包、拔插)都不用动。

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

两种方式不矛盾:前者把串口逻辑收口在主进程、好维护;后者开发更快,适合中小型项目。

参考文档:


文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
 上一篇
serialport 本地编译失败怎么办:从环境到 electron-rebuild 全流程 serialport 本地编译失败怎么办:从环境到 electron-rebuild 全流程
serialport 是带 C/C++ 原生插件的 npm 包,安装时常遇到 node-gyp / MSBUILD 编译报错、Electron 下 ABI 不匹配等问题。本文讲清原生模块「预编译优先、本地编译兜底」的原理,以及 Windows 环境准备、手动 node-gyp 的 configure/build、Electron 场景的 electron-rebuild / electron-builder install-app-deps,并列出常见报错排查。
2026-07-28
下一篇 
无后端表单方案完全手册 无后端表单方案完全手册
静态网站无需编写后端即可收集用户留言并推送到邮箱的完整方案手册,对比 Web3Forms、Formspree、FormSubmit 等服务的用法、额度与常见坑点。
2026-07-24
  目录