2026 年,浏览器与硬件设备的交互正在从「不可能」变为「标配」。Web Serial API 已在 Chrome 89+、Edge 89+、Opera 75+ 中默认启用,让网页可以直接与串口设备(Arduino、ESP32、GPS 模块、工业传感器)通信,无需安装驱动或桌面应用。根据 Chrome Platform Status 的数据,Web Serial API 的使用量在 2025-2026 年增长了 280%,被 Arduino IDE Web、Micro:bit 编辑器、工业物联网平台等广泛采用。如果你正在构建物联网管理后台、硬件调试工具或教育编程平台,Web Serial API 能让用户在浏览器中完成从代码编写到设备烧录的完整工作流。
🔌 一、Web Serial API 核心架构与串口通信基础
1.1 串口通信协议速览
串口通信(Serial Communication)是计算机与外部设备之间最古老也最可靠的通信方式之一。它通过一根数据线逐位(bit-by-bit)传输数据,核心参数包括:
- 波特率(Baud Rate):每秒传输的比特数,常见值为 9600、115200
- 数据位(Data Bits):每个数据帧的有效位数,通常为 7 或 8 位
- 停止位(Stop Bits):标记数据帧结束的位数,通常为 1 或 2 位
- 校验位(Parity):错误检测机制,可选 None/Even/Odd
数据帧结构(8N1 - 最常见配置):
┌───────┬──────────────────────┬─────────┬─────────┐
│ 起始位 │ 8 位数据 │ 无校验 │ 停止位 │
│ (0) │ D0 D1 D2 D3 D4 D5 D6 D7 │ (N) │ (1) │
└───────┴──────────────────────┴─────────┴─────────┘
每帧共 10 位,115200 波特率 ≈ 11520 字节/秒
💡 提示: Arduino 默认使用 8N1 配置(8 数据位、无校验、1 停止位),波特率为 9600 或 115200。ESP32 常用 115200 波特率。
1.2 Web Serial API 的安全模型
Web Serial API 遵循严格的权限模型,与 Web Bluetooth 和 WebUSB 类似。这套安全模型的设计理念是「最小权限 + 用户同意」——浏览器不会自动连接任何设备,每一次连接都需要用户明确授权。这种设计虽然增加了一点操作步骤,但有效防止了恶意网站在用户不知情的情况下读取硬件数据。值得注意的是,requestPort() 方法还支持传入过滤器参数,可以只显示特定厂商和产品 ID 的设备,减少用户在设备列表中查找的麻烦。
- 必须在安全上下文中运行(HTTPS 或 localhost)
- 必须由用户手势触发(click、keypress 等)
- 每次连接都需要用户手动选择设备(浏览器弹出设备选择对话框)
- 仅支持串口设备(不会列出 USB HID、MIDI 等非串口设备)
这个安全模型意味着你无法在后台静默连接设备——用户必须主动授权。这是浏览器安全沙箱的重要设计,防止恶意网站偷偷读取硬件数据。
1.3 浏览器兼容性与特性检测
与 WebUSB 和 Web Bluetooth 不同,Web Serial API 专注于传统串口通信。WebUSB 适用于 USB HID 设备(如自定义 USB 小工具),而 Web Serial API 则针对通过 USB 转串口芯片(如 CH340、CP2102、FTDI)虚拟出的串口设备。对于大多数 Arduino 和 ESP32 开发板来说,Web Serial API 是更直接的选择,因为它不需要你了解底层 USB 协议细节。一个重要的实际区别是:WebUSB 需要在设备固件中实现特定的 USB 描述符,而 Web Serial API 只需要设备有一个标准的串口接口即可工作。这意味着你现有的 Arduino 代码无需任何修改,就能直接通过浏览器访问。
// 特性检测:Web Serial API 可用性检查
if ('serial' in navigator) {
console.log('✅ Web Serial API 可用');
console.log('已授权设备数:', (await navigator.serial.getPorts()).length);
} else {
console.log('❌ 当前浏览器不支持 Web Serial API');
console.log('请使用 Chrome 89+ / Edge 89+ / Opera 75+');
console.log('⚠️ Firefox 和 Safari 暂不支持');
}
| 浏览器 | 支持版本 | 备注 |
|---|---|---|
| Chrome | 89+ | ✅ 默认启用 |
| Edge | 89+ | ✅ 默认启用,与 Chrome 共享内核 |
| Opera | 75+ | ✅ 默认启用 |
| Firefox | — | ❌ 未实现,Mozilla 有讨论但未承诺 |
| Safari | — | ❌ 未实现,Apple 未表态 |
| Chrome Android | 89+ | ⚠️ 需要 OTG 线和 USB 转串口芯片 |
🚀 二、核心 API 实战:连接、读写与流控
2.1 建立连接的完整流程
连接串口设备需要三步:请求端口 → 打开端口 → 配置参数。
// 完整的串口连接流程
async function connectSerial(baudRate = 115200) {
try {
// 第一步:请求用户选择串口设备(弹出系统对话框)
const port = await navigator.serial.requestPort();
// 第二步:打开串口并配置通信参数
await port.open({
baudRate: baudRate, // 波特率:115200 是 ESP32 默认值
dataBits: 8, // 数据位:8 位
stopBits: 1, // 停止位:1 位
parity: 'none', // 校验位:无
flowControl: 'none' // 流控:无(硬件流控需设备支持 RTS/CTS)
});
console.log(`✅ 串口已连接,波特率: ${baudRate}`);
return port;
} catch (error) {
if (error.name === 'NotFoundError') {
console.log('⚠️ 用户取消了设备选择');
} else if (error.name === 'NetworkError') {
console.log('❌ 设备连接失败,可能被其他程序占用');
} else if (error.name === 'InvalidStateError') {
console.log('❌ 端口已经打开,不能重复打开');
} else {
console.log('❌ 连接错误:', error.message);
}
throw error;
}
}
⚠️ 警告: 同一个串口设备只能被一个程序占用。如果你在 Arduino IDE 的串口监视器中打开了端口,浏览器将无法连接,反之亦然。连接前务必关闭其他串口工具。
2.2 读取数据:ReadableStream 与异步迭代
Web Serial API 使用 WHATWG Streams 标准进行数据读取,支持两种模式:
// 方式一:使用 ReadableStream 读取(适合连续数据流)
async function readSerialData(port) {
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log('🔌 串口已断开');
break;
}
// value 是 Uint8Array,需要解码为文本
const text = new TextDecoder().decode(value);
console.log('📩 收到数据:', text);
}
} catch (error) {
console.log('❌ 读取错误:', error.message);
} finally {
reader.releaseLock(); // 重要:释放 reader 锁,否则无法重新获取
}
}
// 方式二:使用 pipeTo 配合 TransformStream(适合数据转换管道)
async function readWithTransform(port, onLine) {
const decoder = new TextDecoderStream();
const lineStream = new TransformStream({
transform(chunk, controller) {
// 按行分割数据(Arduino Serial.println 输出以 \r\n 结尾)
this.buffer = (this.buffer || '') + chunk;
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop(); // 保留未完成的最后一行
for (const line of lines) {
if (line.trim()) controller.enqueue(line.trim());
}
}
});
const reader = port.readable
.pipeThrough(decoder)
.pipeThrough(lineStream)
.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
onLine(value); // 每收到一行数据触发回调
}
} finally {
reader.releaseLock();
}
}
💡 提示:
reader.releaseLock()非常关键。如果不释放锁,下次调用getReader()会抛出TypeError: ReadableStream is locked异常。在finally块中调用是最佳实践。
2.3 写入数据:WritableStream 与背压处理
// 写入字符串数据到串口
async function writeToSerial(port, data) {
const writer = port.writable.getWriter();
try {
// 将字符串编码为 Uint8Array
const encoded = new TextEncoder().encode(data);
await writer.write(encoded);
console.log('📤 已发送:', data.trim());
} catch (error) {
console.log('❌ 写入错误:', error.message);
} finally {
writer.releaseLock();
}
}
// 发送 AT 指令(ESP32/ESP8266 WiFi 模块常用)
async function sendATCommand(port, command, timeout = 2000) {
await writeToSerial(port, command + '\r\n');
// 等待响应,带超时机制
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`AT 指令超时: ${command}`));
}, timeout);
const reader = port.readable.getReader();
let buffer = '';
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += new TextDecoder().decode(value);
// AT 指令通常以 OK 或 ERROR 结尾
if (buffer.includes('OK\r\n') || buffer.includes('ERROR\r\n')) {
clearTimeout(timer);
reader.releaseLock();
resolve(buffer);
return;
}
}
} catch (e) {
clearTimeout(timer);
reject(e);
} finally {
reader.releaseLock();
}
})();
});
}
🔧 三、实战案例:构建浏览器端串口终端
3.1 完整的串口终端实现
下面是一个完整的浏览器端串口终端,支持实时数据收发、自动重连和数据帧解析。这个实现封装了 Web Serial API 的所有复杂性,对外暴露简洁的事件驱动接口。在实际项目中,你可以将这个类集成到 Vue 或 React 组件中,只需监听 onData 事件即可实时更新 UI。自动重连功能特别适合生产环境——当用户不小心拔掉 USB 线再插回去时,终端会自动恢复连接,无需用户手动操作。需要注意的是,自动重连依赖 navigator.serial 的 disconnect 事件,而该事件只在设备被物理断开时触发,如果你调用 port.close() 主动关闭则不会触发。
// 串口终端类:支持双向通信、自动重连、数据帧解析
class SerialTerminal {
constructor(options = {}) {
this.port = null;
this.baudRate = options.baudRate || 115200;
this.onData = options.onData || console.log;
this.onError = options.onError || console.error;
this.onConnect = options.onConnect || (() => {});
this.onDisconnect = options.onDisconnect || (() => {});
this.autoReconnect = options.autoReconnect ?? true;
this.lineBuffer = '';
this._reading = false;
}
async connect() {
if (this.port) {
try { await this.disconnect(); } catch {}
}
this.port = await navigator.serial.requestPort();
await this.port.open({
baudRate: this.baudRate,
dataBits: 8,
stopBits: 1,
parity: 'none'
});
this.onConnect();
this._startReading();
// 监听设备断开事件
navigator.serial.addEventListener('disconnect', (event) => {
if (event.target === this.port) {
this.onDisconnect();
if (this.autoReconnect) this._reconnect();
}
});
}
async disconnect() {
this._reading = false;
if (this.port) {
try {
await this.port.close();
} catch {}
this.port = null;
}
this.onDisconnect();
}
async send(data) {
if (!this.port?.writable) {
this.onError(new Error('串口未连接'));
return;
}
const writer = this.port.writable.getWriter();
try {
await writer.write(new TextEncoder().encode(data));
} finally {
writer.releaseLock();
}
}
async sendLine(text) {
await this.send(text + '\r\n');
}
async _startReading() {
this._reading = true;
const decoder = new TextDecoder();
while (this._reading && this.port?.readable) {
const reader = this.port.readable.getReader();
try {
while (this._reading) {
const { value, done } = await reader.read();
if (done) break;
this.lineBuffer += decoder.decode(value);
const lines = this.lineBuffer.split(/\r?\n/);
this.lineBuffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) this.onData(line.trim());
}
}
} catch (error) {
if (this._reading) this.onError(error);
} finally {
reader.releaseLock();
}
}
}
async _reconnect() {
console.log('🔄 尝试重连...');
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
try {
if (this.port) {
await this.port.open({ baudRate: this.baudRate });
this._startReading();
this.onConnect();
console.log('✅ 重连成功');
return;
}
} catch {}
}
this.onError(new Error('重连失败,请手动重新连接'));
}
}
// 使用示例
const terminal = new SerialTerminal({
baudRate: 115200,
onData: (line) => {
console.log(`[设备] ${line}`);
// 解析 JSON 数据(很多传感器输出 JSON 格式)
try {
const data = JSON.parse(line);
updateDashboard(data);
} catch {}
},
onConnect: () => updateStatus('已连接'),
onDisconnect: () => updateStatus('已断开'),
onError: (err) => console.error('错误:', err.message)
});
// 按钮触发连接
document.getElementById('connect-btn').addEventListener('click', async () => {
await terminal.connect();
});
// 发送命令
document.getElementById('send-btn').addEventListener('click', async () => {
const cmd = document.getElementById('cmd-input').value;
await terminal.sendLine(cmd);
});
3.2 Arduino 侧代码(配合使用)
下面的 Arduino 代码展示了如何将传感器数据格式化为 JSON 并通过串口发送。选择 JSON 格式而非自定义文本格式是一个重要的工程决策:JSON 是浏览器原生支持的格式,可以直接用 JSON.parse() 解析,无需编写自定义解析器。此外,JSON 格式具有自描述性,方便调试和日志记录——你可以直接在串口监视器中阅读数据,而不需要查阅协议文档。如果你的数据量较大或对带宽敏感,可以考虑使用 MessagePack 或 CBOR 等二进制格式替代 JSON,它们的体积通常只有 JSON 的 60-70%。
// Arduino 代码:传感器数据采集并通过串口发送 JSON
// 硬件:Arduino UNO + DHT22 温湿度传感器 + 光敏电阻
#include <DHT.h>
#define DHT_PIN 2
#define DHT_TYPE DHT22
#define LIGHT_PIN A0
DHT dht(DHT_PIN, DHT_TYPE);
void setup() {
Serial.begin(115200);
dht.begin();
pinMode(LIGHT_PIN, INPUT);
}
void loop() {
float temp = dht.readTemperature();
float humidity = dht.readHumidity();
int lightRaw = analogRead(LIGHT_PIN);
float lightPercent = map(lightRaw, 0, 1023, 0, 100);
// 输出 JSON 格式数据,方便浏览器端解析
Serial.print("{\"temp\":");
Serial.print(temp, 1);
Serial.print(",\"humidity\":");
Serial.print(humidity, 1);
Serial.print(",\"light\":");
Serial.print(lightPercent, 0);
Serial.print(",\"ts\":");
Serial.print(millis());
Serial.println("}");
delay(2000); // 每 2 秒采集一次
}
📊 四、数据帧解析与二进制协议
4.1 二进制数据帧解析
很多硬件设备使用二进制协议而非文本协议。二进制协议的优势在于数据密度高(不需要分隔符和字段名称)、解析速度快(定长字段可以直接偏移读取)、适合资源受限的嵌入式设备。但它的缺点也很明显:可读性差、调试困难、版本兼容性需要额外处理。
在实际工程中,你通常会遇到两种情况:一种是使用现成的工业协议(如 Modbus RTU),这种情况下建议直接使用成熟的 JavaScript 库(如 modbus-serial);另一种是设备使用自定义二进制协议,这种情况下你需要自己实现帧解析器。下面展示的是一种常见的自定义二进制帧格式,它包含了帧头同步、命令标识、变长数据和 CRC 校验四个关键要素,覆盖了大多数实际场景。
// 二进制数据帧解析器
// 帧格式: [帧头 0xAA 0x55] [命令 1B] [长度 2B] [数据 nB] [CRC16 2B]
class BinaryFrameParser {
constructor() {
this.buffer = new Uint8Array(0);
this.FRAME_HEADER = [0xAA, 0x55];
}
// 喂入新数据
feed(data) {
// 拼接缓冲区
const newBuffer = new Uint8Array(this.buffer.length + data.length);
newBuffer.set(this.buffer);
newBuffer.set(data, this.buffer.length);
this.buffer = newBuffer;
}
// 尝试解析一帧数据
parseFrame() {
// 查找帧头
let headerIndex = -1;
for (let i = 0; i < this.buffer.length - 1; i++) {
if (this.buffer[i] === 0xAA && this.buffer[i + 1] === 0x55) {
headerIndex = i;
break;
}
}
if (headerIndex === -1) {
// 没找到帧头,丢弃前面的垃圾数据
this.buffer = new Uint8Array(0);
return null;
}
// 丢弃帧头前的垃圾数据
if (headerIndex > 0) {
this.buffer = this.buffer.slice(headerIndex);
}
// 至少需要:帧头(2) + 命令(1) + 长度(2) = 5 字节
if (this.buffer.length < 5) return null;
// 解析数据长度(小端序)
const dataLength = this.buffer[3] | (this.buffer[4] << 8);
const totalFrameLength = 2 + 1 + 2 + dataLength + 2; // 帧头+命令+长度+数据+CRC
// 数据不够,等待更多数据
if (this.buffer.length < totalFrameLength) return null;
// 提取完整帧
const frame = this.buffer.slice(0, totalFrameLength);
// 验证 CRC16
const receivedCrc = frame[totalFrameLength - 2] | (frame[totalFrameLength - 1] << 8);
const calculatedCrc = this._crc16(frame.slice(0, totalFrameLength - 2));
if (receivedCrc !== calculatedCrc) {
console.warn(`⚠️ CRC 校验失败: 收到 0x${receivedCrc.toString(16)}, 计算 0x${calculatedCrc.toString(16)}`);
// 丢弃这一帧,继续从下一个字节查找
this.buffer = this.buffer.slice(2);
return this.parseFrame();
}
// 移除已解析的帧
this.buffer = this.buffer.slice(totalFrameLength);
return {
command: frame[2],
data: frame.slice(5, 5 + dataLength),
raw: frame
};
}
// CRC16-CCITT 校验
_crc16(data) {
let crc = 0xFFFF;
for (let i = 0; i < data.length; i++) {
crc ^= data[i];
for (let j = 0; j < 8; j++) {
crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1);
}
}
return crc;
}
}
// 使用示例:配合 Web Serial API
async function readBinaryFrames(port) {
const parser = new BinaryFrameParser();
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
parser.feed(value);
let frame;
while ((frame = parser.parseFrame()) !== null) {
console.log(`📦 收到帧: 命令=0x${frame.command.toString(16).padStart(2, '0')}, 数据长度=${frame.data.length}`);
handleFrame(frame);
}
}
} finally {
reader.releaseLock();
}
}
4.2 流控机制:软件流控 vs 硬件流控
当数据传输速度超过接收端处理能力时,需要流控(Flow Control)防止数据丢失。这是串口通信中最容易被忽视却最常导致问题的环节。很多开发者遇到「数据乱码」「部分数据丢失」等问题时,第一反应是怀疑代码有 bug,但实际上是流控配置不当导致的。
以 Arduino UNO 为例,它的硬件串口接收缓冲区只有 64 字节。如果传感器以 115200 波特率每 10ms 发送一帧 20 字节的数据,而你的代码每 100ms 才读取一次缓冲区,那么在两次读取之间会积攒约 100 字节的数据,远远超过 64 字节的缓冲区上限。超出部分会被静默丢弃,而且不会有任何错误提示。这就是为什么在高速通信场景下,流控至关重要。
| 流控方式 | 原理 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 无流控 | 不做任何控制 | 简单 | 高速时可能丢数据 | 低速、数据量小 |
| 软件流控 (XON/XOFF) | 发送特殊字符控制 | 不需要额外线 | 不能传输二进制数据 | 文本协议 |
| 硬件流控 (RTS/CTS) | 专用信号线控制 | 可靠、不占数据线 | 需要额外接线 | 高速、二进制数据 |
// 硬件流控连接(需要设备支持 RTS/CTS)
await port.open({
baudRate: 921600, // 高波特率时建议启用硬件流控
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'hardware' // 启用 RTS/CTS 硬件流控
});
⚠️ 警告: 波特率超过 460800 时,强烈建议启用硬件流控。在无流控的情况下,高速传输容易导致缓冲区溢出和数据丢失。Arduino UNO 的硬件串口缓冲区仅有 64 字节,极易溢出。
⚠️ 五、避坑指南与生产级注意事项
5.1 常见错误与解决方案
| 错误 | 原因 | 解决方案 |
|---|---|---|
NotFoundError |
用户取消设备选择 | 提示用户重新选择,不要自动重试 |
NetworkError |
设备被其他程序占用 | 关闭 Arduino IDE 串口监视器等工具 |
InvalidStateError |
端口已打开 | 先 port.close() 再重新打开 |
TypeError: ReadableStream is locked |
Reader 未释放 | 在 finally 中调用 reader.releaseLock() |
| 数据乱码 | 波特率不匹配 | 确认设备端和浏览器端波特率一致 |
| 数据丢失 | 缓冲区溢出 | 降低波特率或启用硬件流控 |
5.2 性能优化建议
- 使用
pipeThrough而非手动循环:浏览器内部对 Stream 管道有优化,性能比手动reader.read()循环高 15-20%。 - 批量处理小数据包:如果设备每秒发送大量小数据包(如传感器每 10ms 一次),使用
TransformStream合并后再处理,减少 UI 更新频率。 - 避免在读取循环中操作 DOM:将数据推入队列,用
requestAnimationFrame批量更新 UI。
// 高性能数据流处理:合并小包 + 批量 UI 更新
function createBatchedHandler(onBatch, interval = 50) {
const batch = [];
let rafId = null;
const flush = () => {
if (batch.length > 0) {
onBatch(batch.splice(0)); // 一次性处理所有积攒的数据
}
rafId = null;
};
return (data) => {
batch.push(data);
if (!rafId) {
rafId = requestAnimationFrame(flush);
}
};
}
// 使用:每帧最多更新一次 UI
const handleBatch = createBatchedHandler((items) => {
const latest = items[items.length - 1]; // 只显示最新数据
updateDisplay(latest);
});
5.3 安全最佳实践
📌 记住: Web Serial API 可以与任何串口设备通信,包括可能控制物理硬件的设备。在生产环境中,务必验证设备返回的数据合法性,防止恶意固件通过串口注入攻击。
- ✅ 始终验证接收到的数据格式和范围
- ✅ 对发送到设备的命令做白名单校验
- ✅ 设置合理的超时和看门狗机制
- ❌ 不要盲目执行设备返回的代码或指令
- ❌ 不要在未授权的页面中嵌入串口功能
💡 六、总结与相关资源
Web Serial API 为浏览器打开了与物理世界交互的大门。它的核心价值在于零安装——用户只需一根 USB 线和一个浏览器,就能完成设备调试、数据采集和固件更新。
⚡ 关键结论: Web Serial API 最适合以下场景:物联网管理平台(远程监控和配置设备)、教育编程工具(学生直接在浏览器中控制 Arduino)、工业设备调试(现场用手机/平板调试设备)。对于需要持续后台运行的场景,建议使用桌面应用(Electron/Tauri)+ Web Serial API 的组合方案。
相关工具与资源:
- 🔧 Web Serial API Polyfill — 基于 WebUSB 的 polyfill 方案
- 🔧 Arduino Lab for MicroPython — 使用 Web Serial API 的在线 IDE
- 🔧 ESP Web Tools — ESP32/ESP8266 在线刷机工具
- 🔧 jsjson.com 在线工具箱 — JSON 格式化、编码转换等开发者必备工具