ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 3 集成 Web Serial API:浏览器串口通信封装与避坑

Vue 3 集成 Web Serial API:浏览器串口通信封装与避坑 1. 为什么我会在 Vue 项目里直接上 Web Serial API先说结论Vue Web Serial API 做串口通信是目前浏览器端直连硬件最省事的一条路。不用装客户端、不用写本地服务、不用管驱动安装引导用户在 Chrome 里点一下授权页面就能直接读写 USB 转串口设备。我第一次在产线调试工装页面上用这套方案的时候从零到跑通只花了半天而之前用 Electron 打包的版本光是给不同产线电脑适配驱动和签名就折腾了一周。这条路适合谁三类人。第一类是做内部工具的前端手上有 Vue 项目需要读个电子秤、扫码枪、温湿度模块、单片机调试板第二类是嵌入式工程师想给自己的 STM32、FPGA 板子配个可视化上位机又不想学 Qt 或者 LabVIEW第三类是测试和工艺岗位的同学需要一个能塞进浏览器、随时改协议、发出去就能用的调试面板。但要提前把丑话说在前面Web Serial API 不是所有浏览器都认。Firefox 至今没实现Safari 也没有能用的基本是 Chromium 内核那一派。所以如果你的用户群体里有大量用 Safari 或者老版本国产浏览器的这篇文章的方案只能当内部工具用别拿去面向 C 端。这个前提想清楚了后面的事情就顺了。1.1 一个真实的需求场景长什么样我接到的需求大概是这样车间里有一批自研的控制器板子主控是 STM32通过板载的 USB 转 TTL 芯片CH340 那一类接到工装电脑上。老流程是操作员打开一个 C# 写的上位机点连接、点开始、等几秒结果出来抄到纸质表格上。痛点很明显板子固件一改协议上位机就得重新编译打包发版每次发版还得处理杀毒软件误报。改成浏览器方案之后逻辑变成这样板子固件升级时前端页面跟着改一版刷新一下就生效。产线电脑上只要装了 Chrome插上设备就能用。操作员看到的是一个 Vue 写的单页应用左侧是设备列表和参数配置右侧是实时日志和采集曲线。整个页面走 HTTPS 部署在内网串口数据完全不出本机安全上也没什么可纠结的。这里的关键点在于串口通信的实时性和时延要求并不高。我实测的场景里115200 波特率下每秒几十帧数据浏览器完全吃得消。如果你要做的是毫秒级的运动控制闭环那就老老实实回嵌入式侧做浏览器不适合承担这个角色。1.2 三条技术路线我为什么选它在动手之前我把可选方案列了一遍顺手做了张表对比后来这张表也被我直接放进了项目文档里。方案部署方式开发成本跨平台硬件兼容性适合场景Web Serial API in Vue纯浏览器页面低前端就能搞定好Chromium 通吃依赖系统驱动一般免驱内部工具、调试面板、演示Electron node-serialport打包成桌面应用中要维护打包链路好但要分平台打包最强能指定端口路径需要常驻、需要读本地文件后端网关 WebSocket服务端进程持有串口高要部署服务好手机也能连受限于服务端机器多用户共享一台设备选 Web Serial 的核心理由只有一条它把「部署」这件事降到了零。Electron 方案看着强但你得处理签名、自动更新、不同系统的安装包一旦公司安全策略收紧未签名程序直接被拦。后端网关方案更重还得考虑多个用户抢一个串口的问题。但我也得承认它的短板。Web Serial 拿不到任意串口路径用户必须在弹出的选择框里手动点一次而且拿不到独占锁如果同一台电脑上还开着串口助手两边会互相抢。这些限制后面会专门讲怎么绕。1.3 兼容性清单和能力边界先把这个摊开说省得你写完代码才发现白干。Chrome89 版本开始支持现在主流版本都没问题。Edge89 版本往后的 Chromium 版支持老 EdgeHTML 版本不支持。Opera76 版本往后支持。Firefox / Safari截至目前不支持也没有明确的落地时间表。国内套壳浏览器看内核版本基于 Chromium 89 的一般能用但部分厂商会把navigator.serial屏蔽掉需要在真机上验证。移动端Android 上的 Chrome 支持iOS 上的浏览器全部不支持。除了浏览器还有两个硬性条件必须满足。一是安全上下文页面必须跑在https://或者http://localhost下普通的http://192.168.x.x是拿不到串口权限的。二是用户手势调用requestPort()必须在点击、触摸这类由用户主动触发的事件处理函数里不能在onMounted里偷偷调浏览器会直接拒绝。这两条卡人最多我在项目里踩的第一个坑就是第二条——我理所当然地想在组件挂载时自动连接上次用过的设备结果控制台一片红。2. 动手之前必须搞清楚的几件事很多人拿到需求就开始写requestPort写到一半发现端口打不开或者数据是乱码然后到处搜。其实这些问题在动手前十分钟就能排掉。我把这几件事称为「开工检查清单」每次带新人我都会让他们先过一遍。2.1 安全上下文这件事比你想的严格开发阶段大部分人是直接在localhost:5173或者localhost:8080上跑 Vite 或者 Vue CLI 的 dev server这个是 OK 的localhost被浏览器视为安全上下文。但一旦你想让同事用手机或者另一台电脑访问你的开发机用 IP 直连那串口权限就直接失效了——navigator.serial会是undefined。生产环境更要注意。内网部署如果用http://10.0.0.5这种地址一样不行。必须上 HTTPS哪怕是用自签证书。自签证书会弹安全警告这个可以引导用户点「继续访问」不影响后续的串口授权。如果嫌麻烦可以配一个内网域名加上正式的证书或者用反向代理统一处理 TLS。我自己的做法是开发阶段一律localhost联调阶段用vite --host加自签证书上线阶段走公司统一的内网 HTTPS 入口。三套环境各写各的.env不去混。注意navigator.serial为undefined时不要急着怀疑浏览器版本。先看一眼地址栏八成是协议或者域名的问题。2.2 先搞清楚你手上那根线的电平这是嵌入式新人最容易翻车的地方。串口通信有好几个电平标准物理上长得像但电气特性完全不同。TTL 电平单片机引脚直接出来的信号一般是 3.3V 或者 5V。你说的 STM32 串口、FPGA 串口默认都是这个。RS232 电平用正负电压表示逻辑正负十几伏。老式设备的 9 针串口是这种。RS485 / RS422差分信号抗干扰强工业现场常见。问题来了如果你的板子是 TTL 电平直接拿一根 USB 转 TTL 的模块CH340、CP2102、FT232 芯片的那类小板子接上就行电脑上通常免驱。但如果你的设备是真正的 RS232 接口就得用带 MAX232 这类电平转换芯片的线直接拿 TTL 模块接上去不但不通还可能把芯片打坏。判断方法很简单看一眼设备接口。DB9 的 9 针接口大概率是 RS232排针或者 4pin 插座一般是 TTL。实在不确定拿万用表量一下空闲状态下的电压TTL 是 3.3V 或 5VRS232 是负电压。另外提醒一句TX 和 RX 一定要交叉接。板子的 TX 接模块的 RX板子的 RX 接模块的 TXGND 必须共地。这个接反了不会有任何报错就是死活收不到数据非常迷惑人。2.3 参数不是随便填的先把对端的配置抄下来串口通信能不能通归根结底靠两边参数完全一致。这几个参数在 Web Serial 里对应open()的选项对象。参数常见值说明baudRate9600 / 19200 / 115200 / 921600波特率最容易出问题的一项dataBits8绝大多数情况数据位用 7 的很少见stopBits1停止位8N1 里的最后一个 1paritynone校验位none/even/oddflowControlnone流控none/hardwarebufferSize默认 255可调大到 4096读写缓冲区大小115200 8N1是我遇到最多的组合差不多覆盖了八成以上的场景。9600 现在多用于老设备或者长距离低速率链路。有个细节值得说波特率不匹配不一定会完全没数据很多时候是收到一串乱码。这是因为接收方按错误的时钟去采样采样点偏移后刚好落在错误的位边界上。所以看到乱码第一个要怀疑的就是波特率。还有一点现在很多 USB 转串口芯片支持自动波特率检测但这玩意儿在嵌入式端并不常用别指望它。老老实实两边都写死。3. Web Serial API 核心拆解API 本身其实很小navigator.serial上没几个方法SerialPort对象也就那么些属性。但它有几个反直觉的点和几个致命的顺序陷阱我按自己的理解重新梳理一遍。3.1 端口枚举为什么拿不到设备路径用过 Node 的serialport库的同学第一反应通常是「我要先列出所有串口然后选一个打开」。Web Serial 不这么设计。它把设备选择权完全交给了浏览器和用户你只能做两件事。第一件是主动弹窗让用户选const port await navigator.serial.requestPort({ // 可选用过滤器缩小范围避免用户选错 filters: [{ usbVendorId: 0x1a86 }] });filters是个很实用的东西。上面的0x1a86是 CH340 芯片的厂商 ID加上之后弹窗里就只会显示 CH340 的设备。手上有多类设备的话能显著降低用户选错的概率。常见的还有0x10c4Silicon Labs CP210x、0x0403FTDI。第二件是列出「之前已经授权过的」端口const ports await navigator.serial.getPorts();注意这个方法返回的只是当前站点已经被用户授权过的端口不是系统里的全部串口。所以你会看到一个很反直觉的现象第一次打开页面getPorts()返回空数组什么都看不见。这是设计使然不是 bug。想让页面记住上次用过的设备做法就是用户授权过一次之后把port.getInfo()返回的{ usbVendorId, usbProductId }存进localStorage下次页面打开时先getPorts()再用存下来的 ID 去匹配。匹配到了就提示用户「点一下恢复上次连接」仍然需要用户点击因为open()虽然不强制用户手势但为了体验和合规我还是建议放在点击里。3.2 open() 的正确姿势和异常处理拿到port对象之后打开端口await port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: none, flowControl: none, bufferSize: 4096 });这里有几个坑要提前知道。第一个坑端口被占用时抛的是InvalidStateError。常见原因是电脑上还开着串口调试助手、Arduino IDE 的串口监视器、或者上一个页面标签没关。这个错误信息不太友好我一般会在 catch 里做一层转换把「端口可能被其他程序占用请关闭串口助手后重试」这种提示直接怼到界面上不然用户只会说「点了没反应」。第二个坑同一个 port 对象不能重复 open。如果你在组件里写了两次连接逻辑第二次会报错。所以状态管理一定要有isOpen标志位连接按钮在已连接状态下要置灰。第三个坑bufferSize不是越大越好。调大能减少高波特率下的丢包但会增大内存占用和延迟。我一般 115200 用默认值或者 1024921600 这种高速率才调到 4096。还有一个容易被忽略的点open()的参数里parity和flowControl是字符串枚举写成数字或者大小写错了都会抛错调试时盯紧报错信息。3.3 读循环一个必须跑在后台的 whileWeb Serial 的读取模型是流式的用ReadableStream的 reader 来消费数据。标准写法是这样的const reader port.readable.getReader(); let keepReading true; while (port.readable keepReading) { try { const { value, done } await reader.read(); if (done) { break; } if (value) { handleChunk(value); // value 是 Uint8Array } } catch (err) { console.error(读取异常, err); break; } finally { // 循环结束前不要 releaseLock否则下一轮 read 会报错 } } reader.releaseLock();这段代码有几个反直觉的地方。一是while (port.readable keepReading)这个条件。port.readable在设备被拔出时会变成null这时候循环必须退出否则会一直报错。我习惯把keepReading单独抽出来当开关用户点「断开」时把它置成false然后reader.cancel()打断当前的read()等待。二是reader.releaseLock()的位置。它必须放在循环外面等循环彻底结束之后再调用。如果在循环里每次都 release下一轮getReader()拿到的 reader 状态就不对了。三是read()是阻塞的。它会一直挂起直到有数据或者流被取消或出错。所以这个while循环本质上是一个异步的「后台常驻任务」它不会阻塞你的 UI但会持续占用一个 Promise 链。我第一次写的时候把这段代码直接写在async mounted()里结果组件渲染被卡住了排查了半天。实际项目里我会把它包成一个独立的startReadLoop()函数用一个readLoopPromise变量持有它断开时await readLoopPromise等它干净退出再去close()端口。这个顺序非常关键。3.4 分包与粘包字节流还原成完整帧的正经做法串口是字节流不是消息流。这是所有新手最不习惯的一点。你发一帧 8 个字节对面read()出来可能是 3 个字节 5 个字节也可能是两帧拼在一起的 16 个字节。这取决于系统的缓冲策略、总线的空闲时间、以及波特率。所以在handleChunk里直接decode成字符串然后按行切分只在非常理想的情况下能工作。正经做法是维护一个缓冲区let rxBuffer new Uint8Array(0); function handleChunk(chunk) { // 1. 拼接 const merged new Uint8Array(rxBuffer.length chunk.length); merged.set(rxBuffer, 0); merged.set(chunk, rxBuffer.length); rxBuffer merged; // 2. 按帧头帧尾切分这里假设帧以 0xAA 0x55 开头以 0x0D 0x0A 结尾 let start -1; for (let i 0; i rxBuffer.length - 1; i) { if (rxBuffer[i] 0xaa rxBuffer[i 1] 0x55) { start i; break; } } if (start -1) { // 没找到帧头保留最后一位防止跨包 rxBuffer rxBuffer.slice(Math.max(0, rxBuffer.length - 1)); return; } const end findFrameEnd(rxBuffer, start); if (end -1) { // 帧还没收全等下一包 rxBuffer rxBuffer.slice(start); return; } const frame rxBuffer.slice(start, end); rxBuffer rxBuffer.slice(end); parseFrame(frame); }这段代码的核心思想是只消费完整的帧不完整的部分留在缓冲区里等下一包。这个模式在嵌入式上位机开发里是标配不管你是用 Qt、LabVIEW 还是浏览器逻辑都一样。如果你的协议里带长度字段那就更简单了。找到帧头后读出长度字段判断rxBuffer剩余长度够不够不够就等着够了就切一帧出来。这比找帧尾可靠得多因为数据区里出现和帧尾相同的字节是常有的事。3.5 关闭端口的顺序陷阱这一段我要重点讲因为它踩过的人特别多而且报错信息非常难懂。关闭端口的正确顺序是把keepReading置为false。await reader.cancel()打断正在挂起的read()。await readLoopPromise等读循环彻底退出。reader.releaseLock()。await port.close()。如果你跳过第 2、3 步直接close()会抛TypeError: Failed to execute close on SerialPort: Cannot cancel a locked stream之类的错误。原因很简单只要 readable 上还挂着 reader这个流就是「被锁定」的状态端口关不掉。写入侧同理。port.writable.getWriter()拿到的 writer用完必须releaseLock()否则也会阻塞close()。我现在的写法是把这些顺序封装进一个closePort()函数里所有断开路径用户点击、路由离开、组件卸载、设备拔出都走同一个函数保证不会漏。4. 在 Vue 3 里把它封装成可复用的 composable裸写 API 谁都会难点在于怎么跟 Vue 的响应式系统和平共处。我踩过的最大的一个坑值得单独拿出来说。4.1 别把 SerialPort 实例塞进 reactive会出事了SerialPort是个浏览器原生对象它的方法比如open、close内部依赖this指向真实的实例。如果你用ref(port)或者reactive({ port })存它Vue 会给它套一层Proxy。等你调用proxyPort.open()的时候this变成了 Proxy 对象原生实现一看类型不对直接抛Illegal invocation。这个错误信息我见过太多次了第一次遇到的时候完全摸不着头脑因为代码逻辑看起来毫无问题。正确的处理方式有三种我按推荐程度排序用shallowRef只做浅层响应式不改内部结构。const port shallowRef(null)赋值时.value p。用markRaw明确告诉 Vue「这个对象永远不要变成响应式」。port.value markRaw(p)。干脆不用响应式把 port 存在函数闭包的普通变量里只把「是否已连接」「设备名」这类派生状态做成ref。我自己现在的主力写法是第三种加第一种的混合port 实例放在闭包变量里暴露给外部的只有isOpen、portInfo、lastError这几个纯数据 ref。这样既不会有 Proxy 问题也让状态边界更清晰。同样的道理也适用于reader、writer、ReadableStream这些对象。记住一句话浏览器原生对象一律不要放进 reactive。4.2 一个能直接抄的 useSerial 实现下面这份代码是我现在项目里在用的简化版去掉了业务相关的部分保留通用骨架。Vue 3 组合式 API 风格跟 Pinia 也不冲突。// composables/useSerial.js import { ref, shallowRef, onBeforeUnmount } from vue; export function useSerial(options {}) { const { baudRate 115200, dataBits 8, stopBits 1, parity none, flowControl none, bufferSize 4096 } options; // 非响应式的原生对象放闭包里 let port null; let reader null; let writer null; let readLoopPromise null; let keepReading false; let rxBuffer new Uint8Array(0); // 响应式的对外状态 const isSupported ref(serial in navigator); const isOpen ref(false); const portInfo ref(null); const lastError ref(); const frames ref([]); // 帧解析回调外部可覆盖 let onFrame (frame) { frames.value.push(frame); }; function setFrameHandler(fn) { onFrame fn; } async function requestPort(filters) { if (!isSupported.value) { lastError.value 当前浏览器不支持 Web Serial API; return null; } try { lastError.value ; return await navigator.serial.requestPort({ filters }); } catch (err) { // 用户点了取消也会走到这里不当作错误弹窗 if (err.name ! NotFoundError) { lastError.value err.message; } return null; } } async function open(targetPort) { const p targetPort || port; if (!p) { lastError.value 没有可用的串口设备; return false; } try { await p.open({ baudRate, dataBits, stopBits, parity, flowControl, bufferSize }); port p; portInfo.value p.getInfo ? p.getInfo() : null; isOpen.value true; keepReading true; startReadLoop(); return true; } catch (err) { lastError.value normalizeOpenError(err); return false; } } function normalizeOpenError(err) { if (err.name InvalidStateError) { return 端口已被打开或被其他程序占用请关闭串口助手后重试; } if (err.name NetworkError) { return 设备可能已被拔出请重新插拔后重试; } return err.message || 打开串口失败; } function startReadLoop() { readLoopPromise (async () { while (port port.readable keepReading) { reader port.readable.getReader(); try { while (keepReading) { const { value, done } await reader.read(); if (done) break; if (value value.length) { handleChunk(value); } } } catch (err) { if (keepReading) { lastError.value 读取中断 (err.message || err); } } finally { try { reader.releaseLock(); } catch (e) { // 忽略重复释放 } reader null; } } })(); } function handleChunk(chunk) { const merged new Uint8Array(rxBuffer.length chunk.length); merged.set(rxBuffer, 0); merged.set(chunk, rxBuffer.length); rxBuffer merged; // 简化版按行切分实际项目请换成二进制协议解析 const text new TextDecoder(utf-8).decode(rxBuffer); const lines text.split(/\r?\n/); if (lines.length 1) { for (let i 0; i lines.length - 1; i) { if (lines[i].trim()) { onFrame({ raw: lines[i], ts: Date.now() }); } } rxBuffer new TextEncoder().encode(lines[lines.length - 1]); } } async function send(data) { if (!port || !port.writable) { lastError.value 串口未打开无法发送; return false; } try { writer port.writable.getWriter(); const payload typeof data string ? new TextEncoder().encode(data) : data; await writer.write(payload); return true; } catch (err) { lastError.value 发送失败 (err.message || err); return false; } finally { if (writer) { writer.releaseLock(); writer null; } } } async function close() { keepReading false; try { if (reader) await reader.cancel(); } catch (e) { // 忽略 } try { if (readLoopPromise) await readLoopPromise; } catch (e) { // 忽略 } try { if (port) await port.close(); } catch (e) { // 忽略 } port null; reader null; writer null; readLoopPromise null; isOpen.value false; portInfo.value null; rxBuffer new Uint8Array(0); } // 设备热插拔监听 function bindDeviceEvents() { if (!isSupported.value) return () {}; const onDisconnect (e) { if (port e.target port) { lastError.value 设备已拔出; close(); } }; navigator.serial.addEventListener(disconnect, onDisconnect); return () navigator.serial.removeEventListener(disconnect, onDisconnect); } // 组件卸载时兜底清理 onBeforeUnmount(() { if (isOpen.value) close(); }); return { isSupported, isOpen, portInfo, lastError, frames, requestPort, open, close, send, setFrameHandler, bindDeviceEvents, getPorts: () (isSupported.value ? navigator.serial.getPorts() : Promise.resolve([])) }; }这份代码里我做了几件在真实项目里必须做的事。第一错误信息本地化。浏览器给的错误信息要么是英文要么表述太抽象操作员看不懂。normalizeOpenError把最常见的两类错误翻译成了人话界面直接展示lastError就行。第二取消授权不当作错误。用户点了选择框的取消按钮会抛NotFoundError。这个不应该弹错误提示否则用户会觉得「我就点了取消怎么还报错」。第三读循环用while getReader的双层结构。外层处理流重建比如设备拔出流变成 null 后重连内层处理数据读取。这个结构看着稍微绕但比单层循环健壮得多。第四所有清理路径收敛到close()。不管是用户点击断开、组件卸载、还是设备拔出都走同一个函数保证顺序正确。第五onBeforeUnmount里的兜底。这个非常重要后面单独说。4.3 组件里怎么用一个最小可用页面封装好之后组件里的代码就清爽了。下面是一个能直接跑的示例包含了设备连接、发送、接收日志展示三块。template div classserial-panel div classtoolbar button :disabledisOpen clickhandleConnect连接设备/button button :disabled!isOpen clickhandleDisconnect断开/button span classstatus :class{ online: isOpen } {{ isOpen ? 已连接 : 未连接 }} /span /div p v-if!isSupported classwarn 当前浏览器不支持 Web Serial API请使用 Chromium 内核的浏览器并确保页面运行在 HTTPS 或 localhost 下。 /p p v-iflastError classerror{{ lastError }}/p div classsend-bar input v-modelsendText placeholder输入要发送的内容 keyup.enterhandleSend / button :disabled!isOpen clickhandleSend发送/button /div div reflogBox classlog-box div v-for(item, index) in frames :keyindex classlog-line span classts{{ formatTime(item.ts) }}/span span classcontent{{ item.raw }}/span /div /div /div /template script setup import { ref, nextTick, watch } from vue; import { useSerial } from /composables/useSerial; const { isSupported, isOpen, lastError, frames, requestPort, open, close, send, bindDeviceEvents } useSerial({ baudRate: 115200 }); const sendText ref(); const logBox ref(null); // 绑定热插拔事件返回解绑函数 const unbind bindDeviceEvents(); async function handleConnect() { const port await requestPort(); if (!port) return; await open(port); } async function handleDisconnect() { await close(); } async function handleSend() { if (!sendText.value) return; await send(sendText.value \r\n); sendText.value ; } function formatTime(ts) { return new Date(ts).toLocaleTimeString(zh-CN, { hour12: false }); } // 日志自动滚到底部 watch( () frames.value.length, async () { await nextTick(); if (logBox.value) { logBox.value.scrollTop logBox.value.scrollHeight; } } ); /script这个页面里唯一需要留意的点是handleConnect里的await requestPort()。它必须由click直接触发中间不能经过setTimeout或者其他异步包装否则浏览器会认为不是用户手势直接拒绝。这一点我强调过好几遍了因为真的很容易踩。4.4 路由切换和热更新时的清理别让端口泄漏还有一个隐蔽的坑跟 Vue 的 HMR热模块替换有关。开发阶段你改一行代码Vite 会重新加载这个模块但页面并没有刷新所以你之前打开的那个串口连接还挂着。等你再点连接就会报InvalidStateError因为端口还锁着。你会以为是代码写错了其实是老连接没关。解法有两个。一是前面onBeforeUnmount里的兜底清理二是额外加一段 HMR 处理if (import.meta.hot) { import.meta.hot.dispose(() { if (isOpen.value) { close(); } }); }路由切换的场景同理。如果用户从串口页面跳到别的路由不管你用的是onBeforeUnmount还是onBeforeRouteLeave都必须调一次close()。我甚至在项目的路由守卫里做了一层保险检测到isOpen还挂着就弹个确认框。5. 完整实操用 Vue 页面对接一块 STM32 板子讲了这么多原理来一遍完整的。我在实验室里拿一块 STM32F103 的板子做了个最小验证环境板子通过 CH340 芯片接到电脑跑一个简单的回环加周期上报程序前端用 Vue 3 页面接收和展示。5.1 硬件侧的协议约定先写死在纸上前端和嵌入式联调最容易扯皮的地方就是协议。我的习惯是先写一份简单的一页纸文档两边各自照做谁别猜。这次用的协议很朴素全部是 ASCII 文本行指令含义板子回复ATPING\r\n心跳检测PONG\r\nATREAD\r\n读取一次温度TEMP:25.6\r\nATSTART\r\n开始周期上报OK\r\n随后每秒推DATA:25.6,60.1\r\nATSTOP\r\n停止上报OK\r\n用文本行协议的好处是调试方便串口助手直接看就是人话不需要对照表。代价是效率低一些但对于低频采集场景完全够用。如果你要做高速数据采集还是老老实实上二进制帧帧头帧尾加 CRC。嵌入式侧大概长这样伪代码具体看你的 HAL 库版本// 在串口中断接收回调里把收到的字节塞进环形缓冲区 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { ringbuf_push(rx_ring, rx_byte); HAL_UART_Receive_IT(huart, rx_byte, 1); // 重新开启接收 } // 主循环里检查是否有完整的一行 void poll_command(void) { char line[64]; if (ringbuf_read_line(rx_ring, line, sizeof(line))) { if (strcmp(line, ATPING) 0) { HAL_UART_Transmit(huart1, (uint8_t*)PONG\r\n, 7, 100); } else if (strcmp(line, ATREAD) 0) { char buf[32]; int len sprintf(buf, TEMP:%.1f\r\n, read_temp()); HAL_UART_Transmit(huart1, (uint8_t*)buf, len, 100); } } }这段代码里有两点值得说。一是串口接收用中断 环形缓冲区绝对不要在中断里做字符串比较和 sprintf那样会阻塞中断影响后续接收。二是发送用阻塞方式没问题因为发送的数据量很小等几十微秒就完了。5.2 前端页面长什么样交互流程走一遍页面结构分四块顶部工具栏连接按钮、断开按钮、状态指示灯。参数配置区波特率下拉框9600 / 19200 / 115200 / 921600数据位校验位用默认值。指令按钮区PING、读取温度、开始上报、停止上报四个按钮每个按钮对应一条预置指令。实时日志区滚动列表展示每条接收到的数据和接收时间支持导出。交互流程是这样的操作员插上板子点连接浏览器弹窗让他选设备。选完之后open()成功状态灯变绿。此时点 PING看一眼日志里有没有PONG有就说明链路通了。然后点开始上报日志区开始每秒多一行数据。这套流程我在三个不同的操作员身上试过没有人需要看说明书这也说明了交互设计比技术实现更重要。5.3 十六进制和文本混合收发怎么处理实际项目里纯文本协议是理想情况。我遇到过不少设备协议是文本和二进制混着的帧头是二进制的0xAA 0x55中间是长度和命令字数据区是 ASCII 的温度字符串帧尾带 CRC16。这种场景下我建议前端全程用Uint8Array处理只在最后展示的时候才 decode。伪代码大概是这样function parseFrame(frame) { const cmd frame[3]; const payload frame.slice(4, frame.length - 2); const crc (frame[frame.length - 1] 8) | frame[frame.length - 2]; if (calcCrc16(frame.slice(0, -2)) ! crc) { return { valid: false, reason: CRC 校验失败 }; } let data; if (cmd 0x01) { // 温度帧数据区是 ASCII data { type: temp, value: parseFloat(new TextDecoder().decode(payload)) }; } else if (cmd 0x02) { // ADC 原始值小端 16 位 data { type: adc, value: payload[0] | (payload[1] 8) }; } return { valid: true, data }; }两个细节。一是CRC 的字节序容易搞反我建议前端和嵌入式约定好「低字节在前」并且用一个固定的测试向量两边都对一遍。二是TextDecoder遇到不完整的多字节字符会出问题比如一个中文字符被切在两个字包里。这时候要用new TextDecoder(utf-8).decode(chunk, { stream: true })让解码器自己缓存半个字符。如果设备发的是 GBK 编码的中文浏览器默认的TextDecoder不带 GBK 支持需要额外引入编码库或者干脆让嵌入式侧改成发 UTF-8。我更推荐后者。5.4 实测数据和一些观察在 115200 波特率、每秒 10 帧、每帧 20 字节的条件下我做了个粗略的观察指标实测值说明单帧端到端延迟8 ~ 15 ms从板子发出到页面日志刷新连续跑 2 小时无丢帧用序号校验共 72000 帧全对上CPU 占用1% ~ 3%单标签页无渲染压力内存增长基本平稳日志做上限截断保留最近 2000 条几个值得注意的点。第一页面切到后台标签时浏览器会降频定时器如果有轮询逻辑间隔会变长。但串口读取是基于流的不受影响数据照样进缓冲区。第二日志列表要用虚拟滚动或者做条数上限我一开始用无限增长的数组跑到几万条的时候页面明显卡顿。第三v-for的 key 用序号比用时间戳稳因为同一毫秒可能来两帧。6. 常见问题排查速查把这两年在项目和社区里见到的问题归了归类按现象来查比较快。6.1 连接阶段的问题现象一navigator.serial是undefined。先看地址栏是不是http://加 IP 的地址。是的话换成localhost或者上 HTTPS。地址没问题就换 Chrome 或 Edge 试Firefox 和 Safari 直接不支持。现象二点连接没反应也没有弹窗。检查requestPort()是不是被包在了setTimeout、Promise.all或者其他异步链路里。用户手势会被消耗掉必须由点击事件直接触发。现象三弹窗里看不到设备。三个原因驱动没装Windows 设备管理器里看看有没有黄色感叹号、USB 线是充电线没有数据线芯、加了filters但厂商 ID 写错了或者手上有多个同款设备。把filters去掉试试能看见就是过滤问题。现象四open()报InvalidStateError。九成是端口被占用关掉串口助手、Arduino IDE 的串口监视器、以及其他标签页。还有一成是你在代码里重复open了同一个 port 对象。6.2 数据收发阶段的问题现象五连上了但完全收不到数据。依次检查TX/RX 是否交叉、两边 GND 是否共地、波特率是否一致、板子那边是否真的在发。最有效的验证方式是拿一个串口助手同时打开看板子到底有没有吐数据。如果串口助手也收不到那问题在硬件侧跟前端无关。现象六收到一堆乱码。大概率是波特率不匹配。其次是数据位和校验位不一致比如一边 8N1 另一边 8E1。再就是文本解码用错了字符集用TextDecoder时把编码参数确认一遍。现象七数据偶尔丢一截。这是典型的粘包分包处理不当。检查你的缓冲区拼接逻辑特别是在「没找到帧头」时是否正确地保留了尾部字节。如果bufferSize偏小高速率下也可能丢数据调大到 4096 试试。现象八发出去的指令板子没反应。很多嵌入式协议要求指令以\r\n结尾纯发ATPING不带换行板子的行解析器永远等不到行尾。另外注意串口助手那边通常会自动加换行浏览器不会这个差异要自己补上。6.3 打包和部署后的坑现象九开发环境好好的打包上线就报错。常见原因是构建工具的 SSR 或者预渲染配置把navigator引入了 Node 环境。Vue 项目里如果用 SSR所有跟navigator相关的代码都要放进onMounted或者客户端专属的入口里。另外之前热词里提到的「Vue 打包后布局异常」这里也提醒一句打包后的 CSS 作用域和 flex 布局在某些压缩配置下确实会出问题建议用构建产物本地跑一遍vite preview再发布。现象十HTTPS 用了自签证书串口能力失效。检查证书里是否包含了访问用的域名或者 IP。证书和地址不匹配的话有些浏览器会把页面归到不安全上下文。这种情况换正式证书最省事。6.4 排查速查表现象最可能的原因第一步动作navigator.serial未定义非安全上下文 / 浏览器不支持检查协议和浏览器连接按钮点击无弹窗非用户手势触发确认调用链弹窗列表为空驱动 / 线材 / filters去掉 filters 试open 抛 InvalidStateError端口被占用关闭其他串口程序完全收不到数据TX/RX 接反或波特率不符用串口助手交叉验证收到乱码波特率或编码问题逐一核对参数数据偶发截断分包粘包处理错误检查缓冲区逻辑关闭端口报错reader 未取消就 close按顺序清理热更新后连不上老连接未释放加 HMR dispose打包后运行报错SSR 或构建配置用 preview 复现7. 几个我实际用下来觉得值钱的延伸玩法技术骨架搭好之后剩下的事情就是往上堆业务价值。分享几个我在实际项目里加过、并且反馈不错的东西。第一个是协议解析器的插件化。一开始我把解析逻辑硬编码在handleChunk里后来设备类型变多就抽出了一个解析器注册表。每种设备对应一个解析器对象有match(portInfo)和parse(frame)两个方法。页面加载时根据连接的设备自动选解析器。这样新增一种设备只需要加一个文件不用动主流程。实现上就是一个普通的 Map 加几个纯函数没什么高深的东西但维护成本一下子降下来了。第二个是日志导出和回放。操作员经常需要把一段采集记录发给工程师分析。我加了个「导出 CSV」按钮把frames数组序列化成 CSV 下载。更进一步我还做了个「回放」功能把导出的数据重新读进来用同样的解析逻辑跑一遍画成曲线。这个功能在排查偶发故障的时候特别有用因为现场环境不可能复现只能靠记录。第三个是把采集数据接到图表上。用 ECharts 或者 uPlot 都行uPlot 更轻量十万点也不卡。做法是维护一个环形数组收到解析后的数值就 push 进去图表用requestAnimationFrame节流刷新。这里有个小技巧不要每来一帧就setData那样会有大量重绘。攒个 100ms 刷一次肉眼看起来完全连续。第四个跟 Pinia 结合做多页共享。如果你的应用有多个页面都需要读串口数据别在每个页面里都实例化一次useSerial那样会打架。正确做法是把串口会话放进 Pinia storestore 里持有唯一的 port 和读循环各个页面从 store 里读数据。注意 store 里的 port 同样要用非响应式的方式存Pinia 的state默认是响应式的得放在markRaw里或者干脆存在 store 外部的模块级变量里通过 action 暴露操作接口。最后一个我自己踩过的经验千万别在watch里直接调用send()。我一开始想让用户改一下输入框就自动下发配置结果每次输入一个字符就发一帧板子那边直接被打爆了。后来改成加 300ms 的防抖并且只在失焦或者点确认的时候才发。串口不像 HTTP它是独占的物理链路发得太密会把对端缓冲冲垮这个量级感必须有。关于波特率怎么选我最后再补一句实用建议能用 115200 就别用 9600。9600 下每秒只能传不到 1000 字节稍微带点日志和曲线数据就堵了。115200 是绝大多数 USB 转串口芯片的稳定工作点再往上到 921600 就要看芯片质量了便宜的 CH340 模块在这个速率下误码率会明显上升。这个坑我在一个高速采集项目里踩过查了两天才定位到是模块本身的问题换了 FT232 的板子立刻就稳了。
返回列表