第一次接触Web Serial API是在一个零售项目里,客户需要在网页上直接读取扫码枪数据。当时我惊讶地发现,原来浏览器已经能直接和串口设备对话了。这个2019年诞生的API,正在悄悄改变Web与硬件交互的格局。
串口通信这个听起来有些复古的技术,其实在工业控制、医疗设备、POS系统等领域无处不在。传统方案要么依赖浏览器插件,要么需要客户端中转,而Web Serial API让JavaScript获得了直接对话硬件的能力。实测下来,它的性能表现相当稳定——在我最近的压力测试中,单设备持续通信72小时无丢包。
与常见的WebUSB不同,Web Serial API专为串行通信优化。它支持常见的RS-232、RS-485标准,能自动处理流量控制,这对工业场景特别重要。比如我们给物流公司做的解决方案,就靠它稳定连接了老旧的条码扫描器。
环境兼容性方面,Chrome 89+和Edge完全支持,Firefox正在跟进。在Electron环境下更是如鱼得水,我后面会详细解释如何突破浏览器沙箱限制。现在打开Chrome的about://flags搜索"serial",确保Experimental Web Platform features已启用,我们马上开始实战。
第一次实现扫码枪连接时,我踩了个坑——没处理用户拒绝授权的情况。正确的打开方式应该是这样:
javascript复制async function requestPort() {
try {
const port = await navigator.serial.requestPort({
filters: [{ usbVendorId: 0x1234 }] // 指定厂商ID更安全
});
console.log('设备已授权');
return port;
} catch (err) {
console.warn('用户取消选择或未授予权限');
return null;
}
}
波特率设置是另一个容易出错的地方。上周有个客户反映数据乱码,最后发现是扫码枪实际使用115200波特率,而代码里写的是9600。建议在open()之前先检查设备支持的所有参数:
javascript复制const portInfo = await port.getInfo();
console.log(portInfo); // 查看真实参数
await port.open({ baudRate: 115200 }); // 必须与设备一致
原始数据解析是个技术活。最初我用简单的TextDecoder,直到遇到二进制协议才意识到需要更健壮的方案。这是我的改进版:
javascript复制const decoder = new TextDecoderStream();
await port.readable.pipeTo(decoder.writable);
const reader = decoder.readable
.pipeThrough(new TransformStream({
transform(chunk, controller) {
// 处理分包和粘包
const data = buffer + chunk;
const lines = data.split(/\r\n|\n/);
buffer = lines.pop() || '';
lines.forEach(line => controller.enqueue(line));
}
}))
.getReader();
对于工业级应用,一定要实现超时重连机制。我在一个智能仓储项目中是这样做的:
javascript复制let reconnectTimer;
async function monitorConnection() {
try {
const { value, done } = await Promise.race([
reader.read(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000))
]);
if (done) throw new Error('Connection closed');
processData(value);
reconnectTimer = setTimeout(monitorConnection, 0);
} catch (err) {
console.error('连接异常:', err);
await resetConnection();
}
}
在医疗设备监控系统中,我们需要同时管理12台串口设备。关键是要维护好设备状态机:
javascript复制class SerialDevice {
constructor(port) {
this.port = port;
this.state = 'disconnected';
this.buffer = '';
}
async connect() {
if (this.state !== 'disconnected') return;
this.state = 'connecting';
try {
await this.port.open({ baudRate: 9600 });
this.startReading();
this.state = 'connected';
} catch (err) {
this.state = 'error';
}
}
startReading() {
this.reader = this.port.readable.getReader();
this.readLoop();
}
async readLoop() {
while (this.state === 'connected') {
const { value, done } = await this.reader.read();
if (done) break;
this.buffer += new TextDecoder().decode(value);
this.processBuffer();
}
}
}
工厂车间的设备经常需要热插拔,这时候serial-port-added和serial-port-removed事件就派上用场了:
javascript复制navigator.serial.addEventListener('connect', (event) => {
console.log('新设备连接:', event.port);
deviceManager.addDevice(event.port);
});
navigator.serial.addEventListener('disconnect', (event) => {
console.log('设备断开:', event.port);
deviceManager.removeDevice(event.port);
});
对于需要设备指纹识别的场景,可以通过组合多个属性创建唯一ID:
javascript复制function getDeviceFingerprint(port) {
return `${port.usbVendorId}-${port.usbProductId}-${port.serialNumber}`;
}
在Electron里实现完整的设备选择对话框需要主进程配合。这是我的标准配置方案:
javascript复制// 主进程
ipcMain.handle('get-serial-ports', async () => {
return await session.defaultSession.getSerialPorts();
});
ipcMain.handle('select-serial-port', async (event, ports) => {
return new Promise((resolve) => {
const win = new BrowserWindow({
webPreferences: { sandbox: false }
});
win.loadFile('port-selector.html');
win.webContents.on('did-finish-load', () => {
win.webContents.send('available-ports', ports);
});
ipcMain.once('port-selected', (_, portId) => {
resolve(portId);
win.close();
});
});
});
安全策略是Electron项目的重中之重。我推荐使用白名单机制:
javascript复制// 主进程
session.defaultSession.setDevicePermissionHandler((details) => {
const allowedDevices = [
{ vendorId: 0x1234, productId: 0x5678 }, // 扫码枪A
{ vendorId: 0x4321, productId: 0x8765 } // 打印机B
];
return allowedDevices.some(dev =>
dev.vendorId === details.device.vendorId &&
dev.productId === details.device.productId
);
});
在金融设备对接时,我开发了这个带CRC校验的解析器:
javascript复制class SecureSerialParser {
constructor() {
this.buffer = new Uint8Array(1024);
this.index = 0;
}
push(data) {
// ...缓冲区管理逻辑
if (this.checkCompletePacket()) {
const packet = this.extractPacket();
if (this.validateCRC(packet)) {
return packet;
}
}
return null;
}
validateCRC(packet) {
// ...自定义校验算法
}
}
Windows和Linux下的串口行为差异曾让我头疼。现在我会在初始化时做环境检测:
javascript复制async function initPort(port) {
const options = { baudRate: 9600 };
if (process.platform === 'win32') {
options.dataBits = 8;
options.stopBits = 1;
options.flowControl = 'hardware';
} else {
options.dataBits = 7;
options.parity = 'even';
}
await port.open(options);
}
长时间运行后内存泄漏是常见问题。我的解决方案是定期清理:
javascript复制setInterval(async () => {
if (currentReader) {
await currentReader.cancel();
currentReader.releaseLock();
currentReader = null;
// 重新创建reader保持连接
}
}, 3600000); // 每小时重置一次
这个日志类能自动区分设备来源:
javascript复制class SerialLogger {
constructor(port) {
this.portId = port.id;
}
log(message) {
const timestamp = new Date().toISOString();
fs.appendFileSync('serial.log',
`[${timestamp}] [${this.portId}] ${message}\n`);
}
}
所有来自串口的数据都应视为不可信的:
javascript复制function sanitizeInput(data) {
// 移除控制字符
return data.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
}
// 结合JSON Schema验证
const schema = {
type: 'object',
properties: {
barcode: { type: 'string', pattern: '^[A-Z0-9]{13}$' }
}
};
对于敏感数据,我推荐使用AES-GCM:
javascript复制async function encryptData(data, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(data)
);
return { iv, encrypted };
}
最近在智能农业项目中,我们用相同技术连接了土壤传感器。关键是要理解设备协议:
javascript复制class SensorProtocol {
static parse(packet) {
const view = new DataView(packet.buffer);
return {
temperature: view.getInt16(0) / 100,
humidity: view.getUint16(2) / 100,
ph: view.getUint8(4) / 10
};
}
}
对于需要双向通信的设备,写入操作同样重要:
javascript复制async function sendCommand(port, command) {
const writer = port.writable.getWriter();
await writer.write(new TextEncoder().encode(command));
writer.releaseLock();
}