使用 Web Serial API 能做的那些酷事
Web Serial API 允许网页向支持串口通信的物理设备开启读写字节流——包括 USB 转串口适配器、微控制器开发板、3D 打印机或已配对的蓝牙经典设备——无需原生应用或浏览器扩展。它与 MediaDevices(摄像头和麦克风)、WebUSB(原始 USB 接口)以及 WebHID(游戏手柄和键盘)同属浏览器硬件访问 API 体系:页面发起访问请求,浏览器弹出权限对话框,用户选择目标设备。
在着手规划周末项目之前,有必要先了解兼容性边界。根据 MDN 浏览器兼容性表,Web Serial 在桌面端支持 Chrome 89+、Edge 89+、Opera 75+ 和 Firefox 151+;Safari 尚未宣布支持计划。该 API 要求安全上下文(HTTPS 或 localhost),且调用 requestPort() 必须由用户手势触发——不能在页面加载时自动弹出端口选择框。本文梳理了六类可基于此构建的具体项目,每类项目均包含 Web Serial 专属代码、硬件配对方式,以及最容易踩到的坑。
核心要点
- Web Serial 在桌面端支持 Chrome 89+、Edge 89+、Opera 75+ 和 Firefox 151+;Safari 尚未宣布支持,且该 API 要求安全上下文和用户手势才能调用
requestPort()。 - Web Serial 与 WebUSB 独立存在,原因在于操作系统的串口驱动会在 WebUSB 介入之前抢先接管 USB 转串口适配器,因此枚举为 COM 端口或
/dev/tty节点的设备需要使用 Web Serial。 - 在调用
port.close()之前先调用reader.releaseLock(),可避免因流锁泄漏在重连时引发”端口已打开”错误。 - 自 Chrome 117 起,Web Serial 可通过
allowedBluetoothServiceClassIds和bluetoothServiceClassId过滤器与已配对的蓝牙经典 RFCOMM/SPP 设备通信——无需单独调用 WebBluetooth 即可实现无线串口通信。 - Chrome 130 新增了
SerialPort.connected布尔值,用于区分端口是物理在线状态还是已被应用关闭的状态。
最小化的连接-读取-写入-清理模式
所有 Web Serial 项目都从相同的四个步骤出发:请求端口、以指定波特率打开端口、通过流进行读写,以及在关闭前释放锁来完成清理。下面的代码片段是本文后续内容的参考基础。它使用可选的 filters 参数,通过 USB 厂商 ID 和产品 ID 来缩小浏览器端口选择器的范围——根据 WICG Web Serial 规范中的 SerialPortRequestOptions 定义,这会过滤掉无关的串口,使用户只看到相关设备。
// 必须在用户手势处理器内运行(例如点击事件监听器)。
const port = await navigator.serial.requestPort({
filters: [{ usbVendorId: 0x2341 }], // 可选;省略则表示"任意端口"
});
await port.open({ baudRate: 115200 });
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break; // reader 已被取消
console.log(value); // value 是一个 Uint8Array 数据块
}
} finally {
reader.releaseLock(); // 关闭前必须先释放锁
await port.close();
}
在调用 port.close() 之前忘记调用 reader.releaseLock() 是重连时出现”端口已打开”错误的常见原因——锁会持有可读流,而根据规范中 SerialPort.close() 的执行步骤,当流仍处于锁定状态时,close() 会被拒绝执行。对于文本协议,建议通过 TextEncoderStream 和 TextDecoderStream 对流进行管道处理,而不是手动解码原始 Uint8Array 数据块;MDN 的 Web Serial 指南涵盖了编码相关的详细说明。
Discover how at OpenReplay.com.
Web Serial API 示例:六个值得用周末实现的项目
Web Serial 最适合的六类项目分别是:以读取为主的调试工具、以写入为主的协议客户端、长时间运行的流式循环、实时数据可视化、浏览器驱动的显示渲染,以及设备配置界面。以下每个条目仅聚焦 Web Serial 专属逻辑,而非完整的应用实现。
1. 基于浏览器的串口监视器
串口监视器是一种以读取为主的调试工具,可以用浏览器标签页替代 Arduino IDE 的 Serial Monitor 或 screen /dev/ttyUSB0。它打开一个端口,通过解码器流式处理传入字节,并将解码后的行追加到 DOM 中。这是本文中最简单的项目,也是最适合入门的第一个构建,因为它在没有写协议复杂性的情况下充分演练了读取循环。
硬件/协议配对: 任何发送换行符分隔文本的 UART 设备——Arduino、ESP32 或 USB 转串口适配器。
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 9600 });
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // 保留末尾不完整的行
lines.forEach(line => appendToLog(line));
}
常见坑: 串口数据以任意块边界到达,而非完整的行——单次 read() 可能返回半行或三行,因此在渲染前需要缓冲数据直到遇到分隔符。
2. 固件烧录工具
固件烧录工具通过串口将编译好的二进制文件写入微控制器的 Flash,就像面向 ESP32 芯片的浏览器版 esptool 工具所做的那样。这是一个以写入为主且受协议约束的项目:在任何有效载荷传输之前,芯片必须先进入引导加载程序模式,数据需按照芯片的烧录协议进行帧封装。
硬件/协议配对: 通过 SLIP 帧封装的串口引导加载程序协议连接的 ESP32/ESP8266;入口序列由 DTR 和 RTS 控制信号驱动。
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
// 切换控制信号,强制 ESP 进入下载模式。
// 参见 Espressif 官方文档中的启动模式信号序列。
await port.setSignals({ dataTerminalReady: false, requestToSend: true });
await port.setSignals({ dataTerminalReady: true, requestToSend: false });
// ...现在通过 port.writable 发送帧封装的引导加载程序命令
ESPTool 风格的浏览器烧录工具必须按照特定顺序切换 DTR 和 RTS 控制信号,以在发送任何写命令之前强制芯片进入下载模式;Espressif 记录了启动模式信号行为,Web Serial 通过 SerialPort.setSignals() 暴露了这些切换操作。
常见坑: 跳过 DTR/RTS 引导加载程序序列会导致芯片停留在应用固件中而无法进入串口引导加载程序,从而无法正常完成烧录。
3. G-code 任务流式传输器
G-code 流式传输器将打印或切割任务逐行发送到 3D 打印机或 CNC 机器,每发送一行后等待固件确认,再发送下一行。这是一个带流量控制的长时间写入循环,构建难度明显高于”发完即忘”的写入器。
硬件/协议配对: 运行 Marlin 或 GRBL 固件的设备通过 USB 串口通信,以行为单位交换 G-code 指令,并使用 ok 作为确认响应。
const encoder = new TextEncoderStream();
encoder.readable.pipeTo(port.writable);
const writer = encoder.writable.getWriter();
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
for (const line of gcodeLines) {
await writer.write(line + "\n");
// 阻塞等待固件确认已处理该行。
let ack = "";
while (!ack.includes("ok")) {
const { value } = await reader.read();
ack += value;
}
}
G-code 流式传输器必须在每行发送后等待打印机的 ok 确认,才能发送下一行;RepRap G-code 参考文档定义了这一握手机制,若不遵守而持续灌入串口缓冲区,会在任务执行中途溢出固件的命令队列。
常见坑: ok 确认可能与温度报告及其他主动推送的行交错出现,因此应匹配 token 而不是假设读到的下一行就是确认响应。
4. 实时遥测仪表盘
遥测仪表盘从串口持续读取传感器数据流,并在浏览器中实时渲染为图表——温度、电压、加速度计各轴数据、GPS 定位信息等。Web Serial 部分仅是一个读取循环;其价值在于将解码后的数值直接传入运行在同一标签页中的图表库。
硬件/协议配对: 任何发送 CSV 行的传感器微控制器,或发送 NMEA 0183 语句的 GPS 模块。
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
const [t, v] = buffer.slice(0, nl).split(",");
pushSample(Number(t), Number(v)); // 向图表推送数据
buffer = buffer.slice(nl + 1);
}
}
常见坑: 高速传感器的数据发送速率可能超过图表的重绘速率;应批量处理采样数据并在 requestAnimationFrame 中更新,而不是每次 read() 都触发重渲染,否则主线程会卡顿。
5. 显示器或视觉控制器
显示控制器从浏览器渲染像素或帧,并将其推送到物理显示设备——LED 矩阵、OLED 屏幕或电子墨水屏。在这里,浏览器充当渲染引擎:你在 JavaScript 中计算位图或亮度数组,然后按照显示控制器所期望的格式写入字节。
硬件/协议配对: MAX7219 驱动的 LED 矩阵、SSD1306 OLED,或通过 UART 接受帧缓冲字节的微控制器后端的电子墨水屏模块。
const writer = port.writable.getWriter();
// 8x8 帧,每行一个字节(位为 1 表示 LED 亮起)。
function renderFrame(rows: number[]) {
return writer.write(Uint8Array.from(rows));
}
await renderFrame([
0b00111100, 0b01000010, 0b10100101, 0b10000001,
0b10100101, 0b10011001, 0b01000010, 0b00111100,
]);
常见坑: 写入操作不会自动与显示器的刷新节奏同步,因此从浏览器驱动动画可能导致画面撕裂或丢帧;每次更新发送完整的一帧,让微控制器处理显示器自身的时序,而不是流式传输部分行数据。
6. 设备配置界面
设备配置界面用于读写串口连接设备上的设置——无人机飞控参数、业余无线电的信道存储、IoT 模块的寄存器值。这正是 VID/PID 过滤模式发挥作用的场景:配置应用针对已知设备,通过厂商 ID 和产品 ID 过滤端口选择器,用户只会看到自己的设备,而不是机器上所有的 COM 端口。
硬件/协议配对: 使用 MSP 协议的飞控、无线电设备的 CAT/CI-V 协议,或任何具有文档化寄存器访问命令集的模块——通过 requestPort 过滤器进行选择。
// 将选择器范围缩小到特定厂商/产品。
const port = await navigator.serial.requestPort({
filters: [{ usbVendorId: 0x10c4, usbProductId: 0xea60 }], // 示例:CP210x
});
await port.open({ baudRate: 115200 });
const writer = port.writable.getWriter();
await writer.write(buildReadConfigCommand()); // 请求当前设置
// ...读取响应,填充表单字段,保存时写回
向 requestPort() 传入 filters: [{ usbVendorId, usbProductId }] 可将对话框范围缩小至匹配的端口,详见 SerialPortRequestOptions 规范。若要在用户回访时跳过对话框,navigator.serial.getPorts() 会返回用户已授权的端口列表。
常见坑: 选择器中显示的厂商/产品 ID 来自 USB 转串口桥接芯片(FTDI、CP210x、CH340),而非终端设备,因此多个不相关的产品可能共享相同的 usbVendorId——尽可能同时过滤 usbVendorId 和 usbProductId。
何时选用 Web Serial,何时选用其他方案
当设备枚举为 COM 端口或 /dev/tty 节点时,即操作系统已为其加载串口驱动时,应选用 Web Serial。Web Serial 与 WebUSB 独立存在,原因在于操作系统的串口驱动会在 WebUSB 介入之前抢先接管 USB 转串口适配器,WICG Web Serial 说明文档对此有直接描述。对于其他类型的设备,应选用对应的 API。
| API | 设备类型 | 权限模型 | 浏览器支持 | 最适用场景 |
|---|---|---|---|---|
| Web Serial | 串口/COM/tty 端口上的设备 | 用户手势选择器 | Chrome/Edge 89+、Opera 75+、Firefox 151+ | 微控制器、打印机、USB 转串口适配器 |
| WebUSB | 原始 USB 接口(无 OS 驱动) | 用户手势选择器 | 基于 Chromium 的浏览器 | 无串口驱动的自定义 USB 设备 |
| WebHID | 人机交互设备 | 用户手势选择器 | 基于 Chromium 的浏览器 | 游戏手柄、键盘、自定义 HID 外设 |
| Web Bluetooth | 蓝牙低功耗(GATT) | 用户手势选择器 | 基于 Chromium 的浏览器 | BLE 传感器、信标、可穿戴设备 |
| WebSocket + 后端守护进程 | 任意设备 | 服务器中介 | 所有浏览器 | 跨浏览器覆盖、服务端解析 |
如果你今天就需要支持 Firefox 和 Safari 用户,且设备逻辑可以放在服务端,那么一个通过 WebSocket 暴露接口的小型原生守护进程是可移植的备选方案——代价是需要一个 Web Serial 所不需要的安装步骤。
值得关注的近期新增功能
Chrome 117 为 Web Serial 添加了蓝牙经典 RFCOMM/SPP 支持,Chrome 130 新增了 SerialPort.connected 布尔值,用于区分物理在线的端口与应用已关闭的端口。这两项功能在大多数现有教程中都未提及,值得在项目中加以利用。
自桌面端 Chrome 117 起,Web Serial 可与已配对的蓝牙经典 RFCOMM/SPP 设备通信:向 requestPort() 传入 allowedBluetoothServiceClassIds(或 filters: [{ bluetoothServiceClassId }] 条目)可在选择器中显示自定义 RFCOMM 服务,服务类可通过 port.getInfo().bluetoothServiceClassId 读取——无需单独调用 WebBluetooth。此蓝牙路径仅限 Chromium(Chrome/Edge 117+、Opera 103+),在 MDN 上仍标记为实验性;Firefox 151 的 Web Serial 通用支持尚不包含蓝牙服务类选项。详细信息参见 Chrome 的 Serial over Bluetooth on the web 博文。这使无线串口通信与你已为 USB 编写的读写流完全一致。
Chrome 130 新增了 SerialPort.connected,这是一个布尔值,当端口物理在线但不一定处于打开状态时为 true。它让重连 UI 能够区分”设备已拔出”和”端口被应用关闭”——将其与 connect 和 disconnect 事件配合使用,可在不轮询的情况下驱动实时连接状态指示器。
当用户关闭 Web Serial 权限对话框时,requestPort() 会静默拒绝,而大多数实现不会将该拒绝渲染为可见状态,导致连接按钮仅仅回到默认状态,页面看起来什么都没发生。这一单一的用户体验失效模式贯穿上述所有六类项目。对 Web Serial 连接流程的会话回放往往会呈现出一种典型模式:用户在放弃之前连续点击连接按钮两三次——这正是拒绝状态未被传达的信号。请捕获 requestPort() 的拒绝并显示明确的”未选择设备”或”端口被占用”提示;被其他进程(Arduino IDE、screen 或 ModemManager)占用的端口也会以同样静默的方式失败。
选择一个类别,接入本文开头的最小化连接-读取-写入-清理代码块,并在投入一个周末之前确认兼容性矩阵与你的目标用户匹配。最快的上手路径是针对你已有的开发板实现一个串口监视器——一旦读取循环和清理逻辑稳定,其他五类项目都是同一字节流的变体。但在以下情况下,Web Serial 并非正确选择:你的用户包含 Safari 用户;设备未枚举为 COM 或 /dev/tty 节点(此时应选用 WebUSB、WebHID 或 Web Bluetooth);或者解析逻辑本就属于服务端——对于这些情况,一个通过 WebSocket 暴露接口的小型原生守护进程能以安装步骤为代价换来更广泛的覆盖。
常见问题
关闭权限对话框会导致 requestPort() 的 Promise 被拒绝而非解析,而大多数实现从不将该拒绝渲染为可见状态,因此按钮会静默回到默认状态。请将 requestPort() 调用包裹在 try/catch 中,并在拒绝时渲染明确的“未选择设备”提示。被其他进程(如 Arduino IDE、screen 或 ModemManager)占用的端口也会以同样静默的方式失败。
连接 Arduino 应使用 Web Serial,因为操作系统会加载串口驱动并接管 USB 转串口适配器,使开发板枚举为 COM 端口或 /dev/tty 节点,而 WebUSB 无法访问这类设备。WebUSB 适用于没有 OS 串口驱动的原始 USB 接口,例如自定义 USB 设备。如果设备显示为 COM 端口,就需要使用 Web Serial 而非 WebUSB。
可以。调用 navigator.serial.getPorts() 可获取用户在之前会话中已授权的端口数组,然后直接打开其中一个而无需再次提示。这对于回访用户完全跳过了用户手势选择器。在 Chrome 130 及更高版本中,SerialPort.connected 返回一个布尔值,指示端口是否物理在线,让重连 UI 能够区分设备已拔出和应用主动关闭端口这两种情况。
根据 MDN 的浏览器兼容性数据,Web Serial 在桌面端支持 Chrome 89+、Edge 89+、Opera 75+ 和 Firefox 151+。Safari 尚未宣布支持,也没有公开的路线图。该 API 还要求安全上下文(即 HTTPS 或 localhost)以及用户手势才能调用 requestPort()。如果你今天就需要广泛覆盖 Firefox 和 Safari,通过 WebSocket 暴露接口的原生守护进程是可移植的备选方案,代价是需要一个安装步骤。
重连时出现“端口已打开”错误几乎总是意味着流锁发生了泄漏。如果在 reader 仍持有可读流的情况下调用 port.close(),close 会因流被锁定而被拒绝。请始终在 port.close() 之前调用 reader.releaseLock(),最好放在 finally 块中,这样无论读取循环以何种方式退出,锁都能被释放。对于从 port.writable 获取的 writer,同样适用此规则。
Gain control over your UX
See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay. — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.