React 驱动的串行端口(Web Serial)交互:实现声明式的 React 组件用于工业传感器数据的实时监控

工业界的“赛博朋克”:用 React 驱动 Web Serial API 打造实时监控大屏

各位码农朋友,大家好!

欢迎来到今天的“硬核前端工程”现场。我是你们的老朋友,那个既爱写 React 又爱折腾硬件的资深工程师。

今天我们要聊一个听起来有点“科幻”,但实际上非常实用,且在工业互联网领域越来越火的话题:如何用 React 驱动 Web Serial API,实现工业传感器数据的实时监控

我知道,你们中的很多人,尤其是做后端或者嵌入式的朋友,可能觉得:“嘿,这不就是串口通信吗?用 Node.js 写个 Socket 或者直接操作 /dev/tty 不就完了?”

别急,别急。让我告诉你们,为什么我们要用 React 去做这件事。

首先,现在的工业现场,大家都在谈“数字化转型”。你总不能让操作员坐在车间里,盯着一个黑底绿字的终端窗口(那是 90 年代的技术),还要手动输入命令去查询温度吧?那太反人类了!

我们需要的是:一个现代化的、响应式的、甚至有点“赛博朋克”风格的大屏。 我们要实时看到温度曲线,要看到报警闪烁的灯,要看到实时跳动的数据。

这就需要 React 这种声明式 UI 框架大显身手了。它能让我们把“数据变化”和“界面变化”绑定得像连体婴一样紧密。

好,废话少说,我们直接进入正题。今天我们要构建一个名为 “工业之心” 的监控组件。


第一章:Web Serial API —— 浏览器的“外设接口”

首先,我们要认识一下这位新朋友:Web Serial API

在传统的浏览器里,你只能访问鼠标、键盘、屏幕。Web Serial API 的出现,就像是浏览器突然觉醒了“触手”,允许它直接通过 USB 或蓝牙连接外设。

核心概念:
想象一下,你有一台工业传感器,它就像一个老派的邮递员,每秒钟给你发 100 封信(数据包)。Web Serial API 就是那个邮局的窗口,React 是那个负责拆信、分类、并把好消息贴在墙上的秘书。

先看个最基础的“握手”代码:

// src/components/SerialConnector.tsx
import React, { useState } from 'react';

const SerialConnector: React.FC = () => {
  const [isConnected, setIsConnected] = useState(false);

  // 这一步是关键!浏览器非常谨慎,它绝不会偷偷摸摸地连接你的硬件。
  // 它需要用户主动点击按钮,并在弹窗中授权。
  const connectDevice = async () => {
    try {
      // 1. 请求端口
      const port = await navigator.serial.requestPort();

      // 2. 打开端口
      // 这里的 baudRate 是波特率,工业设备通常在 9600, 115200, 921600
      await port.open({ baudRate: 921600 });

      setIsConnected(true);
      console.log("嘿!连接成功了!握手成功!");
    } catch (error) {
      console.error("哎呀,连接失败了,可能是没插线,或者用户取消了。", error);
    }
  };

  const disconnectDevice = async () => {
    if (port) {
      await port.close();
      setIsConnected(false);
    }
  };

  return (
    <div style={{ padding: '20px', border: '1px dashed #333', borderRadius: '8px' }}>
      <h3>硬件连接控制台</h3>
      <button 
        onClick={connectDevice} 
        disabled={isConnected}
        style={{ marginRight: '10px', padding: '10px 20px', cursor: 'pointer' }}
      >
        连接传感器
      </button>
      <button 
        onClick={disconnectDevice} 
        disabled={!isConnected}
        style={{ padding: '10px 20px', cursor: 'pointer', backgroundColor: '#ff4d4f', color: 'white' }}
      >
        断开连接
      </button>
      <div style={{ marginTop: '10px', color: isConnected ? 'green' : 'gray' }}>
        状态: {isConnected ? "● 已连接 (在线)" : "○ 未连接 (离线)"}
      </div>
    </div>
  );
};

export default SerialConnector;

专家点评:
看到没?这就是 React 的魔力。我们用 isConnected 这个状态变量,就控制了整个 UI 的交互逻辑。按钮是灰的还是亮的,文字是绿还是灰,全由这个状态决定。这就是声明式编程的精髓:描述你想要什么,而不是一步步告诉它怎么做。


第二章:数据流 —— 从字节到 JSON

连接上端口只是第一步。接下来,我们要像一条贪吃蛇一样,把传感器发来的数据“吃”进来。

Web Serial API 返回的是一个 ReadableStream。这意味着数据是流式的,是一股一股涌过来的,不是一次性给你的。

这里有个坑:数据包不完整怎么办?
比如传感器发了 123456789,结果网络抖动,或者发送太快,你一次性读了 12345,下次读到了 6789。如果你直接解析,12345 会报错,6789 也会报错。

所以,我们需要一个 循环缓冲区,或者更简单点,一个 累加器

让我们写一个 useSerialReader Hook,专门负责搬运数据:

// src/hooks/useSerialReader.ts
import { useEffect, useState, useRef } from 'react';

export const useSerialReader = (port: SerialPort | null) => {
  const [text, setText] = useState('');
  const textDecoder = new TextDecoderStream();
  const readableStreamClosed = port?.readable?.pipeTo(textDecoder.writable);
  const reader = textDecoder.readable.getReader();
  const readBuffer = useRef<string>(''); // 这就是我们的累加器

  useEffect(() => {
    if (!port) return;

    const readLoop = async () => {
      try {
        while (true) {
          const { value, done } = await reader.read();
          if (done) {
            // 流结束了,或者端口关闭了
            reader.releaseLock();
            break;
          }
          if (value) {
            // 把新读到的数据塞进缓冲区
            readBuffer.current += value;

            // 这里可以加一个简单的处理逻辑:
            // 假设传感器数据是以换行符 n 结尾的
            const lines = readBuffer.current.split('n');

            // 最后一行通常是不完整的,保留在 buffer 里
            readBuffer.current = lines.pop() || '';

            // 剩下的都是完整的数据行,全部扔给 setText
            setText(lines.join('n'));
          }
        }
      } catch (error) {
        console.error("读取数据出错:", error);
      }
    };

    readLoop();

    // 清理函数:组件卸载时关闭 reader
    return () => {
      reader.cancel();
    };
  }, [port]);

  return { text };
};

专家点评:
看到 readBuffer 了吗?这就是工业数据处理的灵魂。如果你直接用 setText(value),你可能会遇到数据撕裂。使用累加器,我们就能保证每一行数据都是完整的。这就像吃面条,你得先把这一筷子吃完,再拿下一根,不能夹断。


第三章:工业现场 —— 实时可视化

现在,我们有了数据(text 状态),我们需要把它变成图表。工业现场最怕看到什么?一串串冷冰冰的数字。我们需要曲线图

我们不依赖 ECharts 或 Chart.js(虽然它们很棒),为了演示 React 的底层能力,我们手写一个简单的 SVG 动态折线图组件。这能让你更深刻地理解 React 是如何驱动 DOM 变化的。

假设我们的传感器发来的数据格式是:Temp:25.5,Cooling:On

// src/components/SensorDashboard.tsx
import React, { useEffect, useRef } from 'react';

interface SensorData {
  temp: number;
  status: string;
}

const SensorDashboard: React.FC<{ data: string }> = ({ data }) => {
  const svgRef = useRef<SVGSVGElement>(null);

  // 解析数据
  const parseData = (raw: string): SensorData => {
    const lines = raw.trim().split('n');
    const lastLine = lines[lines.length - 1];
    const [tempStr, status] = lastLine.split(',');
    return {
      temp: parseFloat(tempStr.split(':')[1]),
      status: status.split(':')[1]
    };
  };

  const sensorData = parseData(data);

  // 绘图逻辑
  useEffect(() => {
    if (!svgRef.current) return;
    const svg = svgRef.current;

    // 清空画布 (或者你可以做一个滑动的效果,这里为了简单直接重绘)
    // 实际上为了性能,应该只更新 path 的 d 属性,而不是重绘整个 SVG
    const width = 600;
    const height = 200;
    const padding = 20;

    // 模拟一些历史数据点 (真实场景应该存在数组中)
    // 这里我们简单地把当前数据点画出来
    const points = sensorData.temp;

    // 将数据映射到 SVG 坐标系
    // 假设温度范围 0 - 100
    const x = padding;
    const y = height - padding - ((points / 100) * (height - 2 * padding));

    // 创建路径指令 M (Move to) L (Line to)
    // 我们画一个从左上角到当前点的线
    const pathData = `M ${padding} ${padding} L ${x} ${y}`;

    const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
    pathEl.setAttribute("d", pathData);
    pathEl.setAttribute("stroke", "cyan");
    pathEl.setAttribute("stroke-width", "3");
    pathEl.setAttribute("fill", "none");

    // 清理旧的 path
    const oldPath = svg.querySelector('path');
    if (oldPath) oldPath.remove();

    svg.appendChild(pathEl);

    // 绘制一个背景网格,显得专业点
    const gridEl = document.createElementNS("http://www.w3.org/2000/svg", "line");
    gridEl.setAttribute("x1", "0");
    gridEl.setAttribute("y1", `${y}`);
    gridEl.setAttribute("x2", "100%");
    gridEl.setAttribute("y2", `${y}`);
    gridEl.setAttribute("stroke", "rgba(255,255,255,0.2)");
    svg.appendChild(gridEl);

  }, [sensorData.temp]);

  return (
    <div style={{ background: '#1a1a1a', padding: '20px', borderRadius: '8px', color: '#fff' }}>
      <h2>实时监控面板</h2>

      <div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
        <div>
          <span style={{ color: '#888' }}>温度:</span> 
          <span style={{ fontSize: '24px', color: 'red' }}>{sensorData.temp.toFixed(2)} °C</span>
        </div>
        <div>
          <span style={{ color: '#888' }}>状态:</span> 
          <span style={{ 
            color: sensorData.status === 'On' ? '#52c41a' : '#faad14',
            fontWeight: 'bold'
          }}>
            {sensorData.status === 'On' ? '运行中' : '待机'}
          </span>
        </div>
      </div>

      {/* SVG 画布 */}
      <svg 
        ref={svgRef} 
        width="100%" 
        height="200" 
        style={{ background: '#000', border: '1px solid #333' }}
      />
    </div>
  );
};

export default SensorDashboard;

专家点评:
你看,React 的 useEffect 就像一个尽职尽责的监工。每当 sensorData.temp 变化时,它就会跑起来,修改 SVG 的属性,然后浏览器重新渲染画面。这就是 React 带来的自动刷新体验。


第四章:进阶篇 —— 二进制协议的“硬核”解析

好了,上面的代码处理的是简单的文本数据。但在真正的工业现场,传感器往往很“吝啬”,它们不喜欢发 JSON,也不喜欢发换行符。它们喜欢发 二进制数据

比如,一个标准的 Modbus RTU 数据包可能是这样的:
[StartByte] [SlaveID] [FunctionCode] [RegisterAddr_H] [RegisterAddr_L] [Value_H] [Value_L] [CRC_L] [CRC_H]

这时候,TextDecoder 就没用了,我们得用 DataView

让我们升级一下 useSerialReader,增加一个解析二进制数据包的能力。这可是“高级工程师”的必修课。

// src/hooks/useBinarySerialReader.ts
import { useEffect, useState } from 'react';

// 定义一个数据包结构
interface BinaryPacket {
  register: number;
  value: number;
  timestamp: number;
}

export const useBinarySerialReader = (port: SerialPort | null) => {
  const [packet, setPacket] = useState<BinaryPacket | null>(null);

  useEffect(() => {
    if (!port) return;

    // 1. 获取二进制流
    const reader = port.readable.getReader();

    // 2. 创建 DataView
    const view = new DataView(reader.readable);

    const readLoop = async () => {
      while (true) {
        try {
          // 读取 9 个字节 (一个典型的 Modbus RTU 包)
          const buffer = new Uint8Array(9);
          const { value, done } = await reader.read(buffer.buffer);

          if (done) break;

          // 3. 解析二进制数据
          // 假设数据包格式:[SlaveID=1][FuncCode=3][Addr_H][Addr_L][Val_H][Val_L][CRC_L][CRC_H]
          const slaveId = buffer[0];
          const funcCode = buffer[1];
          const address = (buffer[2] << 8) | buffer[3];
          const value = (buffer[4] << 8) | buffer[5];

          // 简单校验 CRC (略过,为了代码简洁)

          // 4. 更新状态
          setPacket({
            register: address,
            value: value,
            timestamp: Date.now()
          });
        } catch (error) {
          console.error("二进制解析错误", error);
        }
      }
    };

    readLoop();
    return () => reader.releaseLock();
  }, [port]);

  return { packet };
};

专家点评:
看懂了吗?buffer[4] << 8 这是位移操作,把高字节左移 8 位,再和低字节做或运算。这就是计算机处理多字节数据的方式。

这里有一个非常重要的点:性能
在 React 中,我们不应该在 useEffect 里做太重的计算。上面的代码已经相对干净了。但在极高频的数据流下(比如每秒 1000 次更新),React 的状态更新机制可能会导致主线程卡顿。

为了解决这个问题,工业级应用通常会使用 React.memo 或者 虚拟列表 来优化渲染。但对于今天的讲座,我们保持代码的直观性。


第五章:架构模式 —— Context 让组件“继承”能力

现在,我们有 SerialConnector,有 SensorDashboard,有 useSerialReader。如果我们要在页面上放 10 个仪表盘,难道每个都要写一遍 connectDeviceuseSerialReader 吗?那代码会重复得像条狗。

这时候,React Context 登场了。我们要创建一个 SerialContext,把连接状态和读取到的数据一股脑儿丢进去,让所有的子组件都能通过 useContext 直接享用。

// src/context/SerialContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { useSerialReader } from '../hooks/useSerialReader';
import { useBinarySerialReader } from '../hooks/useBinarySerialReader';

interface SerialContextType {
  isConnected: boolean;
  connect: () => Promise<void>;
  disconnect: () => Promise<void>;
  textData: string;
  binaryPacket: any;
}

const SerialContext = createContext<SerialContextType | undefined>(undefined);

export const SerialProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [port, setPort] = useState<SerialPort | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  // 这里我们把具体的逻辑 Hook 包裹起来
  const { textData } = useSerialReader(port);
  const { packet: binaryPacket } = useBinarySerialReader(port);

  const connect = async () => {
    try {
      const p = await navigator.serial.requestPort();
      await p.open({ baudRate: 921600 });
      setPort(p);
      setIsConnected(true);
    } catch (e) {
      console.error("连接失败", e);
    }
  };

  const disconnect = async () => {
    if (port) {
      await port.close();
      setPort(null);
      setIsConnected(false);
    }
  };

  return (
    <SerialContext.Provider value={{ isConnected, connect, disconnect, textData, binaryPacket }}>
      {children}
    </SerialContext.Provider>
  );
};

// 自定义 Hook 供子组件使用
export const useSerial = () => {
  const context = useContext(SerialContext);
  if (!context) throw new Error("useSerial must be used within SerialProvider");
  return context;
};

现在,你的 SensorDashboard 可以变得非常干净:

// src/components/SensorDashboard.tsx (简化版)
import React from 'react';
import { useSerial } from '../context/SerialContext';

const SensorDashboard: React.FC = () => {
  const { textData } = useSerial();

  // ... 前面的解析和绘图逻辑 ...

  return (
    <div>
      {/* 只需要关心数据,不需要关心怎么连上串口 */}
      <h2>我的仪表盘</h2>
      <p>接收到的数据: {textData || "等待数据..."}</p>
      {/* ... */}
    </div>
  );
};

第六章:工业界的“坑”与“灵丹妙药”

讲了这么多,你以为这就结束了?天真。工业现场充满了意外。

1. 安全上下文

坑: 你在本地开发(localhost)一切正常。你把代码部署到服务器上,点击连接按钮,浏览器直接报错:“Web Serial API is not available in secure contexts.”
解释: 浏览器为了安全,禁止在非加密连接(HTTP)下使用 Web Serial。你必须使用 HTTPS。

2. 数据丢失与乱码

坑: 传感器发得快,浏览器解析得慢,数据包被切断了。
灵丹妙药: 增加心跳包机制。让传感器每秒发一个 0x0D 0x0A,如果 5 秒没收到心跳,就报警。同时,在代码里增加 CRC 校验,如果收到的数据包 CRC 错了,就丢弃并请求重发。

3. 内存泄漏

坑: 用户连上了串口,然后切换到了另一个页面,再回来。此时 reader 还在运行,占用着资源,甚至导致内存飙升。
灵丹妙药: 严格使用 useEffect 的清理函数,确保组件卸载时 reader.cancel()


第七章:终极实战 —— 打造一个“赛博朋克”监控大屏

让我们把所有东西整合起来。我们要做一个看起来像电影里那种监控室大屏的界面。

样式策略:

  • 背景色:深色 (#0b0c10)。
  • 文字色:青色 (#66fcf1) 和 橙色 (#45a29e)。
  • 字体:等宽字体 (monospace),因为那是“工程师的字体”。

完整组件代码结构:

// src/App.tsx
import React, { useEffect } from 'react';
import { SerialProvider, useSerial } from './context/SerialContext';
import SerialConnector from './components/SerialConnector';
import SensorDashboard from './components/SensorDashboard';
import './App.css'; // 引入一些简单的 CSS

const AppContent: React.FC = () => {
  const { isConnected } = useSerial();

  return (
    <div className="app-container">
      <header className="app-header">
        <h1>INDUSTRIAL MONITOR SYSTEM v1.0</h1>
        <div className="status-badge">
          SYSTEM STATUS: {isConnected ? "ONLINE" : "OFFLINE"}
        </div>
      </header>

      <main className="app-main">
        <section className="control-panel">
          <SerialConnector />
        </section>

        <section className="monitor-grid">
          <SensorDashboard title="MAIN REACTOR TEMP" />
          <SensorDashboard title="CORE PRESSURE" />
          <SensorDashboard title="VOLTAGE FLUCTUATION" />
        </section>
      </main>

      <footer className="app-footer">
        <p>React Web Serial Driver // Status: <span style={{color: isConnected ? 'green' : 'red'}}>ACTIVE</span></p>
      </footer>
    </div>
  );
};

const App: React.FC = () => {
  return (
    <SerialProvider>
      <AppContent />
    </SerialProvider>
  );
};

export default App;

CSS (App.css) 示例:

.app-container {
  font-family: 'Courier New', Courier, monospace;
  background-color: #0b0c10;
  color: #c5c6c7;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.app-header {
  background: #1f2833;
  padding: 20px;
  border-bottom: 2px solid #45a29e;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.status-badge {
  border: 1px solid #66fcf1;
  padding: 5px 10px;
  color: #66fcf1;
  text-transform: uppercase;
  box-shadow: 0 0 10px rgba(102, 252, 241, 0.2);
}

.app-main {
  flex: 1;
  padding: 20px;
  display: grid;
  grid-template-columns: 1fr 3fr;
  gap: 20px;
}

.control-panel {
  background: #1f2833;
  padding: 20px;
  border-radius: 8px;
  height: fit-content;
}

.monitor-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
}

结尾:未来的路

看到这里,我相信你们已经明白了一个道理:React 不仅仅是用来写 SPA(单页应用)的。

当你把 React 的状态管理能力与 Web Serial API 的硬件交互能力结合在一起时,你就拥有了打破浏览器围墙的能力。

你不再需要安装 Electron,不再需要编写原生 C++ 插件,仅仅通过一行 navigator.serial.requestPort(),就能让浏览器直接对话工业界的物理世界。

这就是 Web 的力量,这就是 React 的魅力。

最后送给大家一句工业界的格言:
“代码要写得像诗一样优雅,但硬件要跑得像野兽一样狂野。”

现在,去把你的 Arduino 或工业传感器连上吧,打开你的 React 项目,看看那个跳动的数字,那可是你亲手指挥硬件的心跳啊!

祝大家编码愉快,设备永不掉线!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注