浏览器里直接跟串口设备打交道这件事在几年前还只能靠 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 再 openWebSerial 不支持运行时动态改波特率。所以 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 到 DOM921600 波特率下几秒钟界面就卡死了。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 能力而是对串口协议和浏览器流模型的理解深度——把读取循环写对、把渲染性能控住、把断线重连处理好这三点做到了剩下的就是按需堆功能的事了。