浏览器里直接跟串口设备打交道,这件事在几年前还只能靠 Electron 套壳或者本地装个驱动桥接程序来实现。WebSerial API 落地之后,前端工程师第一次可以用纯 Web 技术栈读写串口,而 "WebSerial Terminal" 这个项目标题指向的,正是把这套能力封装成一个开箱即用的网页终端。它解决的核心痛点很具体:调试单片机、路由器、工控板卡的时候,不用再装 SecureCRT、Putty 或者一堆串口助手,打开浏览器、点一下授权、选个波特率就能收发数据。适合谁看?嵌入式开发者、物联网方向的前端、做硬件测试的工具链工程师,以及任何需要频繁跟串口打交道的技术人。下面我按实际落地时会遇到的顺序,把这件事从头拆到尾。
1. 先搞清楚 WebSerial 到底能碰哪些设备
1.1 浏览器串口能力的真实边界
WebSerial 不是"网页版串口助手"这么简单,它的能力边界决定了整个终端的设计思路。核心 API 挂在navigator.serial上,提供requestPort()让用户主动选择设备,拿到SerialPort对象后再open()建立连接,之后通过port.readable和port.writable两个流做双向通信。这里有个关键点:浏览器不允许脚本自动枚举并连接串口,必须由用户手势(点击按钮之类)触发requestPort(),这是安全模型决定的,绕不过去。
这意味着终端 UI 的第一个交互必然是"选择设备"按钮,而不是像桌面软件那样一打开就列出所有 COM 口。我一开始想做个自动重连,结果发现getPorts()只能返回用户之前授权过的端口,首次连接永远得手动点。这个限制反而让权限管理变清晰了:用户授权过的设备会持久化,下次打开页面可以静默重连,没授权过的必须走一次选择流程。
另一个边界是串口参数的可配置范围。open()接受baudRate、dataBits、stopBits、parity、bufferSize、flowControl这几个参数。波特率支持任意数值,但实际硬件通常就跑 9600、115200、921600 这几档。数据位只支持 7 和 8,停止位 1 或 2,校验位 none/even/odd。流控这块要注意,flowControl可选"none"或"hardware",软件流控(XON/XOFF)在 WebSerial 里没有原生支持,得自己在数据层实现,这是个容易踩的坑。
1.2 和传统串口工具的能力对照
很多人第一反应是"这玩意儿能替代 SecureCRT 吗",我列个表把差异说清楚,免得预期错位。
| 能力项 | WebSerial Terminal | 传统桌面串口工具 |
|---|---|---|
| 安装成本 | 零安装,开浏览器即用 | 需下载安装,配置驱动 |
| 跨平台 | 有 Chromium 内核即可 | 分 Windows/Linux/macOS 版本 |
| 设备枚举 | 需用户手动授权 | 自动列出所有串口 |
| 脚本自动化 | 原生 JS,可编程性极强 | 依赖宏或脚本引擎 |
| 大数据量吞吐 | 受浏览器流处理能力限制 | 通常更稳 |
| 十六进制显示 | 需自行实现 | 多数内置 |
| 日志落盘 | 需借助 File System Access API | 直接写文件 |
| 后台常驻 | 页面关闭即断 | 可后台运行 |
从表里能看出来,WebSerial Terminal 的强项是零部署和可编程,弱项是后台能力和极端吞吐。所以它的定位不是替代重型工具,而是覆盖"临时调试、远程协助、教学演示、CI 环境下的设备交互"这些场景。我实际用下来,115200 波特率下持续收发几 MB 数据完全没问题,再往上到 921600 就得注意读取循环的写法了。
1.3 浏览器兼容性与运行前提
目前稳定支持 WebSerial 的是 Chromium 系浏览器,Chrome 89+、Edge 89+ 都可以。Firefox 和 Safari 至今没有实现,这是硬伤,做产品的话得在页面上做能力检测并给出降级提示。检测方式很简单:
if (!("serial" in navigator)) { // 提示用户当前浏览器不支持,建议换 Chromium 内核浏览器 }还有个前提容易被忽略:页面必须在安全上下文下运行。https://或者localhost都算安全上下文,但如果你把页面部署到http://的局域网 IP 上,navigator.serial直接就是 undefined。我见过有人在内网http://192.168.x.x上调试半天,最后发现是协议问题。解决办法要么上 HTTPS 证书,要么用 localhost 做端口转发。
2. 终端核心:读写流的正确处理方式
2.1 读取循环为什么不能写成 while(true)
串口读取最容易写错的地方,就是用一个死循环不停reader.read()。看起来能跑,但设备拔掉或者页面切换时,这个循环会变成僵尸,报一堆 "The device has been lost" 之类的错误。正确的做法是把读取循环绑定到 readable 流的生命周期上,用pipeTo或者手动管理 reader 的释放。
我推荐的手动管理写法是这样:
async function readLoop(port, onData) { while (port.readable) { const reader = port.readable.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; if (value) onData(value); // value 是 Uint8Array } } catch (e) { // 设备断开或读取异常,跳出内层循环 console.error("read error", e); } finally { reader.releaseLock(); } } }这里的关键设计是外层 while 检查port.readable是否存在。当设备断开时,port.readable会变成 null,外层循环自然退出。内层用 try/catch 兜住读取异常,finally 里释放锁,避免锁泄漏导致后续无法重新打开。这个结构我踩过坑才总结出来:早期版本没加 releaseLock,设备重插之后一直报 "The port is already locked",排查了很久。
2.2 写入时的背压与分片
写入比读取简单,但有个背压问题。writer.write()返回的 Promise 在数据真正进入缓冲区后才 resolve,如果你一次性写一个很大的 buffer,可能会阻塞。对于终端场景,通常输入都是几十字节的命令,问题不大。但如果要做文件传输,就得手动分片:
async function writeChunked(port, data, chunkSize = 1024) { const writer = port.writable.getWriter(); try { for (let i = 0; i < data.length; i += chunkSize) { await writer.write(data.slice(i, i + chunkSize)); } } finally { writer.releaseLock(); } }分片大小我一般取 1024 字节,实测在 115200 波特率下比较稳。太大容易触发流控等待,太小则写入调用过于频繁。另外要注意,写入和读取用的是两个独立的锁,可以并发进行,但同一个流上不能同时有两个 writer。
2.3 数据编码:文本与十六进制的双模式
终端要同时支持文本模式和十六进制模式,这是刚需。文本模式下用TextDecoder解码,十六进制模式下直接把Uint8Array转成 hex 字符串显示。这里有个细节:TextDecoder 要处理跨 chunk 的多字节字符。比如一个 UTF-8 中文字符占 3 字节,如果刚好被切在两个 chunk 之间,直接解码会出乱码。解决办法是用TextDecoder的stream: true选项:
const decoder = new TextDecoder("utf-8"); // 每次 decode 时传 { stream: true },它会缓存不完整的字节序列 const text = decoder.decode(chunk, { stream: true });十六进制显示则要注意格式化,我习惯每字节两位、空格分隔,每 16 字节换一行,这样对齐好看。发送侧如果用户输入的是 hex 字符串,得先解析成字节数组再写,解析时要过滤空格和非法字符,否则parseInt会返回 NaN 导致写入失败。
3. 从零搭一个能用的终端界面
3.1 界面布局的最小可用集
一个能用的串口终端,界面上必须有这几块:设备选择与连接控制区、串口参数配置区、数据收发显示区、发送输入区。我用的是最朴素的三段式布局:顶部工具栏放连接按钮和参数下拉,中间是占满剩余高度的输出区,底部是输入框加发送按钮。
输出区用<pre>或者等宽字体的<div>,关键是自动滚动到底部。实现上监听内容变化,把scrollTop设为scrollHeight即可。但要注意,如果用户手动往上滚看历史,就别强制拉回底部了,得判断当前是否已经在底部附近:
function isNearBottom(el, threshold = 40) { return el.scrollHeight - el.scrollTop - el.clientHeight < threshold; }只有isNearBottom为 true 时才自动滚动,这个细节能大幅提升翻阅历史日志时的体验。我一开始没做这个判断,用户想回看前面的输出,结果每来一条新数据就被拽到底部,非常烦。
3.2 参数配置的默认值与持久化
串口参数里最常改的是波特率,默认给 115200 比较合理,因为现在大部分开发板和模块都跑这个。数据位 8、停止位 1、校验 none 是绝对主流,可以做成默认。这些配置我建议存到 localStorage,下次打开自动恢复,省得每次重选。
参数下拉的选项不要写死太多,波特率给 9600、19200、38400、57600、115200、230400、460800、921600 这几档就够了,再多的用输入框自定义。这里有个经验:参数必须在连接前设置好,连接后再改需要先 close 再 open,WebSerial 不支持运行时动态改波特率。所以 UI 上参数区在连接后应该置灰,避免用户误操作。
3.3 发送区的几个实用功能
发送区除了基本的文本发送,我加了三个实用功能:行尾符选择(无、CR、LF、CRLF)、十六进制发送开关、历史命令上下键回溯。行尾符这个太重要了,很多设备的命令行必须收到 CR 或 LF 才会执行,没有这个选项用户会以为设备没反应。实现上就是在发送内容后面拼接对应的字节。
历史命令回溯用数组存最近 50 条,监听输入框的 keydown 事件,上下键切换索引。这个小功能用起来很顺手,尤其是反复调试同一条 AT 指令的时候。十六进制发送则是在发送前把输入字符串按 hex 解析,解析失败给个红色提示,别静默失败。
4. 那些文档里不会写的坑
4.1 设备热插拔与断线重连
串口设备被拔掉是家常便饭,尤其是 USB 转串口线接触不良的时候。WebSerial 提供了navigator.serial.addEventListener("disconnect", ...)事件,可以监听设备断开。但要注意,断开事件触发后,port 对象就失效了,必须重新requestPort()或者从getPorts()里拿新的。
我的处理策略是:断开时在界面上明确提示"设备已断开",把连接状态置为未连接,但保留用户之前选的参数。如果这个设备之前授权过,用户点重连时可以直接从getPorts()里匹配usbVendorId和usbProductId找到它,不用再弹选择框。这个体验优化很值,因为调试时设备重启、拔插非常频繁。
注意:
disconnect事件里的event.target就是断开的 port,可以用它跟当前连接的 port 做比对,避免误处理其他设备的断开事件。
4.2 读取循环里的错误吞噬问题
前面给的读取循环里有个catch块,如果不小心写成空的,设备出错时你会完全不知道发生了什么,只看到数据停了。我建议在 catch 里至少打个日志,并且区分错误类型。常见的错误有NetworkError(设备物理断开)、BufferOverrunError(读取太慢,缓冲区溢出)、ParityError(校验错误)。
BufferOverrunError特别值得说,它意味着你的读取速度跟不上数据到达速度。解决办法是减少每次读取后的处理开销,比如不要在 onData 里做复杂的 DOM 操作,先把数据攒到数组里,用 requestAnimationFrame 批量刷新界面。我实测过,如果每条数据都直接 append 到 DOM,921600 波特率下几秒钟界面就卡死了。
4.3 权限持久化与多设备管理
用户授权过的串口会持久化,但这个持久化是按 origin 隔离的。也就是说,你把页面从localhost:3000换到localhost:8080,之前的授权就没了,得重新授权。开发时如果频繁换端口,会一直被弹窗烦到。解决办法是固定开发端口,或者用getPorts()先查有没有已授权的,有就直接用。
多设备场景下,getPorts()返回的是一个数组,每个 port 有getInfo()方法能拿到usbVendorId、usbProductId和serialNumber。做多设备终端的话,可以用这些信息给设备起别名,比如"CH340-01"、"CP2102-02",界面上让用户选。不过要注意,不是所有串口芯片都提供 serialNumber,有些便宜货返回空,那就只能靠 vendorId/productId 加索引来区分了。
5. 性能优化与大数据量场景
5.1 高频数据的批量渲染策略
前面提到 DOM 操作是性能杀手,这里展开说下具体做法。核心思路是数据接收和界面渲染解耦:读取循环只管把数据 push 进一个缓冲区数组,另起一个渲染循环(用 requestAnimationFrame 驱动)定期把缓冲区里的数据合并成一次 DOM 更新。
let buffer = []; let scheduled = false; function onData(chunk) { buffer.push(chunk); if (!scheduled) { scheduled = true; requestAnimationFrame(flush); } } function flush() { scheduled = false; if (buffer.length === 0) return; const merged = concatChunks(buffer); buffer = []; appendToView(merged); }这样无论数据来得多快,每帧最多更新一次 DOM。实测在 921600 波特率持续灌数据的情况下,界面依然流畅。另外,输出区的内容不能无限增长,得设个上限,比如保留最近 5000 行,超出的从头部删掉。否则跑久了内存会爆,页面越来越卡。
5.2 日志导出与本地保存
调试完想把日志存下来,可以用 File System Access API 的showSaveFilePicker(),让用户选个位置直接写文件。这个 API 也是 Chromium 系支持,跟 WebSerial 的兼容范围一致。实现上把接收到的原始字节流按顺序写入即可,注意要保留原始数据而不是渲染后的文本,这样 hex 和文本两种视图都能从日志里还原。
如果不想用 File System Access API,退而求其次可以用 Blob 加<a download>触发下载。缺点是数据量大时内存占用高,因为整个 Blob 得先构造出来。我的建议是超过几 MB 的日志就用流式写入,小日志用 Blob 下载就够了。
5.3 长时间运行的稳定性
终端可能一开就是几个小时,稳定性得考虑。除了前面说的缓冲区上限,还要注意定时清理已释放的 reader 和 writer 引用,避免内存泄漏。另外,如果页面切到后台,浏览器的定时器会被节流,requestAnimationFrame 也会暂停,这会导致数据在缓冲区里堆积。可以在visibilitychange事件里做处理,页面重新可见时立即 flush 一次。
还有个隐蔽的问题:长时间运行后串口可能进入异常状态,表现为能写不能读,或者读取返回空。这时候最稳妥的做法是主动 close 再 open 一次,相当于软复位。我一般会在界面上放个"重连"按钮,遇到诡异问题先重连,八成能解决。
6. 把它用起来:典型场景与扩展方向
6.1 嵌入式开发中的实际用法
我平时用 WebSerial Terminal 最多的场景是调 ESP32 和 STM32。烧录固件还是得用官方工具,但烧完之后看串口日志、发 AT 指令、改配置参数,全在浏览器里搞定。尤其是给别人做远程支持的时候,直接发个链接让对方打开,授权一下串口就能看到日志,比让对方装软件、配驱动快太多。
还有个场景是产线测试。把 WebSerial Terminal 部署到内网服务器,测试工位的电脑只要开浏览器就能连设备跑测试脚本。因为它是纯 JS,测试逻辑可以直接写成页面里的函数,比如"发送握手指令、等待特定响应、判断通过与否",比用 Python 写脚本再打包成 exe 灵活得多。
6.2 结合脚本实现自动化交互
WebSerial 最大的优势是可编程。你可以在终端里内置一个简单的脚本引擎,让用户写 JS 片段来处理收到的数据。比如自动回复心跳包、解析特定格式的传感器数据并画图、根据响应自动发送下一条指令。这些在传统串口工具里要么做不了,要么得学它自己的宏语言。
举个实际例子,调试一个 Modbus 设备时,我写了个小函数:收到01 03开头的响应就自动解析出寄存器值并显示成表格。这种定制化能力是 WebSerial Terminal 相对桌面工具的降维打击,因为整个浏览器生态的库都能直接用。
6.3 部署与分发的注意事项
最后说部署。因为 WebSerial 要求安全上下文,正式环境必须上 HTTPS。如果只是自己用,localhost最省事。想分享给同事,可以用内网 HTTPS 或者部署到任意支持 HTTPS 的静态托管上,纯前端项目没有后端依赖,扔上去就能跑。
有个细节:页面最好加个 manifest 做成 PWA,这样能"安装"到桌面,用起来跟原生应用差不多,还能离线打开。虽然离线时连不了串口(因为要用户授权),但界面和已保存的日志能看。这个体验提升挺明显的,值得花十分钟配一下。
从我自己反复使用的感受来说,WebSerial Terminal 这类工具的价值不在于功能多全,而在于把"连个串口看日志"这件事的门槛降到了几乎为零。它当然替代不了那些重型工具的全部能力,但在快速调试、远程协助、教学演示这些高频场景里,它的便利性是压倒性的。真正上手之后你会发现,限制你的往往不是 API 能力,而是对串口协议和浏览器流模型的理解深度——把读取循环写对、把渲染性能控住、把断线重连处理好,这三点做到了,剩下的就是按需堆功能的事了。