React 与 WebHID 协议:在 React 状态机中直接订阅与控制外部硬件控制器(如游戏手柄)的输入流

各位同学,大家好!

今天我们要聊一个听起来很高大上,实际上非常有“手感”的话题:React 与 WebHID 协议

我知道,你们可能觉得 React 只是在处理 DOM 的加减乘除,或者是用一堆 useState 把数据传给子组件。但今天,我要带你们打破次元壁,直接把浏览器变成一个“物理世界”的接口。

想象一下,你在写一个 Web 应用,突然你想用你手边那个吃灰的游戏手柄来控制网页上的一个虚拟角色。以前,这得写一大堆 keydown 监听器,或者用 Flash 时代的 ActiveX,那都是上个世纪的遗物了。而现在,浏览器原生就给了我们一把钥匙——WebHID API

这门课,不讲虚的,直接上手。我们将从最基础的握手开始,一步步构建一个完整的、基于 React 的游戏手柄状态机。


第一讲:浏览器与硬件的“罗密欧与朱丽叶”之恋

首先,我们要搞清楚一个核心概念:浏览器是害羞的

在 WebHID 出现之前,浏览器就像一个住在城堡里的贵族,而你的游戏手柄就像一个泥腿子农夫。贵族根本不想跟农夫说话。但是,WebHID API 的出现,就像是给贵族配了一把万能钥匙,告诉他:“嘿,如果你愿意,你可以直接跟那个泥腿子农夫对话。”

WebHID 允许网页直接访问设备。这意味着什么?意味着你可以绕过操作系统那一层复杂的驱动程序,直接读取 USB 报告描述符,直接监听按键的原始电压变化。

但是,注意了! 这把钥匙是有条件的。

  1. HTTPS 协议:你的网站必须是 HTTPS 的。你不能在 http://localhost 上直接玩 WebHID(虽然有些浏览器允许,但为了安全,最好是 HTTPS)。
  2. 用户手势:你不能在页面加载的一瞬间就“啪”地一声连上手柄。你必须得有个按钮,让用户点一下,说:“嘿,我允许你访问这个设备。”这是为了防止黑客偷偷控制你的手柄去黑你的电脑。

好,理论讲完了,我们开始造轮子。


第二讲:封装 WebHID 的“瑞士军刀”——自定义 Hook

在 React 里,我们不应该直接在组件里写一大堆 navigator.hid.requestDevice。那太乱了,维护起来就像一锅意大利面。

我们要写一个自定义 Hook,把它叫做 useHidDevice。这个 Hook 的任务就是:请求权限 -> 连接设备 -> 监听输入 -> 关闭连接

看这段代码,这可是我们今天的基础设施。

import { useState, useEffect, useRef } from 'react';

// 一个简单的过滤器,比如我们要找特定的 VID 和 PID
const FILTER = {
  vendorId: 0x1234, // 假设是某品牌手柄
  productId: 0x5678
};

export const useHidDevice = (filter) => {
  const [device, setDevice] = useState(null);
  const [error, setError] = useState(null);
  const [isConnected, setIsConnected] = useState(false);
  const isConnectedRef = useRef(false); // 使用 ref 来避免不必要的重渲染

  // 1. 请求设备
  const requestDevice = async () => {
    try {
      // 这一步会弹出一个浏览器窗口,问你要不要允许连接
      const selectedDevice = await navigator.hid.requestDevice({
        filters: [filter]
      });

      if (selectedDevice) {
        await selectedDevice.open();
        if (selectedDevice.state === 'open') {
          setDevice(selectedDevice);
          setIsConnected(true);
          isConnectedRef.current = true;
          setError(null);
        } else {
          setError('设备无法打开');
        }
      }
    } catch (err) {
      setError(err.message);
      console.error('WebHID Error:', err);
    }
  };

  // 2. 清理工作:当组件卸载时,关闭设备
  useEffect(() => {
    return () => {
      if (device && isConnectedRef.current) {
        device.close();
        console.log('设备已断开');
      }
    };
  }, [device]);

  return {
    requestDevice,
    device,
    error,
    isConnected
  };
};

这段代码虽然短,但它是核心。它处理了 Promise、异步操作和清理函数。注意那个 useRef,它是为了性能优化。我们不想因为设备连接状态改变就重渲染整个 UI,除非真的有变化。


第三讲:状态机的灵魂——将原始数据转化为语义化状态

游戏手柄传回来的数据是什么?是一堆十六进制的数字。比如 0x00, 0x01, 0xFF。这玩意儿对人类来说毫无意义。

我们的任务就是写一个状态机。这个状态机接收原始数据,然后吐出一个干净、整洁的状态对象。

比如,我们定义一个状态结构:

// 我们期望的状态结构
const INITIAL_STATE = {
  buttons: {
    A: false,
    B: false,
    X: false,
    Y: false,
    Start: false,
    Select: false
  },
  axes: {
    leftStick: { x: 0, y: 0 }, // -1 到 1
    rightStick: { x: 0, y: 0 }
  },
  triggers: {
    leftTrigger: 0,
    rightTrigger: 0
  }
};

现在,我们需要监听 device.oninput。WebHID 会在每次数据变化时触发这个事件。

让我们把逻辑封装在 useGamepadState 这个 Hook 里。

export const useGamepadState = (device) => {
  const [state, setState] = useState(INITIAL_STATE);

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

    // 监听输入流
    device.oninput = (event) => {
      const report = event.data;
      // report 是一个 Uint8Array

      // 这里的逻辑取决于你的手柄 Report ID
      // 假设 Report ID 是 0x01,数据从 index 1 开始
      // 实际开发中,你需要查看 Report Descriptor

      // 简化版解析逻辑(仅作演示)
      const buttonsState = parseButtons(report);
      const axesState = parseAxes(report);
      const triggersState = parseTriggers(report);

      setState({
        buttons: buttonsState,
        axes: axesState,
        triggers: triggersState
      });
    };

    return () => {
      device.oninput = null; // 移除监听
    };
  }, [device]);

  return state;
};

// 辅助函数:解析按钮
// 假设 report[1] 是按钮位图
const parseButtons = (report) => {
  if (!report || report.length < 2) return INITIAL_STATE.buttons;

  const byte = report[1];
  return {
    A: (byte & 0x01) !== 0,
    B: (byte & 0x02) !== 0,
    X: (byte & 0x04) !== 0,
    Y: (byte & 0x08) !== 0,
    // ...以此类推
  };
};

// 辅助函数:解析摇杆
// 假设 report[2] 和 report[3] 是左右摇杆 X/Y
const parseAxes = (report) => {
  if (!report || report.length < 4) return INITIAL_STATE.axes;

  const mapAxis = (value) => {
    // 将 0-255 映射到 -1 到 1
    // 并加上死区处理,防止在中心抖动
    let normalized = (value - 127) / 127;
    if (normalized < 0.1 && normalized > -0.1) normalized = 0;
    return normalized;
  };

  return {
    leftStick: {
      x: mapAxis(report[2]),
      y: mapAxis(report[3])
    },
    rightStick: {
      x: mapAxis(report[4]),
      y: mapAxis(report[5])
    }
  };
};

看,这就是状态机的威力。我们不再需要去管那个 Uint8Array 到底怎么存储的,我们只需要关心 state.buttons.A 是 true 还是 false。

这里有一个微小的陷阱:抖动。摇杆在中心点稍微动一下,可能就会产生 -0.05 这样的数值。如果我们不做“死区”处理,UI 就会疯狂闪烁。上面的代码里我已经加了一个简单的 0.1 阈值处理,这就是工业级的思维。


第四讲:渲染的狂欢——将状态映射到 UI

好了,现在我们有了数据。接下来,我们要把这些数据变成用户能看到的画面。

假设我们要做一个“赛车游戏”的仪表盘。我们需要显示油门(触发器)、刹车(另一个触发器)和方向盘(左右摇杆)。

我们用 React 的 useMemo 来做性能优化。因为 state 每一帧都在变,我们不能让每个子组件都重新渲染。我们要做的是“按需渲染”。

export const RacingDashboard = () => {
  const { requestDevice, isConnected, error } = useHidDevice(FILTER);
  const state = useGamepadState(device); // 这里需要把 device 传进去

  // 计算方向盘的角度
  const rotation = useMemo(() => {
    const { leftStick } = state.axes;
    // Math.atan2 返回弧度,转换为角度
    const angle = Math.atan2(leftStick.y, leftStick.x) * (180 / Math.PI);
    return angle;
  }, [state.axes.leftStick]);

  // 计算油门和刹车的视觉进度
  const throttleProgress = state.triggers.leftTrigger; // 0 到 1
  const brakeProgress = state.triggers.rightTrigger;  // 0 到 1

  return (
    <div style={styles.container}>
      <h1>React + WebHID 赛车仪表盘</h1>

      <button onClick={requestDevice} disabled={isConnected}>
        {isConnected ? "已连接" : "连接手柄"}
      </button>

      {error && <p style={{color: 'red'}}>{error}</p>}

      {isConnected && (
        <div style={styles.dashboard}>
          {/* 油门 */}
          <div style={styles.barContainer}>
            <div style={{...styles.bar, height: `${throttleProgress * 100}%`, background: 'green'}}></div>
            <span>油门</span>
          </div>

          {/* 刹车 */}
          <div style={styles.barContainer}>
            <div style={{...styles.bar, height: `${brakeProgress * 100}%`, background: 'red'}}></div>
            <span>刹车</span>
          </div>

          {/* 方向盘 */}
          <div style={styles.steeringWheelContainer}>
            <div style={{...styles.steeringWheel, transform: `rotate(${rotation}deg)`}}>
              <div style={styles.steeringIndicator}></div>
            </div>
          </div>

          {/* 按钮状态 */}
          <div style={styles.buttons}>
            {Object.entries(state.buttons).map(([name, pressed]) => (
              <div key={name} style={pressed ? styles.buttonActive : styles.buttonInactive}>
                {name}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

这里用到了一些内联样式,只是为了方便演示。你可以把它换成 CSS Modules 或者 styled-components。

注意看那个 transform: rotate(${rotation}deg)。这就是 React 的魔力。我们只需要改变一个数字,CSS 就会自动帮我们完成复杂的图形变换。这比直接操作 Canvas 要简单太多了,而且响应式效果更好。


第五讲:高级玩法——多设备支持与事件重连

大多数教程只会教你连一个设备。但在现实世界里,用户可能会拔掉手柄,然后又插回去。或者他们有两个手柄,想同时用。

这时候,我们就需要处理 device.ondisconnect 事件。

export const useHidDevice = (filter) => {
  const [device, setDevice] = useState(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const handleDisconnect = (event) => {
      if (event.device === device) {
        console.log('手柄掉了!准备重连...');
        setIsConnected(false);
        setDevice(null);
        // 这里可以触发一个全局通知
      }
    };

    navigator.hid.addEventListener('disconnect', handleDisconnect);

    return () => {
      navigator.hid.removeEventListener('disconnect', handleDisconnect);
    };
  }, [device]);

  const requestDevice = async () => {
    try {
      const selectedDevice = await navigator.hid.requestDevice({ filters: [filter] });
      if (selectedDevice) {
        await selectedDevice.open();
        setDevice(selectedDevice);
        setIsConnected(true);
      }
    } catch (err) {
      console.error(err);
    }
  };

  return { requestDevice, device, isConnected };
};

现在,当设备断开时,我们的状态会自动更新。为了用户体验,我们可以监听 navigator.hid.getDevices()。如果用户重新插上手柄,浏览器会自动通知我们。

useEffect(() => {
  const updateDevice = () => {
    navigator.hid.getDevices().then(devices => {
      const connected = devices.find(d => d.vendorId === FILTER.vendorId);
      if (connected && !device) {
        console.log('检测到新设备,自动连接...');
        connected.open().then(() => setDevice(connected));
      }
    });
  };

  navigator.hid.addEventListener('connect', updateDevice);
  navigator.hid.addEventListener('disconnect', updateDevice);

  return () => {
    navigator.hid.removeEventListener('connect', updateDevice);
    navigator.hid.removeEventListener('disconnect', updateDevice);
  };
}, [device]);

这就像是给你的应用装了一个“雷达”,时刻扫描着你的硬件。


第六讲:数据节流——不要让你的 CPU 喝醉

WebHID 的数据刷新率通常很高,比如 1000Hz 或者 500Hz。但是,浏览器的渲染帧率是 60Hz。

如果你每一帧都去更新 React 的状态,React 就会疯狂地执行 render 函数,导致页面卡顿,甚至风扇狂转。

这时候,我们需要节流

最简单的方法是使用 requestAnimationFrame。我们只在屏幕刷新的时候更新状态。

export const useGamepadState = (device) => {
  const [state, setState] = useState(INITIAL_STATE);
  let animationFrameId = useRef(null);

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

    const loop = () => {
      // 获取原始数据 (假设 device.inputs 有数据)
      // 注意:WebHID 的具体 API 可能会变,这里假设有 inputs 属性
      const rawInput = device.inputs?.[0]; 

      if (rawInput) {
        // 解析逻辑...
        const newState = parseData(rawInput);

        // 只有当状态真的变了才 setState
        setState(prev => {
          if (JSON.stringify(prev) === JSON.stringify(newState)) return prev;
          return newState;
        });
      }

      animationFrameId.current = requestAnimationFrame(loop);
    };

    loop();

    return () => {
      cancelAnimationFrame(animationFrameId.current);
    };
  }, [device]);

  return state;
};

或者,更高级一点,使用 Debounce(防抖) 或者 Lodash 的 throttle。但 requestAnimationFrame 是最符合浏览器渲染机制的做法。


第七讲:深入底层——Report Descriptor(报告描述符)的奥秘

如果你想让你的应用真正强大,你就不能只猜数据怎么存。你必须去读 Report Descriptor

当你调用 device.getReportDescriptor() 时,你会得到一个数组。这个数组用一种特殊的格式描述了你的手柄长什么样。

比如:

  • 哪个字节代表按钮 A?
  • 哪个字节代表左摇杆 X 轴?
  • 数据是有符号数还是无符号数?
  • 坐标是绝对值还是相对值?

这听起来很吓人,但其实也没那么难。我们可以写一个简单的解析器来打印出设备的信息。

// 这是一个非常简化的解析思路
const parseReportDescriptor = (descriptor) => {
  let index = 0;
  const info = {};

  while (index < descriptor.length) {
    const item = descriptor[index];
    const size = item.bitSize;
    const tag = item.tag;

    // 简单的逻辑演示
    if (tag === 0x05) { // Usage Page
      info.usagePage = item.data;
    } else if (tag === 0x09) { // Usage
      info.usage = item.data;
    } else if (tag === 0x01) { // Input
      info.inputType = item.data;
    }

    index++;
  }

  return info;
};

当你拿到这个描述符,你就知道该怎么写 parseButtonsparseAxes 了。这就像是拿到了一份说明书,而不是在黑暗中摸索。


第八讲:调试技巧——如何像黑客一样看数据

当你连上手柄,但 UI 没反应时,该怎么办?

不要慌。打开浏览器的开发者工具,看 Console。

  1. 监听所有事件:在 useGamepadState 里加一行 console.log('Raw Data:', report)。你会发现,数据其实一直在传,只是你的解析逻辑错了。
  2. 检查 Vendor ID:确保你请求的 VID 和手柄的 VID 一致。有些虚拟手柄可能会冒充成键盘。
  3. 检查 HTTPS:如果你在本地测试,确保你用了 localhost 或者 127.0.0.1,并且通过 HTTPS 协议访问。

还有一个神器叫 HID Viewer(Chrome 扩展),它可以让你看到所有连接的 HID 设备的数据流。你可以用它来验证你的解析逻辑是否正确。


第九讲:实战案例——构建一个复古游戏模拟器

让我们把所有东西串起来。假设我们想用游戏手柄玩《超级马里奥兄弟》。

我们需要定义一个“按键映射表”。

const KEY_MAP = {
  'A': 'ArrowRight', // 按下 A 键,模拟向右走
  'B': 'ArrowLeft',
  'X': 'z',
  'Y': 'x',
  'Start': 'Enter',
  'Select': 'Shift'
};

export const useRetroEmulator = (state) => {
  const [virtualKeys, setVirtualKeys] = useState({});

  useEffect(() => {
    // 监听 state.buttons 的变化
    const newVirtualKeys = {};

    // 比较当前状态和上一次状态,防止按住不放时重复触发 keydown
    // 这里简化处理,实际需要维护一个 lastPressedKeys

    if (state.buttons.A) newVirtualKeys.ArrowRight = true;
    if (state.buttons.B) newVirtualKeys.ArrowLeft = true;

    setVirtualKeys(newVirtualKeys);
  }, [state.buttons]);

  return virtualKeys;
};

然后在你的游戏组件里,你可以使用 window.dispatchEvent(new KeyboardEvent(...)) 来把 WebHID 的输入“伪造”成键盘事件,这样你就可以直接运行任何现成的 JS 游戏引擎了。


第十讲:未来展望与总结

WebHID 还在不断发展中。未来,我们可能会看到更高级的 API,比如直接访问 RGB 灯光(如果你的手柄支持),或者通过 WebUSB 直接修改固件。

但是,核心思想不变:Web 技术正在变得无所不能

通过 React 的声明式状态管理和 WebHID 的底层硬件访问,我们构建了一个既现代又复古的交互体验。

最后,我想说:代码不仅仅是逻辑,它是通往物理世界的桥梁。

当你写下一行 setState,当你监听一个 oninput,你实际上是在写一首诗,一首关于人类意图与机器响应的诗。

好了,今天的讲座就到这里。去连接你的手柄,去感受那电流的脉动吧!下课!


(注:以上代码示例均为演示性质,实际使用时需要根据具体的硬件 Report Descriptor 进行调整。)

发表回复

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