欢迎来到“肉体与代码的联姻”:React、Web MIDI 与 Web HID 的深度实战
各位好!欢迎来到今天的技术讲座。我是你们的讲师。
今天我们不讲什么“如何用 map 渲染列表”或者“怎么用 useEffect 避免无限循环”。那些东西,我在你的 App.js 里已经看腻了。今天,我们要干点更刺激的。我们要把手伸进你的电脑背后,去触碰那些真实的物理世界。
我们要谈论的是 Web MIDI 和 Web HID。我们要用 React 的声明式思维,去驯服那些像野兽一样的硬件设备。我们要把冰冷的二进制数据流,变成你 UI 上鲜活、滚动的状态。
准备好了吗?让我们把鼠标扔进垃圾桶,开始这场关于“触觉编程”的旅程。
第一部分:硬件界的“诺亚方舟”与“潘多拉魔盒”
在开始写代码之前,我们要先搞清楚我们手里拿的是什么牌。Web MIDI 和 Web HID,就像是浏览器这艘诺亚方舟里,装的两类不同的动物。
1. Web MIDI:数字管风琴的继承者
Web MIDI API 是为了那些“老古董”和“新酷炫”的乐器准备的。它模拟了传统的 MIDI 信号。想象一下,你的电脑是一个巨大的管风琴,或者一个合成器。
- 它的特点: 它有节奏感,它是基于事件的。它有“通道”的概念,有“音符”的概念。
- 它能干啥: 它可以让你控制合成器,让键盘上的按键变成钢琴键;它也可以让外部 MIDI 控制台控制你的网页音量、亮度、颜色。
- 它的语言: 它是 3 字节或 3 字节以上的数据包。
2. Web HID:USB 线的直通卡
Web HID API 就不一样了。它是为了“万物皆可 USB”而生的。键盘、游戏手柄、无人机、甚至是你那台只有两颗灯泡的 Arduino。
- 它的特点: 它是原始的,它是任意的。它没有固定的协议,它只管把数据“报告”给你。
- 它能干啥: 它是通用的。你想控制一个 3D 打印机的温控?用 HID。你想用 Xbox 手柄玩网页游戏?用 HID。它就像一根 USB 线,直接插在浏览器和设备之间。
- 它的语言: 它是“报告”。设备会每隔一段时间(或者一有变化)就向主机扔一个数据包。
第二部分:声明式 UI 的痛点——为什么我们需要“中间件”?
React 的核心哲学是什么?声明式。你告诉 React:“嘿,如果 state 是 true,就渲染一个绿色的按钮;如果是 false,就渲染一个灰色的按钮。” React 负责把你的意图变成 DOM。
但是,硬件是命令式的。
- React 说:“我想把灯变亮。”
- 硬件说:“我要你发送这个十六进制的字节
0x90,带上这个音符60,还有这个力度127。”
如果你直接在 React 里写 document.querySelector('midi-device').send(...),那你这就不是 React 了,你是在写 jQuery。而且,React 的状态更新是批处理的,它是异步的。如果你在 useEffect 里直接发 MIDI,你可能会发现你的 UI 变了,但硬件没反应,或者反应慢了半拍。
我们的挑战: 如何用 React 的“声明式”思维,去驱动“命令式”的硬件?
答案:中间态。 我们需要一个“状态层”。这个状态层既是 React 的数据源,也是硬件的指令源。
第三部分:构建“状态驱动的硬件控制器”
让我们来设计一个架构。我们不要让组件直接跟硬件对话,我们要创建一个 HardwareContext。这个 Context 就像一个“翻译官”或者“调度员”。
1. 定义状态结构
首先,我们要定义硬件的状态长什么样。
// types.ts
// MIDI 状态:我们假设我们有一个 8 键的控制器
// 每个键的状态包含:是否按下,力度(0-127)
type MidiState = {
[key: number]: {
isOn: boolean;
velocity: number;
};
};
// HID 状态:假设我们有一个摇杆和两个按钮
type HidState = {
joystickX: number; // -32767 到 32767
joystickY: number;
btnA: boolean;
btnB: boolean;
};
// 合并后的全局硬件状态
type HardwareState = {
midi: MidiState;
hid: HidState;
};
2. 创建“硬件连接器”
我们需要一个 Hook 来处理连接和事件监听。这是枯燥的部分,但也是地基。
Web MIDI 的连接
// useMidiConnection.ts
import { useState, useEffect } from 'react';
export const useMidiConnection = () => {
const [midiAccess, setMidiAccess] = useState<MIDIAccess | null>(null);
const [input, setInput] = useState<MIDIInput | null>(null);
useEffect(() => {
const initMidi = async () => {
try {
// 请求权限:这是浏览器给你的“开门钥匙”
const access = await navigator.requestMIDIAccess();
setMidiAccess(access);
// 找到第一个输入设备(通常叫 "Generic MIDI Keyboard")
const inputs = Array.from(access.inputs.values());
if (inputs.length > 0) {
setInput(inputs[0]);
}
} catch (err) {
console.error("MIDI 连接失败,可能是因为用户拒绝了权限或浏览器不支持", err);
}
};
initMidi();
}, []);
return { midiAccess, input };
};
Web HID 的连接
Web HID 比较棘手,因为它需要用户点击才能授权(为了安全)。
// useHidConnection.ts
import { useState, useEffect } from 'react';
export const useHidConnection = (vendorId: number, productId: number) => {
const [device, setDevice] = useState<HIDDevice | null>(null);
useEffect(() => {
const connect = async () => {
try {
const devices = await navigator.hid.getDevices();
const targetDevice = devices.find(d => d.vendorId === vendorId && d.productId === productId);
if (targetDevice) {
await targetDevice.open();
setDevice(targetDevice);
} else {
console.log("设备未找到,请插上设备并点击屏幕上的按钮授权。");
}
} catch (err) {
console.error("HID 连接错误", err);
}
};
// 注意:这里我们使用一个空的 effect 依赖,实际项目中可能需要手动触发
// 或者监听 navigator.hid.getDevices() 的变化
connect();
}, []);
return device;
};
3. 核心映射逻辑
现在,我们需要把硬件的原始事件,转换成我们的 HardwareState。
// useHardwareState.ts
import { useState, useEffect, useCallback } from 'react';
import { useMidiConnection } from './useMidiConnection';
import { useHidConnection } from './useHidConnection';
import type { HardwareState, MidiState, HidState } from './types';
// MIDI 映射配置:哪个 MIDI 通道对应哪个 React 状态键?
const MIDI_NOTE_MAP = [0, 1, 2, 3, 4, 5, 6, 7]; // 假设 8 个键
export const useHardwareState = () => {
const [state, setState] = useState<HardwareState>({
midi: {},
hid: { joystickX: 0, joystickY: 0, btnA: false, btnB: false },
});
const { input: midiInput } = useMidiConnection();
const { device: hidDevice } = useHidConnection(0x1234, 0x5678); // 替换为你的 VID/PID
// --- 处理 MIDI 事件 ---
useEffect(() => {
if (!midiInput) return;
const handleMidiMessage = (e: MIDIInputEvent) => {
const [status, note, velocity] = e.data;
// 解析 MIDI 命令
// 0x90 = Note On, 0x80 = Note Off
const command = status >> 4;
const noteIndex = note % 8; // 映射到我们的 8 键数组
setState(prev => {
const newMidiState = { ...prev.midi };
if (command === 0x9 && velocity > 0) {
// Note On
newMidiState[noteIndex] = { isOn: true, velocity };
} else if (command === 0x8) {
// Note Off
newMidiState[noteIndex] = { isOn: false, velocity: 0 };
}
return { ...prev, midi: newMidiState };
});
};
midiInput.onmidimessage = handleMidiMessage;
return () => { midiInput.onmidimessage = null; };
}, [midiInput]);
// --- 处理 HID 事件 ---
useEffect(() => {
if (!hidDevice) return;
const handleInputReport = (e: HIDInputEvent) => {
// 这里是解析二进制数据的地方
// 假设报告描述符是:[Button1, Button2, X, Y]
// 注意:这里需要根据你的具体设备 Report Descriptor 来解析
const report = e.data;
// 这是一个简化的假设解析
const btnA = report[0] === 1;
const btnB = report[1] === 1;
const x = (report[2] - 127) * 2; // 简单的偏移
const y = (report[3] - 127) * 2;
setState(prev => ({
...prev,
hid: { joystickX: x, joystickY: y, btnA, btnB }
}));
};
hidDevice.addEventListener('inputreport', handleInputReport);
return () => { hidDevice.removeEventListener('inputreport', handleInputReport); };
}, [hidDevice]);
return state;
};
看到了吗? 现在的 state 是一个纯粹的、干净的、符合 TypeScript 类型定义的数据对象。它不关心数据是从 MIDI 挤出来的,还是从 HID 传出来的。它只关心状态。
第四部分:将状态注入硬件——“反向控制”
现在,我们不仅能读取硬件,还能控制硬件。这才是真正的交互。假设我们有一个 8 个 LED 的 MIDI 控制台,我们想让 React 的状态直接驱动这些 LED。
我们需要一个 useHardwareControl Hook。它的逻辑和上面相反:监听 React 的 State 变化 -> 发送命令给硬件。
// useHardwareControl.ts
import { useEffect } from 'react';
import type { HardwareState, MidiState } from './types';
// MIDI 输出映射:哪个 React 状态对应哪个 MIDI CC/Note
const LED_MIDI_MAP = [0, 1, 2, 3, 4, 5, 6, 7];
export const useHardwareControl = (midiOutput: MIDIOutput | null) => {
// 我们只关心 MIDI 状态的变化
useEffect(() => {
if (!midiOutput) return;
// 这里我们需要订阅状态变化,或者依赖某个全局 store
// 为了演示方便,我们假设我们有一个全局状态订阅机制
// 实际上,在 React 中,直接在这里 useEffect 监听状态不太优雅,
// 更好的做法是使用一个专门的 Effect 依赖 [state.midi]
// 假设我们有一个全局的状态订阅器,这里简化处理:
// 实际代码应该类似这样:
/*
useEffect(() => {
const unsubscribe = globalStore.subscribe(renderHardware);
return unsubscribe;
}, []);
*/
// 下面是一个模拟:监听 React 状态变化
// 注意:这只是一个示例,生产环境需要更高效的 Diff 算法
const listener = (newState: HardwareState) => {
const oldState = listener.lastState || { midi: {} };
listener.lastState = newState;
LED_MIDI_MAP.forEach((midiNote, index) => {
const newLight = newState.midi[index];
const oldLight = oldState.midi[index];
if (newLight && !oldLight) {
// 亮起
midiOutput.send([0x90, midiNote, 127]); // Note On, Velocity 127
} else if (!newLight && oldLight) {
// 熄灭
midiOutput.send([0x80, midiNote, 0]); // Note Off
} else if (newLight && newLight.velocity !== oldLight?.velocity) {
// 亮度变化
midiOutput.send([0x90, midiNote, newLight.velocity]);
}
});
};
// 初始化调用
listener({ midi: {}, hid: { joystickX: 0, joystickY: 0, btnA: false, btnB: false } });
return listener;
}, [midiOutput]);
};
注意: 在 React 中直接在 useEffect 里写这种“状态监听器”有点反模式。通常我们会使用一个专门的“硬件层”组件,它订阅全局状态,然后渲染。或者,我们可以使用 useEffect 监听特定的状态变量。
让我们重构一下,让它更 React 化:
// 更好的方式:直接监听特定的状态变化
export const useLedController = (midiOutput: MIDIOutput | null, notes: number[]) => {
useEffect(() => {
if (!midiOutput) return;
// 我们假设 notes 是一个数组,长度等于我们想控制的 LED 数量
return (state: HardwareState) => {
state.midi.forEach((light, index) => {
if (index < notes.length) {
if (light.isOn) {
midiOutput.send([0x90, notes[index], light.velocity]);
} else {
midiOutput.send([0x80, notes[index], 0]);
}
}
});
};
}, [midiOutput, notes]);
};
第五部分:实战演练——构建一个“赛博朋克”音序器
好了,理论太多了,手痒了吧?让我们来造一个东西。
目标: 创建一个 React 组件,它是一个 16 步的音序器。
硬件: 一个 MIDI 键盘。
交互: 当你按下键盘上的某个键时,屏幕上的格子会高亮;当你松开时,格子变暗。
1. 组件结构
// Sequencer.tsx
import React, { useState, useEffect, useMemo } from 'react';
import { useHardwareState } from './hooks/useHardwareState';
const Sequencer = () => {
const hardwareState = useHardwareState(); // 获取全局状态
const [step, setStep] = useState(0);
// 我们需要映射 MIDI 键到音序器步进
// 假设 MIDI 键 36-51 对应音序器的 0-15 步
const midiNotes = useMemo(() => {
return Array.from({ length: 16 }, (_, i) => 36 + i);
}, []);
// 核心逻辑:根据硬件状态渲染 UI
// 注意:这里我们并没有直接在 render 中判断硬件状态,
// 而是利用了硬件状态驱动的 UI 变化。
return (
<div style={{ padding: '20px', fontFamily: 'monospace', color: '#0f0', background: '#000' }}>
<h1>React MIDI Sequencer</h1>
<div style={{ display: 'flex', gap: '2px' }}>
{midiNotes.map((note, index) => {
const isPressed = hardwareState.midi[index]?.isOn;
return (
<div
key={index}
style={{
width: '30px',
height: '100px',
background: isPressed ? '#0f0' : '#333',
border: '1px solid #555',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
transition: 'background 0.1s'
}}
>
{note}
</div>
);
})}
</div>
<p>当前步进: {step}</p>
</div>
);
};
export default Sequencer;
看懂了吗?
React 的 render 函数非常干净。它根本不知道什么是 MIDIInputEvent,它也不知道什么是 navigator.requestMIDIAccess。它只知道:if (isPressed) return green; else return gray;。
这就是声明式硬件交互的魔力!你把“如何检测硬件”和“如何渲染 UI”完全解耦了。
2. 进阶:双向绑定
现在,让我们加上“反向控制”。我们想用 React 的 UI 来控制 MIDI 设备。
// InteractiveSequencer.tsx
import React, { useState, useEffect } from 'react';
import { useHardwareState } from './hooks/useHardwareState';
const InteractiveSequencer = () => {
const [selectedNote, setSelectedNote] = useState(0);
const hardwareState = useHardwareState();
// 我们需要获取 MIDI 输出设备
// 这里简化处理,实际应该从 Context 获取
const midiOutput = useMemo(() => {
// 伪代码:从 useMidiConnection 获取输出
return null;
}, []);
// 监听硬件状态变化,更新 UI 选中状态
useEffect(() => {
// 检查是否有任何 MIDI 键被按下
const pressedKeys = Object.keys(hardwareState.midi).filter(k => hardwareState.midi[k].isOn);
if (pressedKeys.length > 0) {
const lastPressed = pressedKeys[pressedKeys.length - 1];
setSelectedNote(parseInt(lastPressed));
}
}, [hardwareState.midi]);
// 监听 UI 变化,发送 MIDI 命令
useEffect(() => {
if (midiOutput) {
// 发送 Note On
midiOutput.send([0x90, selectedNote, 127]);
// 设置一个定时器来发送 Note Off (模拟按键释放)
const timer = setTimeout(() => {
midiOutput.send([0x80, selectedNote, 0]);
}, 500);
return () => clearTimeout(timer);
}
}, [selectedNote, midiOutput]);
return (
<div>
<p>点击下方的格子来触发 MIDI 信号,或者直接按键盘。</p>
<div style={{ display: 'flex' }}>
{[0, 1, 2, 3].map((n) => (
<div
key={n}
style={{
width: '50px',
height: '50px',
background: selectedNote === n ? '#f0f' : '#ccc',
margin: '5px',
cursor: 'pointer'
}}
onClick={() => setSelectedNote(n)}
>
{n}
</div>
))}
</div>
</div>
);
};
第六部分:深入解析——那些你可能遇到的“坑”
讲了这么多美好的东西,现实往往是残酷的。在 React 和硬件交互的战场上,你会遇到很多坑。
1. 浏览器权限与安全策略
浏览器是胆小的。它不会自动连上你的 MIDI 设备或 HID 设备。你必须让用户主动授权。
- MIDI: 使用
navigator.requestMIDIAccess()。这会弹出一个浏览器原生的对话框。如果你的应用没有用户交互(比如点击按钮),这会直接失败。 - HID: 这更严格。浏览器要求你必须有一个用户手势(Click, Keydown 等)才能调用
navigator.hid.requestDevice()。
解决方案: 在你的应用入口或者一个“设置”面板里,放置一个显眼的“连接设备”按钮。
const ConnectButton = () => {
const [loading, setLoading] = useState(false);
const handleConnect = async () => {
setLoading(true);
try {
// 触发连接逻辑...
await navigator.hid.requestDevice({ filters: [...] });
} catch (e) {
console.error("连接被拒绝");
} finally {
setLoading(false);
}
};
return <button onClick={handleConnect} disabled={loading}>{loading ? "连接中..." : "连接硬件"}</button>;
};
2. 事件循环与 React 批处理
这是一个经典问题。假设你有一个 HID 设备,它以 1000Hz 的频率发送数据(比如一个摇杆)。每次数据到达,useEffect 都会触发,导致 setState 被调用。
如果 React 的批处理机制没有生效(或者你使用了多个 useState),你的 UI 可能会因为过于频繁的重渲染而卡顿。
解决方案:
- 节流: 不要对每个字节都渲染。只在状态改变时渲染。
- 使用
useLayoutEffect而不是useEffect: 如果你需要同步硬件状态和 UI(比如摇杆位置),useLayoutEffect可以在浏览器绘制前执行,避免视觉上的跳动。 - 避免在渲染函数中做繁重计算: 确保你的硬件状态解析逻辑非常快。
3. 设备丢失
用户拔掉了 USB 线,或者重启了电脑。你的 input 或 device 变量变成了 null。
解决方案: 监听 disconnect 事件。
device.addEventListener('disconnect', (event) => {
console.log("设备断开连接", event.device);
setDevice(null);
// 重新连接逻辑...
});
4. MIDI 协议的复杂性
MIDI 1.0 的协议很古老。一个简单的 Note On 其实包含很多细节:Channel(通道),Note Number(音符),Velocity(力度)。
- Status Byte: 0x9x (Note On), 0x8x (Note Off), 0xBx (Control Change)。
- Data Bytes: 1 和 2。
如果你在处理 MIDI 数据时,不小心把 Status Byte 当成了 Note Number,你的程序就会崩溃或者发出奇怪的噪音。
最佳实践: 写一个简单的 MIDI 解析器工具函数。
const parseMidiMessage = (data) => {
const status = data[0];
const command = status >> 4; // 高 4 位是命令
const channel = status & 0x0F; // 低 4 位是通道
if (command === 0x9) { // Note On
return {
type: 'noteOn',
channel,
note: data[1],
velocity: data[2]
};
} else if (command === 0x8) { // Note Off
return {
type: 'noteOff',
channel,
note: data[1],
velocity: data[2]
};
} else if (command === 0xB) { // Control Change
return {
type: 'controlChange',
channel,
controlNumber: data[1],
value: data[2]
};
}
return null;
};
第七部分:Web HID 的“二进制噩梦”
Web MIDI 比较规范,但 Web HID 是任意的。每个 USB 设备的“报告描述符”都不一样。
- Report Descriptor: 这是设备出厂时写死的一张说明书,告诉电脑“我的数据包长什么样”。
- Input Report: 设备发给电脑的实际数据。
如果你要做一个通用的 HID 驱动,你必须解析 Report Descriptor。这对普通开发者来说太难了。
但是! 对于大多数消费级设备(键盘、手柄、无人机),我们可以使用一些库来解析,或者直接通过经验猜测。
例如,一个标准的 Xbox 手柄的输入报告通常是这样的:
- Byte 0: Buttons (A, B, X, Y, LB, RB, Select, Start, Stick L, Stick R)
- Byte 1: Buttons (D-Pad Up, Down, Left, Right)
- Byte 2: Left Stick X
- Byte 3: Left Stick Y
- …
React 中的处理:
// 这是一个非常简化的解析器,实际需要根据设备文档
const parseXboxReport = (data) => {
const buttons = {
A: !!(data[0] & 0x01),
B: !!(data[0] & 0x02),
X: !!(data[0] & 0x04),
Y: !!(data[0] & 0x08),
LB: !!(data[0] & 0x10),
RB: !!(data[0] & 0x20),
Start: !!(data[0] & 0x40),
Back: !!(data[0] & 0x80),
};
const leftStickX = (data[2] - 127) * 2; // 中心点归一化
const leftStickY = (data[3] - 127) * 2;
return { buttons, leftStickX, leftStickY };
};
记住,Web HID 的核心在于解析。你收到的只是一堆 Uint8Array,你需要把它们翻译成人话。
第八部分:高级模式——状态同步与异步
在实际应用中,你可能会遇到这样的情况:React 的状态更新是异步的,但硬件事件的触发是实时的。
例如,你有一个 LED 灯,你想让它根据 React 的 brightness 状态来改变亮度。
useEffect(() => {
const interval = setInterval(() => {
// 这里我们有一个定时器,每 50ms 检查一次状态
// 在生产环境中,你应该使用一个状态订阅库(如 Recoil, Redux)或者 RxJS
const brightness = myGlobalState.brightness;
midiOutput.send([0xB0, 0x07, brightness]); // CC7: Main Volume
}, 50);
return () => clearInterval(interval);
}, [midiOutput]);
更好的方法:响应式流。
虽然我们今天主要讲原生 React,但如果你处理非常复杂的硬件交互,建议引入 RxJS (Reactive Extensions for JavaScript)。
RxJS 可以让你把硬件事件变成一个流(Observable),然后使用 map, filter, debounceTime 等操作符来处理数据,最后再更新 React 的 State。
import { fromEvent } from 'rxjs';
import { map, throttleTime } from 'rxjs/operators';
// 将 MIDI 事件转为 RxJS 流
const midiStream$ = fromEvent(midiInput, 'midimessage');
// 管道:只取 Note On,并且每 100ms 取一次(防抖)
midiStream$.pipe(
map(e => parseMidiMessage(e.data)),
filter(msg => msg && msg.type === 'noteOn'),
throttleTime(100)
).subscribe(msg => {
// 这里是纯粹的逻辑处理,不需要管 React 的渲染
updateSequencerState(msg.note);
});
第九部分:总结与展望
好了,各位同学。今天我们走得很远。
我们从 React 的声明式 UI 出发,跨越了代码的边界,来到了物理世界。我们学会了如何使用 navigator.requestMIDIAccess 拿到 MIDI 的钥匙,如何使用 navigator.hid.requestDevice 拿到 HID 的网线。
我们构建了一个架构:
- 底层: 事件监听器,将原始二进制数据解析成结构化对象。
- 中间层: 状态管理,将硬件数据映射到 React State。
- 顶层: 声明式组件,根据 State 自动渲染 UI。
核心思想回顾:
- 不要直接在 Render 中操作硬件。 保持 UI 的纯粹。
- 使用 Context 或 Store 作为桥梁。 不要让组件之间互相传递硬件引用。
- 善用
useEffect。 它是连接 React 世界和硬件世界的桥梁。 - 处理异步和权限。 这不是小事,这是用户交互的第一步。
未来的展望:
随着 WebAssembly 的普及,我们可以把更复杂的硬件驱动逻辑放在 Wasm 里运行,提高性能。随着 Web Bluetooth 的进一步发展,我们甚至可以控制智能家居。
现在的浏览器,已经不仅仅是一个文档阅读器了。它是一个操作系统。而 React,正是连接这个操作系统与物理世界的最优雅的 UI 框架。
最后,给各位的建议:
去买一个 MIDI 键盘(几十块钱的都行),去弄一个 USB 游戏手柄。不要只盯着屏幕。去触摸,去感知。当你看到 React 的 UI 因为你的一个按键动作而实时跳动时,那种感觉,比写一个 console.log('Hello World') 要爽上一万倍。
好了,下课!记得把你的 USB 线插好,去写代码吧!
(附录:完整示例代码结构参考)
为了方便大家直接上手,这里给出一个简化版的完整项目结构:
src/
├── components/
│ ├── HardwareVisualizer.tsx // 可视化硬件状态的组件
│ └── ControlPanel.tsx // 控制面板
├── contexts/
│ └── HardwareContext.tsx // 全局硬件状态 Context
├── hooks/
│ ├── useMidiConnection.ts // MIDI 连接 Hook
│ ├── useHidConnection.ts // HID 连接 Hook
│ └── useHardwareState.ts // 状态映射 Hook
├── types/
│ └── index.ts // 类型定义
└── App.tsx
祝大家编程愉快,硬件常亮!