Vue中Web Serial API串口通信实战指南
Vue 中 Web Serial API 串口通信实战指南
目录
- 前言
- Web Serial API 简介
- 3.1 浏览器兼容性
- 3.2 Vue 项目初始化
- 4.1 权限请求与端口选择
- 4.2 打开/关闭串口
- 4.3 读写数据流
- 5.1 组件化思路与目录结构
- 5.2 串口服务封装 (
serialService.js
) - 5.3 串口管理组件 (
SerialManager.vue
) - 5.4 串口数据交互组件 (
SerialTerminal.vue
)
- 6.1 硬件准备与波特率协议
- 6.2 Vue 端完整示例代码
- 6.3 数据流动图解
- 7.1 常见错误类型
- 7.2 调试建议
- 8.1 流缓冲与节流
- 8.2 重连与断开重试
- 安全与权限注意事项
- 总结
前言
随着现代浏览器不断扩展对硬件接口的支持,Web Serial API(串口 API)让前端能够直接操控电脑上的串口设备(如:Arduino、传感器、机器人控制板等),无需编写任何原生应用或安装额外插件。本文将以 Vue 为基础,手把手教你如何在浏览器环境下,通过 Web Serial API 与串口设备进行双向通信。我们会从基础原理讲起,演示如何:
- 请求串口权限并选择端口
- 打开与关闭串口
- 以指定波特率收发数据
- 在 Vue 组件中将这些逻辑模块化、组件化
- 结合常见硬件(例如 Arduino)做实战演示
配有详尽的代码示例、ASCII 图解和关键步骤说明,即便是串口通信新手,也能迅速上手。
声明:Web Serial API 目前在 Chrome、Edge 等基于 Chromium 的浏览器中有较好支持,其他浏览器兼容性有限。请确保使用支持该 API 的浏览器。
Web Serial API 简介
2.1 什么是 Web Serial API
Web Serial API 是一组在浏览器里与本地串口(Serial Port)设备通信的标准接口。通过它,网页可以:
- 探测并列出用户电脑连接的串口设备
- 请求用户许可后打开串口,指定波特率、数据位、停止位等参数
- 通过可读/可写的流(Streams)与设备进行双向数据交换
这一特性尤其适用于物联网、硬件调试、科学实验、工业监控等场景,前端工程师可以直接在网页中完成与硬件的交互。
2.2 浏览器兼容性
截至本文撰写,Web Serial API 在以下环境已获得支持:
浏览器 | 版本 | 支持情况 |
---|---|---|
Chrome | 89 及以上 | ✅ 支持 |
Edge | 89 及以上 | ✅ 支持 |
Opera | 75 及以上 | ✅ 支持 |
Firefox | 无官方支持 | ❌ 不支持 |
Safari | 无官方支持 | ❌ 不支持 |
若浏览器不支持,需先进行兼容性检查并给出降级提示。
项目环境准备
3.1 浏览器兼容性检查
在 Vue 组件或服务代码中,调用 Web Serial API 之前,需先确认浏览器支持:
function isSerialSupported() {
return 'serial' in navigator;
}
if (!isSerialSupported()) {
alert('当前浏览器不支持 Web Serial API,请使用 Chrome 或 Edge 最新版本。');
}
3.2 Vue 项目初始化
以下示例以 Vue 3 + Vite 为基础。若使用 Vue 2 + Vue CLI,改写语法即可。
# 1. 新建 Vue 3 项目(Vite 模板)
npm create vite@latest vue-web-serial -- --template vue
cd vue-web-serial
npm install
# 2. 安装 UI 库(可选,此处不依赖额外 UI)
# npm install element-plus
# 3. 运行开发
npm run dev
完成后,项目目录示例:
vue-web-serial/
├─ public/
├─ src/
│ ├─ assets/
│ ├─ components/
│ │ ├─ SerialManager.vue
│ │ └─ SerialTerminal.vue
│ ├─ services/
│ │ └─ serialService.js
│ ├─ App.vue
│ └─ main.js
├─ index.html
└─ package.json
基本原理与流程
与串口设备通信有以下核心步骤:
请求权限并选择串口
- 使用
navigator.serial.requestPort()
弹出设备选择对话框; - 获取用户批准后,得到一个
SerialPort
对象。
- 使用
打开串口
- 在端口对象上调用
port.open({ baudRate: 9600, dataBits, stopBits, parity })
; - 返回一个 Promise,当端口成功打开后,可获取可读/可写的流。
- 在端口对象上调用
读写数据流
- 写入:通过
WritableStream
获取writer = port.writable.getWriter()
,再调用writer.write(Uint8Array)
发送; - 读取:从
port.readable.getReader()
中的reader.read()
获取来自设备的数据流。
- 写入:通过
关闭串口
- 将读写器
reader.cancel()
、writer.releaseLock()
,最后调用port.close()
释放资源。
- 将读写器
4.1 权限请求与端口选择
async function requestPort() {
if (!('serial' in navigator)) {
throw new Error('浏览器不支持 Web Serial API');
}
// 弹出选择框,用户选择后返回 SerialPort
const port = await navigator.serial.requestPort();
return port;
}
注意:调用 requestPort()
必须在用户交互(如点击按钮)触发的回调里,否则会被浏览器拦截。
4.2 打开/关闭串口
async function openPort(port) {
// 9600 波特率,8 数据位,1 停止位,无奇偶校验
await port.open({ baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none' });
}
async function closePort(port) {
if (port.readable) {
await port.readable.cancel();
}
if (port.writable) {
await port.writable.getWriter().close();
}
await port.close();
}
4.3 读写数据流
写数据:
async function writeData(port, dataStr) {
const encoder = new TextEncoder();
const writer = port.writable.getWriter();
await writer.write(encoder.encode(dataStr));
writer.releaseLock();
}
读数据(使用循环持续读取):
async function readLoop(port, onDataCallback) {
const decoder = new TextDecoder();
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value);
onDataCallback(text);
}
} catch (err) {
console.error('读取出错:', err);
} finally {
reader.releaseLock();
}
}
Vue 中集成 Web Serial API
为了代码组织清晰,我们将串口读写逻辑封装成一个服务(serialService.js
),并在 Vue 组件里调用。
5.1 组件化思路与目录结构
src/
├─ services/
│ └─ serialService.js # 串口通信核心逻辑
├─ components/
│ ├─ SerialManager.vue # 串口端口选择、打开/关闭 控制
│ └─ SerialTerminal.vue # 收发数据及显示终端输出
├─ App.vue
└─ main.js
- serialService.js:封装
requestPort
、openPort
、readLoop
、writeData
、closePort
等函数,导出一个单例对象。 - SerialManager.vue:提供 UI 让用户请求权限并打开/关闭串口,同时将
SerialPort
对象及读/写状态通过props
或provide/inject
传给子组件。 - SerialTerminal.vue:接受已打开的
SerialPort
,执行读循环并提供输入框发送数据,可实时显示接收到的文本。
5.2 串口服务封装 (serialService.js
)
// src/services/serialService.js
/**
* 封装 Web Serial API 核心逻辑
*/
class SerialService {
constructor() {
this.port = null; // SerialPort 对象
this.reader = null; // ReadableStreamDefaultReader
this.writer = null; // WritableStreamDefaultWriter
this.keepReading = false;
}
// 1. 请求用户选择串口
async requestPort() {
if (!('serial' in navigator)) {
throw new Error('浏览器不支持 Web Serial API');
}
// 用户交互触发
this.port = await navigator.serial.requestPort();
return this.port;
}
// 2. 打开串口
async openPort(options = { baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none' }) {
if (!this.port) {
throw new Error('请先 requestPort()');
}
await this.port.open(options);
}
// 3. 开始读循环
async startReading(onData) {
if (!this.port || !this.port.readable) {
throw new Error('串口未打开或不可读');
}
this.keepReading = true;
const decoder = new TextDecoder();
this.reader = this.port.readable.getReader();
try {
while (this.keepReading) {
const { value, done } = await this.reader.read();
if (done) break;
const text = decoder.decode(value);
onData(text);
}
} catch (err) {
console.error('读取失败:', err);
} finally {
this.reader.releaseLock();
}
}
// 4. 停止读循环
async stopReading() {
this.keepReading = false;
if (this.reader) {
await this.reader.cancel();
this.reader.releaseLock();
this.reader = null;
}
}
// 5. 写入数据
async writeData(dataStr) {
if (!this.port || !this.port.writable) {
throw new Error('串口未打开或不可写');
}
const encoder = new TextEncoder();
this.writer = this.port.writable.getWriter();
await this.writer.write(encoder.encode(dataStr));
this.writer.releaseLock();
}
// 6. 关闭串口
async closePort() {
await this.stopReading();
if (this.writer) {
await this.writer.close();
this.writer.releaseLock();
this.writer = null;
}
if (this.port) {
await this.port.close();
this.port = null;
}
}
}
// 导出单例
export default new SerialService();
5.3 串口管理组件 (SerialManager.vue
)
负责:请求串口、打开/关闭、显示连接状态。
<template>
<div class="serial-manager">
<button @click="handleRequestPort" :disabled="port">
{{ port ? '已选择端口' : '选择串口设备' }}
</button>
<span v-if="port">✔ 已选择设备</span>
<div v-if="port" class="controls">
<label>波特率:
<select v-model="baudRate">
<option v-for="b in [9600, 19200, 38400, 57600, 115200]" :key="b" :value="b">
{{ b }}
</option>
</select>
</label>
<button @click="handleOpenPort" :disabled="isOpen">
{{ isOpen ? '已打开' : '打开串口' }}
</button>
<button @click="handleClosePort" :disabled="!isOpen">
关闭串口
</button>
<span v-if="isOpen" class="status">✔ 串口已打开</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import serialService from '@/services/serialService';
const port = ref(null);
const isOpen = ref(false);
const baudRate = ref(9600);
// 1. 请求选择端口
async function handleRequestPort() {
try {
const selected = await serialService.requestPort();
port.value = selected;
} catch (err) {
alert('选择串口失败:' + err.message);
}
}
// 2. 打开串口
async function handleOpenPort() {
try {
await serialService.openPort({ baudRate: baudRate.value });
isOpen.value = true;
} catch (err) {
alert('打开串口失败:' + err.message);
}
}
// 3. 关闭串口
async function handleClosePort() {
try {
await serialService.closePort();
isOpen.value = false;
port.value = null;
} catch (err) {
alert('关闭串口失败:' + err.message);
}
}
</script>
<style scoped>
.serial-manager {
margin: 16px 0;
}
.controls {
margin-top: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.status {
color: #4caf50;
font-weight: bold;
}
button {
padding: 6px 12px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
select {
margin-left: 4px;
padding: 4px;
}
</style>
handleRequestPort
:用户点击,弹出串口选择对话框,成功后将port
引用存储到本地状态。handleOpenPort
:传入选定波特率,调用serialService.openPort()
,打开后将isOpen
标记为true
。handleClosePort
:关闭读写并释放资源,重置状态。
5.4 串口数据交互组件 (SerialTerminal.vue
)
负责:在串口打开后,执行读循环,将收到的数据展示在“终端”窗口,并提供输入框发送数据。
<template>
<div class="serial-terminal" v-if="isOpen">
<div class="terminal-output" ref="outputRef">
<div v-for="(line, idx) in lines" :key="idx">{{ line }}</div>
</div>
<div class="terminal-input">
<input v-model="inputText" placeholder="输入发送内容" @keydown.enter="sendData" />
<button @click="sendData">发送</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onBeforeUnmount, nextTick } from 'vue';
import serialService from '@/services/serialService';
import { inject } from 'vue';
// 从父组件注入 isOpen 标记
const isOpen = inject('isOpen');
const lines = ref([]);
const inputText = ref('');
const outputRef = ref(null);
// 1. 当 isOpen 变为 true 时,启动读循环
watch(isOpen, async (open) => {
if (open) {
lines.value = []; // 清空终端输出
await serialService.startReading(onDataReceived);
} else {
await serialService.stopReading();
}
});
// 当收到数据时,追加到 lines 数组并自动滚到底部
function onDataReceived(text) {
lines.value.push(text);
nextTick(() => {
const el = outputRef.value;
el.scrollTop = el.scrollHeight;
});
}
// 2. 发送输入框内容到串口
async function sendData() {
if (!inputText.value) return;
try {
await serialService.writeData(inputText.value + '\n');
lines.value.push('▶ ' + inputText.value); // 回显
inputText.value = '';
nextTick(() => {
const el = outputRef.value;
el.scrollTop = el.scrollHeight;
});
} catch (err) {
alert('发送失败:' + err.message);
}
}
// 3. 组件卸载时确保关闭读循环
onBeforeUnmount(async () => {
if (isOpen.value) {
await serialService.stopReading();
}
});
</script>
<style scoped>
.serial-terminal {
border: 1px solid #ccc;
border-radius: 4px;
margin-top: 16px;
display: flex;
flex-direction: column;
height: 300px;
}
.terminal-output {
flex: 1;
background: #1e1e1e;
color: #f1f1f1;
font-family: monospace;
padding: 8px;
overflow-y: auto;
}
.terminal-input {
display: flex;
border-top: 1px solid #ccc;
}
.terminal-input input {
flex: 1;
border: none;
padding: 8px;
font-size: 14px;
}
.terminal-input input:focus {
outline: none;
}
.terminal-input button {
padding: 8px 12px;
background: #409eff;
border: none;
color: white;
cursor: pointer;
}
.terminal-input button:hover {
background: #66b1ff;
}
</style>
- 使用
watch(isOpen, …)
:当串口打开(isOpen=true
),调用serialService.startReading(onDataReceived)
启动读循环,并将收到的数据逐行显示;若isOpen=false
,停止读循环。 onDataReceived
:将接收到的文本推入lines
,并在下一次 DOM 更新后自动滚动到底部。sendData
:在输入框按回车或点击“发送”时,将输入内容通过serialService.writeData
写入串口,并在终端窗口回显。
实战示例:与 Arduino 设备通信
将上述组件组装起来,即可实现浏览器与 Arduino(或其他串口设备)双向通信。
6.1 硬件准备与波特率协议
假设硬件:
- Arduino Uno
- 简单程序:打开串口 9600 baud,收到字符后原样回显,并每隔 1 秒发送 “hello from Arduino\n”。
Arduino 示例代码(Arduino.ino
):
void setup() {
Serial.begin(9600);
}
void loop() {
if (Serial.available()) {
String input = Serial.readStringUntil('\n');
Serial.print("Echo: ");
Serial.println(input);
}
Serial.println("hello from Arduino");
delay(1000);
}
6.2 Vue 端完整示例代码
6.2.1 App.vue
<template>
<div id="app">
<h1>Vue Web Serial API 串口通信示例</h1>
<SerialManager />
<SerialTerminal />
</div>
</template>
<script setup>
import { provide, ref } from 'vue';
import SerialManager from './components/SerialManager.vue';
import SerialTerminal from './components/SerialTerminal.vue';
// 在根组件提供 isOpen 状态,供子组件注入
const isOpen = ref(false);
provide('isOpen', isOpen);
// 监听子组件的操作,更新 isOpen(通过事件或直接反写)
// 这里用 provide/inject 简化示例,当 SerialManager 打开或关闭时,
// 可手动同步 isOpen(也可使用状态管理方案)
</script>
<style>
body {
font-family: Arial, sans-serif;
padding: 16px;
}
#app {
max-width: 800px;
margin: 0 auto;
}
</style>
6.2.2 说明
App.vue
通过provide('isOpen', isOpen)
将isOpen
标记提供给子组件;- 在
SerialManager.vue
中,一旦成功打开串口,应更新isOpen.value = true
;关闭串口时isOpen.value = false
。 SerialTerminal.vue
通过inject('isOpen')
获取同一个响应式标记,自动启动/停止读循环。
6.3 数据流动图解
┌────────────────────────────────────────────┐
│ 用户点击“选择串口”按钮 │
└────────────────────────────────────────────┘
↓ SerialManager.handleRequestPort
┌────────────────────────────────────────────┐
│ navigator.serial.requestPort() → 用户选择串口 │
└────────────────────────────────────────────┘
↓ SerialManager.handleOpenPort
┌────────────────────────────────────────────┐
│ serialService.openPort({ baudRate:9600 }) │
│ → Arduino 与 Chrome 建立串口连接 │
└────────────────────────────────────────────┘
isOpen.value = true (provide/inject 触发)
↓ SerialTerminal.watch(isOpen)
┌────────────────────────────────────────────┐
│ serialService.startReading(onDataReceived) │
│ ↓ │
│ Arduino 每秒发送 “hello from Arduino\n” │
│ Chrome 通过 reader.read() 读取到字符串 │
│ 调用 onDataReceived(text) → lines.push() │
└────────────────────────────────────────────┘
↓ SerialTerminal.onDataReceived
┌────────────────────────────────────────────┐
│ 终端输出区域渲染新行 “hello from Arduino” │
└────────────────────────────────────────────┘
↓ 用户在 SerialTerminal 输入 “Test\n”
┌────────────────────────────────────────────┐
│ SerialTerminal.sendData → serialService.writeData("Test\n") │
│ → Arduino 接收到 “Test” 并回显 “Echo: Test” │
└────────────────────────────────────────────┘
↓ Arduino → Chrome 通过读循环读取 “Echo: Test\n”
┌────────────────────────────────────────────┐
│ SerialTerminal.onDataReceived → lines.push("Echo: Test") │
│ → 终端输出 “Echo: Test” │
└────────────────────────────────────────────┘
错误处理与调试技巧
7.1 常见错误类型
NotFoundError
- 表示没有找到任何可用串口,或用户在选择对话框中点击“取消”。
- 需在
catch
中捕获并提示用户。
SecurityError
- 触发时机:在非 HTTPS 环境下调用 Web Serial API。
- 解决:将页面部署到 HTTPS 环境,或使用
localhost
进行本地开发。
NetworkError
/InvalidStateError
- 在串口已打开但硬件被拔掉、连接中断时可能出现。
- 建议在捕获后执行重连或关闭清理。
读写冲突
- 在尚未完成前一次
reader.read()
或writer.write()
时,重复调用会抛错。 - 建议采用“先释放锁再重新获取锁”的方式,或检查
reader
、writer
状态。
- 在尚未完成前一次
7.2 调试建议
查看浏览器控制台
- 在 Chrome DevTools → Application → Serial 中可查看当前已连接的串口设备;
- 在 Console 面板观察错误信息和日志输出。
使用串口调试助手
- 在 PC 上安装独立串口调试软件(如 “YAT”、“PuTTY”)调试 Arduino 程序,确保 Arduino 程序正常运行并在标准串口发送/接收。
波特率和协议匹配
- 确认 Arduino 端使用
Serial.begin(9600)
与前端openPort({ baudRate: 9600 })
一致; - 若串口设备发送二进制数据而非文本,需使用
Uint8Array
而非TextEncoder/TextDecoder
。
- 确认 Arduino 端使用
日志与断点
- 在
serialService
的各关键步骤(openPort
、startReading
、writeData
)添加console.log
; - 使用 DevTools 中断点调试,跟踪
reader.read()
返回的数据。
- 在
性能与稳定性优化
8.1 流缓冲与节流
连续读写
如果数据量较大(如设备在短时间内发送大量数据),需在
onDataReceived
里做节流,例如累积一段时间后再更新 UI:let buffer = ''; let timer = null; function onDataReceived(text) { buffer += text; if (!timer) { timer = setTimeout(() => { lines.value.push(buffer); buffer = ''; timer = null; scrollToBottom(); }, 200); // 每 200ms 更新一次 } }
写入频率限制
- 若前端频繁调用
writeData
,串口设备可能来不及处理,建议控制发送间隔或检查设备“就绪”信号。
- 若前端频繁调用
8.2 重连与断开重试
串口在长期通信中可能因为设备拔插、电脑睡眠导致断开,可监听
port.readable
或捕获read()
读出错后尝试重连:async function safeReadLoop(onData) { try { await serialService.startReading(onData); } catch (err) { console.warn('读循环中断,尝试重连…'); await serialService.closePort(); // 等待 2 秒后重连 setTimeout(async () => { await serialService.openPort(); safeReadLoop(onData); }, 2000); } }
- 或在
reader.read()
捕获异常后,主动关闭并重新执行openPort
+startReading
。
安全与权限注意事项
仅限 HTTPS
- Web Serial API 必须在 安全上下文(HTTPS 或
localhost
)下使用,否则会报SecurityError
。
- Web Serial API 必须在 安全上下文(HTTPS 或
显式用户交互调用
navigator.serial.requestPort()
必须出现在用户手动点击回调中,否则被浏览器阻止。如果希望“自动重连”或“静默打开”,须先做好用户授权。
设备权限仅在页面生命周期内有效
- 用户选择端口后,若页面刷新或关闭,需要重新调用
requestPort()
。无法跨页面或跨站点持久化。
- 用户选择端口后,若页面刷新或关闭,需要重新调用
主动关闭端口
- 在页面
unload
、beforeunload
事件里,确保调用serialService.closePort()
释放硬件资源,否则相同设备无法二次打开。
- 在页面
避免泄露设备数据
- 串口数据可能包含敏感信息,应在前端或后端加密或脱敏,避免在开发者工具中暴露。
总结
本文围绕 Vue 中 Web Serial API 串口通信,从基础原理到完整示例进行了系统介绍,关键内容包括:
- Web Serial API:了解许可请求、打开串口、读写流、关闭串口的核心流程;
- Vue 集成架构:通过
serialService.js
将串口逻辑抽象为可复用服务;通过SerialManager.vue
管理串口端口与状态;通过SerialTerminal.vue
实现终端式数据收发与显示; - 实战示例:与 Arduino 设备进行双向通信,Arduino 定时发送 “hello” 并回显收到的数据,前端可发送指令并查看实时回显;
- 错误处理与调试:列举了常见错误类型(权限、兼容性、读写冲突)和解决思路;
- 性能优化:提供流缓冲节流、重连机制示例,保证在大量数据或断连场景下稳定运行;
- 安全与权限:强调必须在 HTTPS 环境下使用,权限仅在同一页面会话中有效,务必在卸载时主动关闭串口。
通过本文示例与说明,相信你已经掌握了在现代浏览器中,利用 Vue 框架调用 Web Serial API 与物理串口设备通信的全流程。后续你可以将其扩展到更复杂的工业控制、物联网可视化、机器人调试界面等场景,轻松打造高效、便捷的硬件交互 Web 应用。
评论已关闭