各位同学,大家好!欢迎来到今天的硬核前端技术分享会。我是你们的讲师,一个在浏览器里和硬件设备“调情”多年的资深工程师。
今天我们要聊的话题,听起来很高大上,实际上非常“接地气”——React 与 蓝牙低功耗(Web Bluetooth):构建基于 React Context 抽象的硬件特征值读取与写入协议。
别被这些术语吓到了。在座的各位,谁的手机没连过蓝牙耳机?谁没在智能家居里控制过灯泡?我们每天都在用蓝牙,但我们通常只把它当成一个“自动连接”的后台服务。但今天,我们要把这个后台服务搬进浏览器,用 React 把它变得可控、可预测、甚至有点优雅。
为什么我们需要这么做?因为原生 Web Bluetooth API 简直是反人类的设计。
第一部分:Web Bluetooth API 的“渣男”本质
首先,让我们看看浏览器原生的蓝牙 API。如果你直接在 useEffect 里写原生代码,你会得到一段长达 50 行的 Promise 链。它充满了回调地狱,充满了 await,充满了 try...catch。
// 这就是你们不想看到的原生写法
async function connectToBLE() {
try {
const device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: ['battery_service']
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('battery_service');
const characteristic = await service.getCharacteristic('battery_level');
const value = await characteristic.readValue();
console.log(`Battery level is ${value.getUint8(0)}%`);
// 这里开始订阅通知...
characteristic.startNotifications().then(_ => {
characteristic.addEventListener('characteristicvaluechanged', handleNotifications);
});
} catch (error) {
console.error(error);
}
}
看到了吗?这就是所谓的“渣男”代码。如果你在组件里这么写,你的组件会变得臃肿不堪,充满了副作用,而且一旦设备断开,整个应用可能会崩溃。而且,你不能把这个逻辑复用到另一个页面。
我们需要一个翻译官。这个翻译官就是 React Context。
Context API 就像是一个巨大的、透明的、挂在墙上的公告板。所有的组件都可以看这个公告板,而不需要互相打电话问:“嘿,蓝牙连上了吗?”、“嘿,我发个指令过去”。我们把所有的硬件状态——连接状态、设备对象、特征值引用、错误信息——都扔到这个公告板上。
第二部分:设计我们的“硬件大脑” – Context 结构
我们的目标是一个名为 HardwareContext 的东西。它需要包含以下“器官”:
- 状态管理:
isConnected,device,services(GATT Service 和 Characteristic 的引用),lastError。 - 核心动作:
connect,disconnect,read,write,subscribe(监听数据)。 - 协议层:一个简单的指令集,把人类能看懂的 JSON 或数字,转换成硬件能听懂的二进制流。
让我们先定义一下我们的数据结构。为了简单起见,我们假设我们要控制一个“智能魔方”或者一个“温度传感器”。我们定义一个简单的协议:
- 写入指令:
{ type: 'SET_COLOR', value: '#FF0000' } - 读取指令:
{ type: 'GET_STATUS' }
第三部分:构建 BluetoothProvider
现在,让我们开始编写 Provider。这将是整个架构的核心。
首先,我们需要一个 Hook 来获取上下文:useHardware。
// HardwareContext.js
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
// 创建 Context
const HardwareContext = createContext();
// 定义初始状态
const initialState = {
isConnected: false,
isScanning: false,
device: null,
services: null,
dataBuffer: null,
error: null,
// Actions
connect: () => {},
disconnect: () => {},
write: () => {},
read: () => {},
};
export const HardwareProvider = ({ children }) => {
// --- 状态定义 ---
const [state, setState] = useState(initialState);
// 使用 ref 来保存设备对象和特征值引用,避免不必要的重渲染
const deviceRef = useRef(null);
const serverRef = useRef(null);
const characteristicRefs = useRef({}); // 存储不同的特征值句柄
const notificationHandlers = useRef(new Map());
// --- 核心方法 ---
// 1. 连接设备
const connect = useCallback(async (deviceNameFilter = null) => {
try {
setState(prev => ({ ...prev, isScanning: true, error: null }));
// 请求设备
const options = {
acceptAllDevices: !deviceNameFilter,
optionalServices: ['battery_service', 'device_information'], // 填入你的服务 UUID
};
// 注意:Web Bluetooth 需要 HTTPS 或 localhost
const device = await navigator.bluetooth.requestDevice(options);
deviceRef.current = device;
// 监听断开连接事件(非常重要!)
device.addEventListener('gattserverdisconnected', handleDisconnect);
// 连接 GATT 服务器
const server = await device.gatt.connect();
serverRef.current = server;
setState(prev => ({ ...prev, isConnected: true, device: device }));
// 获取服务
const batteryService = await server.getPrimaryService('battery_service');
const deviceInfoService = await server.getPrimaryService('device_information');
// 缓存服务引用
setState(prev => ({ ...prev, services: { batteryService, deviceInfoService } }));
// 获取特征值并初始化订阅
await initCharacteristics({ batteryService, deviceInfoService });
} catch (error) {
console.error("Connection Error:", error);
setState(prev => ({ ...prev, error: error.message, isScanning: false }));
}
}, []);
// 2. 初始化特征值与订阅
const initCharacteristics = async (services) => {
const { batteryService, deviceInfoService } = services;
// 假设我们要监听电池变化
const batteryCharacteristic = await batteryService.getCharacteristic('battery_level');
characteristicRefs.current.battery = batteryCharacteristic;
// 启动通知
await batteryCharacteristic.startNotifications();
batteryCharacteristic.addEventListener('characteristicvaluechanged', (event) => {
const value = event.target.value;
// 这里处理数据解析
const batteryLevel = value.getUint8(0);
console.log(`Battery updated: ${batteryLevel}%`);
setState(prev => ({ ...prev, dataBuffer: { battery: batteryLevel } }));
});
};
// 3. 断开连接
const disconnect = useCallback(() => {
if (deviceRef.current) {
deviceRef.current.gatt.disconnect();
}
}, []);
// 4. 读取数据 (Write 操作)
const write = useCallback(async (serviceUuid, characteristicUuid, data) => {
if (!state.isConnected || !serverRef.current) {
throw new Error("Device not connected");
}
try {
const service = await serverRef.current.getPrimaryService(serviceUuid);
const characteristic = await service.getCharacteristic(characteristicUuid);
// 将数据转换为 ArrayBuffer
const encoder = new TextEncoder();
const dataView = encoder.encode(data);
await characteristic.writeValue(dataView);
console.log(`Written to ${characteristicUuid}:`, data);
// 某些设备需要写入后读取确认,这里可以加个延时或者轮询逻辑
return true;
} catch (error) {
console.error("Write Error:", error);
throw error;
}
}, [state.isConnected]);
// 5. 读取数据
const read = useCallback(async (serviceUuid, characteristicUuid) => {
if (!state.isConnected || !serverRef.current) {
throw new Error("Device not connected");
}
try {
const service = await serverRef.current.getPrimaryService(serviceUuid);
const characteristic = await service.getCharacteristic(characteristicUuid);
const value = await characteristic.readValue();
// 将 ArrayBuffer 转回字符串或数字
const decoder = new TextDecoder();
return decoder.decode(value);
} catch (error) {
console.error("Read Error:", error);
throw error;
}
}, [state.isConnected]);
// 辅助:处理断开连接
const handleDisconnect = () => {
console.log("Device disconnected");
deviceRef.current = null;
serverRef.current = null;
setState(prev => ({
...prev,
isConnected: false,
device: null,
services: null
}));
};
// --- 上下文值 ---
const value = {
...state,
connect,
disconnect,
write,
read,
};
return <HardwareContext.Provider value={value}>{children}</HardwareContext.Provider>;
};
export const useHardware = () => useContext(HardwareContext);
看,这就是我们的“大脑”。它把浏览器原生的、丑陋的、异步的 Web Bluetooth API,包装成了一个干净的 React Hooks 接口。现在,我们的 UI 组件只需要关心 isConnected 和调用 write 方法就行了。
第四部分:UI 层的“指挥官” – 组件实现
现在,让我们看看如何使用这个 Context。想象一下,我们有一个“控制面板”。
// ControlPanel.js
import React, { useState } from 'react';
import { useHardware } from './HardwareContext';
const ControlPanel = () => {
const { isConnected, connect, disconnect, write, read, error } = useHardware();
const [status, setStatus] = useState('');
const handleConnect = async () => {
try {
// 这里可以传入特定的设备名称过滤,比如 'MySmartDevice'
await connect('MySmartDevice');
setStatus('Connected successfully!');
} catch (err) {
setStatus('Connection failed');
}
};
const handleToggleLED = async () => {
try {
// 假设我们的协议是:发送 "ON" 或 "OFF" 到服务 UUID 和特征值 UUID
await write('battery_service', 'battery_level', 'ON');
setStatus('LED turned ON');
} catch (err) {
setStatus('Failed to send command');
}
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', margin: '20px', borderRadius: '8px' }}>
<h2>Hardware Control Panel</h2>
<div>
<button onClick={handleConnect} disabled={isConnected}>
{isConnected ? 'Connected' : 'Connect Device'}
</button>
{isConnected && <button onClick={disconnect}>Disconnect</button>}
</div>
<div style={{ marginTop: '20px' }}>
<button onClick={handleToggleLED}>Toggle LED</button>
<p>Status: {status}</p>
<p style={{ color: 'red' }}>{error}</p>
</div>
</div>
);
};
export default ControlPanel;
这就是魔法。我们的 ControlPanel 组件完全不知道蓝牙是怎么工作的,它只是问 Context:“嘿,我连上了吗?”、“我想发个指令”。Context 负责处理底层那些繁琐的 gatt.getPrimaryService 和 valueChanged 事件。
第五部分:协议抽象 – 让数据可读
上面的代码里,我们直接传了字符串 “ON” 和 “OFF”。但在真实的工业场景或硬件开发中,你通常需要操作字节。你需要构建协议帧,包括头部、长度、校验和、数据体。
我们需要一个更强大的协议抽象层。让我们创建一个 ProtocolHandler 类或函数,放在 Provider 内部。
假设我们的硬件协议长这样:
[0xAA, 0x55, CMD, LEN, DATA, CRC]
// 在 HardwareProvider 内部,或者单独的 utils 文件中
const buildCommand = (cmdCode, data) => {
// cmdCode: 0x01 for LED ON, 0x02 for LED OFF
// data: payload bytes
const header = [0xAA, 0x55];
const length = 1 + data.length; // CMD + Data
const footer = [calculateCRC([0xAA, 0x55, cmdCode, length, ...data])];
return new Uint8Array([...header, cmdCode, length, ...data, ...footer]);
};
// 在 write 方法中使用
const write = async (serviceUuid, characteristicUuid, commandType, payload = []) => {
const buffer = buildCommand(commandType, payload);
// ... 执行 writeValue(buffer) ...
};
通过这种方式,我们在 UI 层永远不需要关心 0xAA 或校验和,我们只需要调用 writeCommand('LED_ON')。Context 内部负责把这些翻译成机器听得懂的代码。
第六部分:通知机制 – 处理“推送”数据
这是最难也是最关键的部分。硬件会不断推送数据(比如心跳包、温度变化)。在 Web Bluetooth 中,这通过 characteristicvaluechanged 事件触发。
在 React 中,我们如何处理这个事件流?
错误示范:
在 useEffect 里直接调用 addEventListener。当组件卸载时,事件监听器没有移除,导致内存泄漏。而且,如果组件重渲染,事件监听器会被重复添加。
正确示范:
我们需要使用 useRef 来保存事件监听器的引用,并在 useEffect 的清理函数中移除它们。
// 修正后的 initCharacteristics 部分
useEffect(() => {
// ... 获取特征值逻辑 ...
const handleData = (event) => {
const value = event.target.value;
// 处理数据
const hexValue = Array.from(new Uint8Array(value))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log('Incoming Data:', hexValue);
// 更新 Context 状态,触发 UI 重新渲染
setState(prev => ({ ...prev, dataBuffer: hexValue }));
};
characteristic.addEventListener('characteristicvaluechanged', handleData);
// 清理函数:组件卸载或设备断开时移除监听
return () => {
characteristic.removeEventListener('characteristicvaluechanged', handleData);
};
}, [state.isConnected]); // 依赖 isConnected,确保只在连接时监听
第七部分:错误处理与重连策略
Web Bluetooth 的设备并不总是稳定的。用户可能会拔掉 USB 蓝牙适配器,或者手机蓝牙会自动休眠。
1. 全局错误捕获:
在 Provider 的顶层,我们不应该让错误直接让整个应用崩溃。我们需要一个全局的错误状态。
// 在 Provider 的顶层
try {
// ... 连接逻辑 ...
} catch (err) {
if (err.name === 'NotFoundError') {
setState(prev => ({ ...prev, error: "Device not found, please scan again" }));
} else if (err.name === 'SecurityError') {
setState(prev => ({ ...prev, error: "HTTPS required for Web Bluetooth" }));
} else {
setState(prev => ({ ...prev, error: err.message }));
}
}
2. 自动重连:
当 gattserverdisconnected 事件触发时,我们无法简单地再次调用 connect,因为 device 对象可能已经失效(虽然浏览器通常会保留它,但最好重置状态)。
最简单的策略是:提示用户重新连接。
const handleDisconnect = () => {
setState(prev => ({ ...prev, isConnected: false }));
// 可以在这里显示一个 "Reconnect" 按钮
};
更高级的策略是:使用 navigator.bluetooth.getDevices() 尝试重新连接已知的设备。但这需要设备支持 remembered 属性,且浏览器缓存策略各异。
第八部分:TypeScript 时代的最佳实践
虽然我们刚才用的是 JS,但作为资深专家,我强烈建议你用 TypeScript。Web Bluetooth 的 API 在 TS 中有很棒的类型定义(@types/web-bluetooth)。
定义一个接口来描述你的设备状态:
interface HardwareState {
isConnected: boolean;
device: BluetoothDevice | null;
services: {
battery: BluetoothRemoteGATTCharacteristic | null;
// 其他特征值...
} | null;
error: string | null;
}
interface HardwareContextType {
...HardwareState,
connect: (filter?: BluetoothDeviceFilter) => Promise<void>;
write: (uuid: string, charUuid: string, data: string) => Promise<void>;
read: (uuid: string, charUuid: string) => Promise<string>;
}
// 在 Provider 中
const [state, setState] = useState<HardwareState>({
isConnected: false,
device: null,
services: null,
error: null,
});
类型检查能帮你发现很多低级错误,比如在 readValue 后忘记把 ArrayBuffer 转换成字符串,或者传递了错误的 UUID。
第九部分:性能优化 – 别让蓝牙拖慢你的页面
- 防抖与节流:如果你的 UI 有一个按钮,用户疯狂点击“发送指令”,千万不要让浏览器连续发送 100 个指令。硬件可能处理不过来,或者浏览器会拒绝发送。使用
useDebounce。 - 避免在渲染循环中操作蓝牙:永远不要在
render函数或useEffect的依赖项里包含device对象(除非你做了深拷贝)。每次重渲染都重新startNotifications是灾难性的。 - 数据节流:如果传感器每 10ms 发送一次数据,而你的 UI 更新一次需要 16ms,你会卡死页面。在 Context 里处理数据时,使用一个简单的节流逻辑,比如每 100ms 更新一次 UI 状态。
第十部分:iOS 与 Android 的“爱恨情仇”
Web Bluetooth 在移动端的支持情况非常微妙。
- Android:支持得很好。大多数现代浏览器都能直接调用
requestDevice。 - iOS (Safari):这是一个巨大的坑。
- iOS 不支持
requestDevice。你无法在代码里弹出一个窗口让用户选择设备。 - iOS 不支持
BluetoothRemoteGATTServer.getServices()。 - 解决方案:iOS 要求用户必须先在系统设置里手动配对设备。然后,你需要使用
navigator.bluetooth.getDevices()来获取已配对设备的列表,而不是请求新设备。
- iOS 不支持
这导致了 UI 逻辑的巨大差异:
// Android 逻辑
const connect = async () => {
const device = await navigator.bluetooth.requestDevice({...}); // 弹窗
// ...
};
// iOS 逻辑
const connect = async () => {
const devices = await navigator.bluetooth.getDevices();
const device = devices.find(d => d.name === 'MyDevice'); // 找设备
if (device) {
// 尝试连接
const server = await device.gatt.connect();
} else {
alert("Please pair device in system settings first!");
}
};
所以,你的 Context 必须检测平台,或者提供一个统一的 UI 接口,底层逻辑自动切换。
第十一部分:构建一个完整的“智能温控器”示例
让我们把所有东西整合起来,模拟一个真实的场景。
场景:
- UI 显示当前温度(来自蓝牙通知)。
- UI 有一个滑块,用来设置目标温度(写入蓝牙)。
- UI 有一个开关,用来控制电源。
代码整合示例(伪代码流):
// 1. Provider 负责数据流
// ... (之前的代码) ...
// 新增方法:
const setTemperature = async (temp) => {
// temp 是一个 0-100 的整数
// 协议转换:[CMD, TEMP_DATA]
const buffer = new Uint8Array([0x01, temp]);
await write('thermo_service', 'write_char', buffer);
};
// 2. UI 组件
const Thermostat = () => {
const { isConnected, dataBuffer, setTemperature } = useHardware();
const [targetTemp, setTargetTemp] = useState(25);
// 监听数据更新
useEffect(() => {
if (dataBuffer) {
// 假设 dataBuffer 是一个对象 { current: 22.5, target: 25 }
// 我们可以更新 UI
}
}, [dataBuffer]);
return (
<div>
<h1>Thermostat</h1>
<p>Current Temp: {dataBuffer?.current || 'N/A'} °C</p>
<input
type="range"
min="16"
max="30"
value={targetTemp}
onChange={(e) => setTargetTemp(e.target.value)}
/>
<button onClick={() => setTemperature(targetTemp)}>
Set Temperature
</button>
{!isConnected && <div>Please connect device.</div>}
</div>
);
};
第十二部分:调试技巧 – 如何在 Chrome DevTools 里看到数据?
很多时候,你的代码写得没问题,但就是读不到数据。这时候你需要学会“显微镜”式的调试。
-
Chrome DevTools -> More Tools -> Bluetooth Inspector:
这个工具会显示所有已配对设备的列表。点击设备,你可以看到它的 GATT 服务树。你可以在这里手动点击“Read Characteristic”或“Write Characteristic”。这是验证你的硬件协议(UUID、数据格式)是否正确的第一步。 -
console.log 大法:
在handleData里,把event.target.value转成Uint8Array打印出来。const view = new Uint8Array(event.target.value.buffer); console.log("Raw Bytes:", view);看看字节是不是你预期的
[0xAA, 0x55, ...]。如果不是,说明你的协议解析逻辑错了,或者硬件发送的数据格式不对。 -
网络面板:
Web Bluetooth 使用 WebSocket 传输数据(具体是 RFCOMM over SPP over WebSocket)。你可以看到底层的通信情况,虽然通常不是 debug 代码的主要方式,但有时候能看到连接握手失败的信息。
第十三部分:终极思考 – Web Bluetooth 的未来
Web Bluetooth 还是一个相对较新的 API。它允许浏览器直接控制硬件,这在过去是不可想象的。这标志着“万物互联”在浏览器端迈出了重要一步。
但是,它也有局限性:
- 权限:用户必须手动授权。如果你写一个网页,想自动连接用户的智能冰箱,浏览器会直接拒绝。这是安全机制。
- 兼容性:Firefox 目前不支持(或者支持得很差)。iOS 的支持非常有限。
作为开发者,我们需要保持谦卑。在实现复杂功能时,要考虑到用户可能使用的是不支持该 API 的浏览器。提供一个优雅的降级方案(比如提示用户下载 App)是必要的。
结语:从“Hello World”到“Hello Hardware”
通过今天这个讲座,我们从最原始的 navigator.bluetooth 开始,一步步构建了一个基于 React Context 的抽象层。
我们学会了:
- 如何用 Context 封装复杂的状态和副作用。
- 如何处理异步的读写操作。
- 如何构建协议来解析二进制流。
- 如何处理通知和错误。
- 如何考虑跨平台(iOS/Android)的差异。
记住,Web 开发不仅仅是 DOM 操作。当你开始与硬件对话时,你进入了物理世界。那里的延迟、断连、协议错误,都是真实存在的挑战。但当你成功地在浏览器里点亮一盏 LED 灯,或者读取到一个传感器的温度时,那种成就感是编写一个漂亮的 React 组件无法比拟的。
好了,今天的讲座就到这里。希望大家在接下来的开发中,能写出既优雅又健壮的硬件控制应用。如果有任何问题,欢迎在评论区吐槽。下课!