在浏览器中通过Web Serial API读取串口数据时,开发者面对的不是一条条整齐的消息,而是连续的字节流。串口设备可能一次发送几个字节,也可能一次发送几百个字节,如果直接假设每次read返回的就是一条完整记录,很快就会出现数据错乱。解决这个问题的关键在于引入一个累积缓冲区,并按照预设的分隔符把字节流切分成有意义的帧,然后再进行类型转换。

一、Web Serial API读取串口数据的基本流程
Web Serial API的核心入口是navigator.serial,它允许网页在用户授权后访问串口设备。使用requestPort方法弹出设备选择窗口,拿到SerialPort对象后调用open方法并指定波特率等参数。打开成功之后,port.readable是一个ReadableStream,它暴露的reader会持续产生Uint8Array类型的数据块。
下面是一个最基础的读取示例。它演示了如何获取端口、打开连接以及循环读取数据。注意这里并没有做任何数据解析,只是把每一块数据打印出来,实际项目中这样的读取方式无法保证单次数据块与业务消息对齐。
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
} finally {
reader.releaseLock();
}
串口读取返回的value是Uint8Array,代表原始字节。如果设备发送的是文本协议,比如以换行符结尾的AT指令响应,就需要把字节解码成字符串。如果设备发送的是二进制协议,则可能需要按照固定长度或特定头部来解析。本文重点关注文本分隔符场景,因为这是很多传感器、GPS模块和工业设备常见的数据输出方式。
在读取过程中,浏览器会根据系统缓冲情况分块传递数据,块的大小不固定。因此,read方法返回的数据可能包含多条完整记录,也可能只包含半条记录,甚至一次读到的数据结尾正好卡在分隔符中间。这种不确定性要求我们必须在应用层维护一个缓冲区,把每次收到的字节追加进去,再统一处理。
二、按分隔符切分数据的核心实现
按分隔符切分数据的基本思路是:维护一个字符串缓冲区,每次read拿到新数据后,用TextDecoder把字节解码为字符串并追加到缓冲区末尾。然后反复查找缓冲区中是否存在分隔符,如果找到,就把分隔符之前的内容截取出来作为一条完整记录,同时把分隔符之后的内容保留在缓冲区中等待后续数据。
常见分隔符包括换行符\n、回车换行\r\n以及自定义符号如分号、逗号等。对于\r\n这种双字符分隔符,不能简单按单个字符查找,否则容易留下残留的\r。更稳妥的做法是直接使用indexOf查找完整的\r\n字符串,找到后一次性截断两个字符。
下面给出一个基于字符串缓冲区的实现。它还考虑了多字节字符问题:如果使用TextDecoder的默认方式,一个中文字符可能被拆到两个Uint8Array块中,导致解码出现乱码。设置stream为true后,TextDecoder会保留不完整的字节序列,等到下次数据到达时继续解码。
class SerialLineReader {
private buffer = "";
private decoder = new TextDecoder("utf-8");
push(chunk: Uint8Array): string[] {
this.buffer += this.decoder.decode(chunk, { stream: true });
const lines: string[] = [];
let index: number;
while ((index = this.buffer.indexOf("\r\n")) !== -1) {
const line = this.buffer.slice(0, index);
this.buffer = this.buffer.slice(index + 2);
lines.push(line);
}
return lines;
}
flush(): string {
const remaining = this.buffer;
this.buffer = "";
return remaining;
}
}
上面的代码中,push方法接收新的Uint8Array,解码后追加到buffer。while循环不断查找\r\n,每找到一次就截取一条记录。这样即使一次收到了多条消息,也能全部提取出来。如果数据块结束时没有完整记录,剩余部分会继续留在buffer中,等待下一次push。
在实际读取循环中,可以创建SerialLineReader实例,每次收到数据就调用push,然后遍历返回的lines数组交给业务处理。这种方式把字节流转换成了逻辑行,极大简化了后续的数据解析工作。
如果设备使用单个\n作为分隔符,只需要把indexOf的参数改成\n,并且slice的偏移量改成1。如果分隔符是自定义字符串,比如###,同样可以使用indexOf查找,但要注意分隔符长度会影响slice的偏移量。
三、分隔符处理后数据类型的转换与校验
从串口读取并按分隔符切分后,得到的是字符串。下一步通常需要把这些字符串转换成具体的业务类型。比如温湿度传感器可能每秒发送一次类似25.6,48.2的数据,我们需要把它解析成两个number。又比如继电器状态可能发送ON或OFF,我们需要转换成boolean。
类型转换不能只依赖隐式转换,否则错误数据会被静默处理成NaN或false,给后续逻辑带来隐患。建议编写一个带校验的解析函数,根据期望的数据格式进行转换,并在失败时跳过或记录错误。
下面是一个处理常见数据类型的示例。parseNumber会先使用trim去除首尾空白,再检查是否为空,然后使用Number转换,最后用Number.isFinite判断结果是否有效。parseBoolean只接受明确的true或false字符串,避免把任意非空字符串都当成true。
function parseNumber(value: string): number | null {
const text = value.trim();
if (text === "") return null;
const num = Number(text);
return Number.isFinite(num) ? num : null;
}
function parseBoolean(value: string): boolean | null {
const text = value.trim().toLowerCase();
if (text === "true" || text === "1") return true;
if (text === "false" || text === "0") return false;
return null;
}
function parseLine(line: string): Record<string, unknown> | null {
const parts = line.split(",");
if (parts.length < 2) return null;
const temperature = parseNumber(parts[0]);
const humidity = parseNumber(parts[1]);
if (temperature === null || humidity === null) return null;
return { temperature, humidity };
}
上面的parseLine假设数据格式为温度,湿度,并返回一个包含两个数值字段的对象。如果分隔符不是逗号,或者字段顺序不同,可以按实际协议调整。对于更复杂的数据,比如JSON格式,可以使用JSON.parse,但必须用try-catch包裹,因为串口数据可能被截断或混入异常字符。
TypeScript的优势在这里体现得很明显。通过给解析函数定义明确的返回类型,可以在编译阶段发现很多潜在的类型错误。例如parseLine返回Record<string, unknown>,调用方需要进一步做类型断言或使用类型守卫提取具体字段。
如果需要强类型对象,可以定义一个接口,比如SensorData,然后让parseLine返回SensorData | null。这样在调用方就能直接访问data.temperature,而不会出现类型错误。
四、完整TypeScript封装与生产注意事项
把读取、分隔和类型转换整合到一个类中,可以让串口处理逻辑更加清晰。下面是一个完整的SerialDataParser类,它封装了连接、读取、行解析和类型转换。使用时只需要传入串口参数和解析回调即可。
interface SensorData {
temperature: number;
humidity: number;
}
class SerialDataParser {
private port: SerialPort | null = null;
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private lineReader = new SerialLineReader();
private abortController = new AbortController();
async connect(baudRate: number): Promise<void> {
this.port = await navigator.serial.requestPort();
await this.port.open({ baudRate });
const readStream = this.port.readable;
this.reader = readStream.getReader();
}
async start(onData: (data: SensorData) => void): Promise<void> {
if (!this.reader) throw new Error("请先调用connect");
try {
while (true) {
const { value, done } = await this.reader.read();
if (done) break;
const lines = this.lineReader.push(value);
for (const line of lines) {
const data = parseLine(line);
if (data) {
onData(data as SensorData);
}
}
}
} catch (err) {
if (!this.abortController.signal.aborted) {
console.error("串口读取错误", err);
}
} finally {
this.reader.releaseLock();
}
}
async disconnect(): Promise<void> {
this.abortController.abort();
if (this.reader) {
await this.reader.cancel();
this.reader = null;
}
if (this.port) {
await this.port.close();
this.port = null;
}
}
}
这个类有几个值得注意的地方。第一,disconnect方法中调用reader.cancel会触发读取循环中的read返回done为true,从而退出循环。第二,使用AbortController可以区分用户主动断开和设备异常断开,避免在主动关闭时打印错误日志。第三,reader在finally中释放锁,保证即使发生异常也能释放资源。
在生产环境中,还需要考虑内存占用和背压问题。如果串口数据速率很高,而业务处理速度跟不上,缓冲区可能会无限增长。可以在push方法中加入最大长度限制,超过限制时丢弃最旧的数据或报错。另外,如果单帧数据很大,比如包含几百KB的固件升级包,使用字符串拼接的方式性能会下降,这时应该考虑使用Uint8Array级别的缓冲区,按字节查找分隔符。
对于二进制协议,分隔符可能不是一个可见字符,而是特定的字节序列。此时不能直接使用TextDecoder解码为字符串,而应该在字节数组中查找分隔符的字节表示。方法类似,但缓冲区改为Uint8Array,并使用循环比较字节。对于多字节分隔符,可以逐字节判断,或者将缓冲区转换为普通数组后用indexOf查找。
最后,浏览器对串口API的支持目前还比较有限,主要基于Chromium内核的浏览器。在开发阶段可以使用虚拟串口工具模拟设备数据,测试不同数据块大小和分隔符位置,确保解析逻辑在粘包、半包以及异常数据情况下都能稳定运行。通过合理设计缓冲区和类型转换流程,TypeScript可以帮助我们构建出可靠、可维护的串口数据应用。
TypeScriptWeb Serial API串口分隔符处理修改时间:2026-09-26 13:51:25