
去年接了个需求客户那边希望车间里的操作员打开一个网页就能读到控制板的实时数据不用装任何客户端。这个需求最终落在了Vue Web Serial API的组合上界面用 Vue 写串口通信直接交给浏览器原生的 Web Serial API。听起来很顺但真做起来会发现从权限模型到数据拆包每一步都有它自己的脾气。这篇就把我从零搭这套东西的完整过程拆开讲包括 API 的边界在哪、Vue 里怎么封装才不别扭、以及那些文档上不会写但一定会踩的坑。适合已经会写 Vue、但要第一次把浏览器和设备连起来的同学也适合从 Electron Node 串口方案迁移过来的人。1. 浏览器直连串口这件事到底值不值得做在动手之前先把这个方案的定位讲清楚。Web Serial API的本质是浏览器把串口设备当成一种受权限保护的外设暴露给 JavaScript页面拿到的是一个SerialPort对象读和写都走标准的 Streams 接口。这意味着你不需要任何中间层不需要本地服务、不需要插件、不需要驱动程序之外的东西。用户插上 USB 转串口线点一下按钮选端口就能收发数据。1.1 从 Electron 打包到纯浏览器方案我为什么换我最早的做法是 Electron 加 Node 的串口库。那套方案能用但有几个绕不开的问题。第一是体积一个空壳 Electron 应用打包出来一百多兆客户 IT 部门看到安装包大小就开始盘问。第二是架构复杂度串口在 Node 主进程里跑数据要通过 IPC 通道送到渲染进程中间多一层序列化调试的时候日志要两边看一个字节丢了都难定位。第三是升级成本设备协议改一个字段就得重新打包、重新签名、重新分发车间里十几台电脑一台台更新。换成纯浏览器方案之后协议逻辑全在前端改完直接刷新页面就是新版部署成本几乎归零。代价是浏览器兼容性——目前只有 Chromium 内核的浏览器支持也就是 Chrome、Edge、以及国内各种基于 Chromium 的浏览器。Firefox 和 Safari 到现在都没实现。所以这个方案的前提是你能控制用户用什么浏览器。如果是面向公众的产品这条路走不通如果是内部工具、实验室调试台、车间看板那它就是目前性价比最高的选择。提示如果你的场景必须兼容 Firefox 或 Safari那就得回到 Electron 或者本地代理服务的方案Web Serial API 帮不上忙别在这上面浪费时间。1.2 浏览器侧的两道硬门槛绕不过去第一道门槛是安全上下文。Web Serial API 只在一个安全的页面环境里可用简单说就是你得通过 HTTPS 访问或者在本机用localhost、127.0.0.1访问。很多人开发时在局域网里用http://192.168.x.x:5173打开页面然后发现navigator.serial是undefined就是这个原因。这个限制不是可以配置的它是浏览器层面的硬规则。第二道门槛是用户手势。navigator.serial.requestPort()这个弹窗必须在用户交互的回调里调用比如click事件里。你不能在页面onMounted里直接调浏览器会直接拒绝。这个设计的意图是防止页面偷偷扫你的串口设备逻辑上是合理的但写代码时要记得把连接动作和按钮绑定在一起。还有一个容易被忽略的点授权是按来源持久化的。用户在第一次选了某个端口之后浏览器会记住这个授权下次打开同一个域名的页面调navigator.serial.getPorts()就能拿到已授权的端口列表不用再弹窗。所以好的交互设计是页面加载时先getPorts()有记录就直接显示重新连接没有记录才引导用户点选择设备。2. navigator.serial 的几个行为细节必须先摸透API 表面上很简洁就几个方法但每个方法的行为边界如果不清楚后面写业务逻辑会一直卡壳。这一节把我在实际调试中确认过的行为一条条列出来。2.1 requestPort 与 getPorts授权和连接是两件事requestPort()返回的是一个SerialPort对象但它只是授权凭证不代表端口已经打开。这两件事经常被新手混在一起导致代码里出现我明明拿到端口了为什么写数据报错。正确的顺序是拿到SerialPort之后还得调port.open(options)成功了才能读写。getPorts()返回的是数组里面是所有之前授权过的端口。注意它返回的顺序不保证稳定也不能通过名字精确区分同型号的两个设备——除非你在设备端写了唯一的序列号通过读取设备信息来识别。这一点在多设备场景下很关键我后面会单独讲。还有一个细节同一个SerialPort对象在同一个时间只能被一个标签页打开。如果你开了两个标签页连同一个设备第二个标签页调open()会失败报一个端口已被占用的错误。Chrome 在标签页关闭时会自动释放但如果标签页是崩溃的或者留在后台没关就会一直占着。2.2 open 的参数怎么定别凭感觉填open()的参数是串口通信的物理层配置两端的配置必须完全一致否则收到的就是一堆乱码或者完全收不到数据。常见的参数组合如下参数常见取值说明baudRate9600 / 115200 / 921600波特率最常改的一项两端必须一致dataBits7 / 8数据位绝大多数设备用 8stopBits1 / 2停止位一般 1paritynone/even/odd校验位工业设备有时用 evenflowControlnone/hardware硬件流控除非明确需要一般 nonebufferSize4096 等读缓冲区大小不是必须配置我的经验是先跟做固件的同事确认清楚不要自己猜。特别是波特率STM32 那类芯片如果用外部晶振跑了非标准时钟标称 115200 实际可能偏差百分之几短帧看不出问题连续长时间传输就会丢字节。校验位和流控也是很多协议文档只写了波特率其他项默认省略但省略意味着用默认值而默认值不一定就是none和1。2.3 readable 和 writable两条流的脾气不一样port.open()成功之后port.readable和port.writable才会有值。它们都是标准 Streamsreadable是ReadableStreamwritable是WritableStream。关于readable有三条必须记住一个readable在任意时刻只能有一个 reader重复调getReader()会抛TypeError。所以要么维持一个长期存活的 reader 循环读要么每次读完releaseLock()再重新拿。reader.read()返回的value是Uint8Array不是字符串。想要字符串得自己解码。当reader.read()处于挂起状态await 中直接调port.close()会抛错。必须先reader.cancel()等read()返回done: true再关端口。关于writable相对简单getWriter()拿到 writerawait writer.write(uint8array)发送用完releaseLock()。写操作是异步的如果设备端处理慢又没做流控连续写太快可能丢数据所以发指令时最好在中间留一点间隔或者等上一条指令的应答再发下一条。3. 在 Vue 项目里把串口封装成一层干净的服务直接在组件里写串口逻辑代码很快就会失控连接状态、读取循环、错误处理、页面卸载时的清理全搅在一起。我的做法是分成两层——底层是一个不依赖任何框架的SerialService类上层用 Vue 的响应式把它接出来。3.1 先写一个脱离框架的 SerialService这层不引入任何 Vue 的东西纯 JavaScript 类这样万一以后要换框架或者拿去做单元测试都不用改。核心结构大概是这样export default class SerialService { constructor(options {}) { this.port null; this.reader null; this.writer null; this.reading false; this.buffer new Uint8Array(0); this.onFrame () {}; this.onStateChange () {}; this.openOptions { baudRate: 115200, dataBits: 8, stopBits: 1, parity: none, flowControl: none, ...options, }; } static get supported() { return typeof navigator ! undefined serial in navigator; } }把这个类写出来之后你会发现所有脏活都有地方放了缓冲区管理、拆帧、异常重建 reader 都在这一层组件只负责调方法、拿结果。3.2 连接和读取循环的具体写法连接分两步先授权再打开async connect() { if (!SerialService.supported) throw new Error(当前浏览器不支持 Web Serial API); this.port await navigator.serial.requestPort(); await this.port.open(this.openOptions); this.onStateChange(connected); this.readLoop(); } async readLoop() { this.reading true; while (this.port this.port.readable this.reading) { this.reader this.port.readable.getReader(); try { while (this.reading) { const { value, done } await this.reader.read(); if (done) break; if (value value.length) this.feed(value); } } catch (err) { // 设备被拔掉、驱动崩了都会走到这里 this.onStateChange(error, err); } finally { this.reader.releaseLock(); this.reader null; } } }这里有个我踩过好几次的坑整个读取循环必须放在 try/catch 里并且 catch 之后要有重建机制。因为 USB 转串口线一拔read()会立刻抛异常而不是返回done: true。如果不做处理循环直接退出页面看起来还连着实际上再也不收数据了。稳妥的做法是在 catch 里判断端口是否还在如果还在就延迟几百毫秒重新进循环。3.3 用 ref 和 reactive 把状态接到界面上上层封装我用一个组合式函数来做把 Service 实例、连接状态、收到的帧列表都变成响应式的import { ref, shallowRef, onUnmounted } from vue; import SerialService from ./SerialService; export function useSerial(options) { const service shallowRef(null); const status ref(idle); const frames ref([]); function ensure() { if (!service.value) { service.value new SerialService(options); service.value.onStateChange (s) { status.value s; }; service.value.onFrame (frame) { frames.value.push(frame); if (frames.value.length 200) frames.value.shift(); }; } return service.value; } async function connect() { const s ensure(); await s.connect(); } onUnmounted(async () { if (service.value) await service.value.disconnect(); service.value null; }); return { status, frames, connect }; }两个细节值得说。第一Service 实例用shallowRef而不是ref因为它是普通对象深度响应式代理会给它内部的各种 buffer 和 stream 都套上 Proxy性能损失没必要。第二帧列表我用了一个简单的长度上限超过 200 条就丢掉最旧的。串口数据来得很快不加限制的话数组会一直涨页面开着半天内存就上去了而且渲染也会变卡。4. 粘包和拆包这才是串口通信真正的难点如果你只做过网络请求会觉得串口收发就是读一行、写一行。实际完全不是。串口是字节流没有消息边界这个概念。设备一次发 20 个字节你调read()可能第一次拿到 3 个第二次拿到 17 个也可能一次全给你甚至可能和下一帧的数据混在一起。这就是粘包和拆包问题。4.1 为什么一次 read 拿不到完整一帧原因很好理解串口上数据是按字节一个个到的接收端的驱动程序攒够一定数量或者等待一小段时间没有新数据就把缓冲区里的东西交给上层。它根本不知道你的协议里一帧是什么样。所以拆帧逻辑必须由你自己实现这是所有串口开发的必修课不管是 C 语言写单片机还是 JS 写浏览器页面都一样。我在 Service 里维护一个累积缓冲区每次feed()进来的新数据追加到尾部然后循环尝试从头部切出一帧feed(chunk) { const merged new Uint8Array(this.buffer.length chunk.length); merged.set(this.buffer, 0); merged.set(chunk, this.buffer.length); this.buffer merged; this.tryExtract(); }注意这里用新建Uint8Array再set的方式拼接而不是简单的数组push。高频数据下数组扩容的开销很可观用定型数组能明显改善。当然如果你的数据量很小用普通数组也无所谓。4.2 三种拆帧方案选哪个看协议定长帧最简单适合协议固定的场景比如每帧就是 16 个字节。判断buffer.length 16就切走前 16 个。缺点是协议一旦变化就得改代码。分隔符帧是文本协议的主流做法比如每帧以\n结尾。在缓冲区里找分隔符的位置找到了就切出来。要点是分隔符本身要不要保留、以及分隔符出现在数据内容里怎么转义。长度前缀帧最通用也是我最推荐的做法。协议格式通常是帧头两个字节的固定魔术数然后两个字节表示负载长度最后是负载和校验。解析时先找到帧头读出长度字段再判断缓冲区里有没有凑够一帧凑够了才切。这样即使数据内容里出现任何字节都不会误判。方案适用场景优点风险点定长帧传感器周期上报实现简单判断快协议变更成本高分隔符帧文本指令、日志输出可读性好调试方便内容需转义易误切长度前缀帧二进制协议、复杂结构通用、健壮需要处理帧头同步4.3 文本还是二进制别混着来如果你的设备发的是 ASCII 文本可以在readable后面接一个TextDecoderStreamconst decoder new TextDecoderStream(); this.port.readable.pipeTo(decoder.writable); const reader decoder.readable.getReader();但这里有个前提编码必须是完整的多字节序列。TextDecoderStream内部会处理跨 chunk 的多字节字符一般没问题但它会把流锁住你就不能再用getReader()拿原始字节了。所以这套只适合纯文本协议。如果是二进制协议老老实实处理Uint8Array需要展示的时候再局部转成十六进制字符串。我见过有人图省事用TextDecoder去解二进制数据结果校验位被当成非法字符替换掉导致校验永远对不上查了半天才发现是解码方式错了。5. 反复踩过的几个坑按排查难度排序这一节是我在实际项目里遇到的真实问题每个都花了时间才定位到。5.1 端口被占着不放页面看起来没连但其实连着最典型的表现是刷新页面之后点连接报端口已被占用。原因通常有两个。一是上一个页面的reader没有正确cancel()导致port.close()抛异常被吞掉了端口实际上还开着。二是打开了多个标签页其中一个还活着。排查方法很直接打开chrome://device-log或者直接把所有相关标签页关掉再试。如果关掉所有标签页还是占用那大概是另一个软件占着比如串口助手、烧录工具、或者上一次没退干净的调试程序。代码层面的防御是在onUnmounted和页面beforeunload里都调一次断开逻辑并且断开时先 cancel reader。5.2 USB 转串口芯片和配置的隐性错配有些便宜的 USB 转串口模块用的是 CH340、CP2102 之类的芯片不同批次驱动行为有差异。我遇到过一种情况连接看起来成功也能收到数据但每隔几十帧就丢一帧。查了很久最后发现是模块的缓冲区设置和波特率不匹配导致溢出。解决办法是把bufferSize调大一点同时在应用层加一个简单的丢帧检测——如果协议里有递增的序号字段收到乱序就记录下来统计丢帧率。这样至少能把问题量化而不是靠猜。5.3 打包部署之后 navigator.serial 消失本地开发好好的部署到服务器上就报navigator.serial是undefined。九成是因为访问地址变成了http://开头的 IP 或者域名不是安全上下文。剩下的可能是嵌在了 iframe 里而 iframe 没有声明显式授权。如果是 iframe 场景外层容器需要加上allowserial否则内层页面拿不到这个 API。这个细节在文档里提得不多但做后台系统集成的时候很容易撞上。现象大概率原因处理方式navigator.serial为 undefined非 HTTPS 环境或 iframe 未授权换 localhost/HTTPSiframe 加 allow连接时报占用端口未释放或多标签页先 cancel reader 再 close关闭多余标签收不到数据但连接成功波特率或校验位不一致与固件端逐项核对参数偶发丢帧缓冲区溢出或时钟偏差增大 bufferSize加丢帧统计刷新后要重新选端口授权记录被清或换了域名检查 origin 是否一致用 getPorts 复用6. 和设备端配合时的几个约定前端能跑通只是成功了一半实际能不能稳定用很大程度取决于和设备端的协议约定是否合理。我在和做固件的同事对齐时会坚持下面这几条。6.1 帧结构里必须有校验和长度我建议的帧格式是两字节帧头、一字节命令号、两字节负载长度、N 字节负载、一字节校验。校验用简单的累加和或者 CRC8 都行重点是必须有。串口通信受到的干扰比网络大没有校验的话一个比特翻转就会让上位机收到一条看似合法的错误指令这种问题最难查。长度字段的作用前面说过是拆帧的依据。帧头的作用是同步——当因为某种原因数据错位之后解析器能靠扫帧头重新对齐而不是一直错下去。6.2 心跳和超时重连要写在上层串口没有连接状态的概念你能拿到连接成功的回调但设备什么时候拔掉、什么时候死机串口本身不会告诉你。所以心跳包必须自己实现上位机每隔一两秒发一条查询指令设备回一条应答连续几次没有应答就标记为断开界面上给用户明确提示。我在实现时用的是一个简单的超时计时器每次收到数据就重置。如果超过阈值没收到任何字节就主动调disconnect()然后尝试重连。这样处理的好处是即使设备只是暂时忙不过来也不会立刻误报断开而真的掉线时用户几秒内就能看到状态变化。另外重连不要写成死循环。我一开始写了个while(true)反复重试结果设备不在的时候 CPU 一直跑满浏览器卡得不行。后来改成指数退避第一次等 500ms然后 1s、2s、4s 一直到上限配合界面上的手动重连按钮体验好很多。7. 这套方案还能往哪些方向延伸把这套东西跑通之后我陆续加了几个功能实用性提升很明显。一个是本地指令历史记录存在localStorage里调试的时候可以直接点历史指令重发不用每次手打。另一个是数据导出把接收到的帧按时间戳整理成 CSV 下载方便事后分析也能直接丢给做数据分析的同事。再往后可以考虑的是多端口同时连接。getPorts()返回多个端口时理论上可以同时打开几个设备用一个页面做多机看板。需要注意的就是前面提到的设备识别问题——同型号设备靠getPorts()的顺序区分不可靠最好是让固件端上报一个唯一 ID前端建立 ID 到SerialPort对象的映射。至于界面层面的优化我个人的体会是别把精力全花在花哨的图表上。串口调试工具最核心的体验是响应快、状态明确、出错时能看懂发生了什么。一个清晰的十六进制数据视图加上发送和接收的时间戳比任何动画都管用。我见过不少项目把界面做得很漂亮结果收数据时卡顿或者错误信息只弹一个操作失败用户完全不知道下一步该干什么那才是真正的体验问题。