React Web蓝牙心率监测器:浏览器端健康应用开发
概述
Web Bluetooth API允许网页应用直接与蓝牙低功耗(BLE)设备通信,无需安装原生应用。
本文将实现:
- 连接蓝牙心率监测器
- 实时接收心率数据
- 可视化心率曲线
- PWA离线支持
技术栈
code
React Web App
├── Web Bluetooth API # 蓝牙连接
├── Chart.js # 数据可视化
├── Service Worker # PWA支持
└── IndexedDB # 本地数据存储
Code collapsed
浏览器兼容性
支持情况
| 浏览器 | Web Bluetooth | 备注 |
|---|---|---|
| Chrome | ✅ 56+ | 完整支持 |
| Edge | ✅ 79+ | 完整支持 |
| Firefox | ❌ | 实验性支持 |
| Safari | ❌ | 不支持 |
| Opera | ✅ 43+ | 完整支持 |
注意:Web Bluetooth要求:
- HTTPS环境(或localhost)
- 用户手势触发连接
- 主动设备配对
项目设置
创建项目
code
# 使用Vite创建React项目
npm create vite@latest heart-monitor -- --template react
cd heart-monitor
# 安装依赖
npm install chart.js react-chartjs-2
npm install workbox-webpack-plugin
# 启动开发服务器
npm run dev
Code collapsed
PWA配置
code
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
https: true, // Web Bluetooth需要HTTPS
},
build: {
target: 'esnext', // 支持最新JavaScript特性
}
});
Code collapsed
Web Bluetooth API
1. 蓝牙设备扫描
code
// hooks/useBluetooth.ts
import { useState, useCallback, useRef } from 'react';
interface BluetoothDevice {
device: BluetoothDevice | null;
server: BluetoothRemoteGATTServer | null;
connected: boolean;
error: string | null;
}
export function useBluetooth() {
const [state, setState] = useState<BluetoothDevice>({
device: null,
server: null,
connected: false,
error: null
});
const characteristicRef = useRef<BluetoothRemoteGATTCharacteristic | null>(null);
/**
* 扫描并连接蓝牙设备
*/
const connect = useCallback(async () => {
try {
setState(prev => ({ ...prev, error: null }));
// 1. 检查浏览器支持
if (!navigator.bluetooth) {
throw new Error('您的浏览器不支持Web Bluetooth API');
}
// 2. 请求设备并连接
// Heart Rate Service UUID: 0x180D
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }],
optionalServices: ['battery_service'] // 可选:电池服务
});
console.log('设备已找到:', device.name);
// 3. 连接GATT服务器
const server = await device.gatt.connect();
console.log('GATT服务器已连接');
// 4. 获取心率服务
const service = await server.getPrimaryService('heart_rate');
console.log('心率服务已找到');
// 5. 获取心率测量特征
const characteristic = await service.getCharacteristic('heart_rate_measurement');
console.log('心率特征已找到');
// 6. 启用通知
await characteristic.startNotifications();
console.log('心率通知已启用');
// 7. 监听数据
characteristic.addEventListener('characteristicvaluechanged', (event: any) => {
const value = event.target.value;
const heartRate = parseHeartRate(value);
// 触发自定义事件
window.dispatchEvent(new CustomEvent('heartrate', {
detail: { heartRate, timestamp: Date.now() }
}));
});
characteristicRef.current = characteristic;
setState({
device,
server,
connected: true,
error: null
});
} catch (error: any) {
console.error('蓝牙连接失败:', error);
setState(prev => ({
...prev,
error: error.message || '连接失败'
}));
}
}, []);
/**
* 断开连接
*/
const disconnect = useCallback(async () => {
if (state.device && state.device.gatt.connected) {
state.device.gatt.disconnect();
}
characteristicRef.current = null;
setState({
device: null,
server: null,
connected: false,
error: null
});
}, [state.device, state.server]);
return {
...state,
connect,
disconnect
};
}
/**
* 解析心率数据
*/
function parseHeartRate(data: DataView): number {
const flags = data.getUint8(0);
// 检查16位格式标志(bit 0)
const is16Bit = (flags & 0x1) === 0x1;
if (is16Bit) {
// 16位心率值(bytes 1-2)
return data.getUint16(1, true); // little-endian
} else {
// 8位心率值(byte 1)
return data.getUint8(1);
}
}
Code collapsed
2. 心率数据Hook
code
// hooks/useHeartRate.ts
import { useState, useEffect } from 'react';
export function useHeartRate() {
const [heartRate, setHeartRate] = useState<number | null>(null);
const [history, setHistory] = useState<Array<{
value: number;
timestamp: number;
}>>([]);
useEffect(() => {
const handleHeartRateUpdate = (event: any) => {
const { heartRate: newHeartRate, timestamp } = event.detail;
setHeartRate(newHeartRate);
setHistory(prev => {
const newHistory = [...prev, { value: newHeartRate, timestamp }];
// 只保留最近100个数据点
if (newHistory.length > 100) {
return newHistory.slice(-100);
}
return newHistory;
});
};
window.addEventListener('heartrate', handleHeartRateUpdate);
return () => {
window.removeEventListener('heartrate', handleHeartRateUpdate);
};
}, []);
return {
heartRate,
history,
average: history.length > 0
? history.reduce((sum, h) => sum + h.value, 0) / history.length
: null
};
}
Code collapsed
React组件
主应用组件
code
// App.tsx
import { useState } from 'react';
import { useBluetooth } from './hooks/useBluetooth';
import { useHeartRate } from './hooks/useHeartRate';
import HeartRateChart from './components/HeartRateChart';
import HeartRateDisplay from './components/HeartRateDisplay';
import ConnectionButton from './components/ConnectionButton';
import './App.css';
function App() {
const bluetooth = useBluetooth();
const heartRateData = useHeartRate();
const [isMonitoring, setIsMonitoring] = useState(false);
const handleConnect = async () => {
if (bluetooth.connected) {
bluetooth.disconnect();
setIsMonitoring(false);
} else {
await bluetooth.connect();
if (!bluetooth.error) {
setIsMonitoring(true);
}
}
};
return (
<div className: "App">
<header>
<h1>❤️ 心率监测器</h1>
<p>使用Web Bluetooth API连接您的蓝牙心率设备</p>
</header>
<main>
{/* 连接状态 */}
<div className: "connection-status">
<ConnectionButton
connected={bluetooth.connected}
onClick={handleConnect}
error={bluetooth.error}
/>
{bluetooth.connected && (
<span className: "status-indicator active">已连接</span>
)}
</div>
{/* 错误显示 */}
{bluetooth.error && (
<div className: "error-message">
❌ {bluetooth.error}
</div>
)}
{/* 心率显示 */}
{bluetooth.connected && (
<>
<HeartRateDisplay
heartRate={heartRateData.heartRate}
average={heartRateData.average}
/>
{/* 心率图表 */}
{heartRateData.history.length > 0 && (
<HeartRateChart data={heartRateData.history} />
)}
</>
)}
{/* 使用说明 */}
{!bluetooth.connected && (
<div className: "instructions">
<h2>📋 使用说明</h2>
<ol>
<li>确保您的蓝牙心率设备已开启且可被发现</li>
<li>点击"连接设备"按钮</li>
<li>在浏览器弹出的设备列表中选择您的设备</li>
<li>配对后即可看到实时心率数据</li>
</ol>
<div className: "browser-support">
<h3>🌐 浏览器支持</h3>
<p>推荐使用Chrome、Edge或Opera浏览器</p>
<p>⚠️ Safari和Firefox不支持Web Bluetooth API</p>
</div>
</div>
)}
</main>
<footer>
<p>数据仅在本地处理,不会上传到任何服务器</p>
</footer>
</div>
);
}
export default App;
Code collapsed
心率显示组件
code
// components/HeartRateDisplay.tsx
import React from 'react';
interface HeartRateDisplayProps {
heartRate: number | null;
average: number | null;
}
export function HeartRateDisplay({ heartRate, average }: HeartRateDisplayProps) {
const getZone = (hr: number) => {
if (hr < 60) return { zone: '休息', color: '#3498db' };
if (hr < 70) return { zone: '燃脂', color: '#2ecc71' };
if (hr < 80) return { zone: '有氧', color: '#f39c12' };
if (hr < 90) return { zone: '无氧', color: '#e67e22' };
return { zone: '极限', color: '#e74c3c' };
};
const currentZone = heartRate ? getZone(heartRate) : null;
return (
<div className: "heart-rate-display">
{/* 当前心率 */}
<div className: "current-heart-rate">
<span className: "label">当前心率</span>
<div className: "heart-rate-value" style={{ color: currentZone?.color }}>
{heartRate !== null ? (
<>
<span className: "value">{heartRate}</span>
<span className: "unit">BPM</span>
</>
) : (
<span className: "loading">--</span>
)}
</div>
{currentZone && (
<div className: "zone-badge" style={{ backgroundColor: currentZone.color }}>
{currentZone.zone}
</div>
)}
</div>
{/* 平均心率 */}
{average !== null && (
<div className: "average-heart-rate">
<span className: "label">平均心率</span>
<span className: "value">{Math.round(average)} BPM</span>
</div>
)}
{/* 心率区间说明 */}
<div className: "heart-rate-zones">
<h4>心率区间</h4>
<div className: "zone-list">
<div className: "zone-item">
<div className: "zone-color" style={{ backgroundColor: '#3498db' }}></div>
<span>休息 (<60 BPM)</span>
</div>
<div className: "zone-item">
<div className: "zone-color" style={{ backgroundColor: '#2ecc71' }}></div>
<span>燃脂 (60-70 BPM)</span>
</div>
<div className: "zone-item">
<div className: "zone-color" style={{ backgroundColor: '#f39c12' }}></div>
<span>有氧 (70-80 BPM)</span>
</div>
<div className: "zone-item">
<div className: "zone-color" style={{ backgroundColor: '#e67e22' }}></div>
<span>无氧 (80-90 BPM)</span>
</div>
<div className: "zone-item">
<div className: "zone-color" style={{ backgroundColor: '#e74c3c' }}></div>
<span>极限 (>90 BPM)</span>
</div>
</div>
</div>
</div>
);
}
Code collapsed
连接按钮组件
code
// components/ConnectionButton.tsx
import React from 'react';
interface ConnectionButtonProps {
connected: boolean;
onClick: () => void;
error: string | null;
}
export function ConnectionButton({ connected, onClick, error }: ConnectionButtonProps) {
return (
<button
className={`connection-button ${connected ? 'connected' : ''} ${error ? 'error' : ''}`}
onClick={onClick}
>
{connected ? (
<>
<span className: "icon">✓</span>
<span>断开连接</span>
</>
) : (
<>
<span className: "icon">🔗</span>
<span>连接设备</span>
</>
)}
</button>
);
}
Code collapsed
心率图表组件
code
// components/HeartRateChart.tsx
import React, { useEffect, useRef } from 'react';
import {
Chart as ChartJS,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Legend,
Tooltip,
Filler
} from 'chart.js/auto';
import { Line } from 'react-chartjs-2';
interface HeartRateChartProps {
data: Array<{ value: number; timestamp: number }>;
}
export function HeartRateChart({ data }: HeartRateChartProps) {
const chartData = {
labels: data.map((_, i) => i),
datasets: [
{
label: '心率 (BPM)',
data: data.map(d => d.value),
borderColor: '#e74c3c',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 2,
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0
},
scales: {
y: {
min: 40,
max: 180,
title: {
display: true,
text: '心率 (BPM)'
}
},
x: {
display: false
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: () => '心率',
label: (context) => `${context.parsed.y} BPM`
}
}
}
};
return (
<div className: "heart-rate-chart">
<Line data={chartData} options={options} height={200} />
</div>
);
}
Code collapsed
样式
code
/* App.css */
.App {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
header {
text-align: center;
margin-bottom: 30px;
}
header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.connection-status {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-bottom: 20px;
}
.connection-button {
padding: 15px 30px;
font-size: 1.1rem;
font-weight: bold;
border: none;
border-radius: 25px;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transition: transform 0.2s, box-shadow 0.2s;
}
.connection-button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.connection-button.connected {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.status-indicator {
padding: 5px 15px;
border-radius: 15px;
background: #e0e0e0;
font-size: 0.9rem;
}
.status-indicator.active {
background: #2ecc71;
color: white;
}
.heart-rate-display {
background: white;
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.current-heart-rate {
text-align: center;
margin-bottom: 20px;
}
.heart-rate-value {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 15px 0;
}
.heart-rate-value .value {
font-size: 4rem;
font-weight: bold;
}
.heart-rate-value .unit {
font-size: 1.5rem;
color: #666;
}
.zone-badge {
display: inline-block;
padding: 5px 15px;
border-radius: 15px;
color: white;
font-weight: bold;
font-size: 0.9rem;
}
.heart-rate-chart {
background: white;
border-radius: 15px;
padding: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
height: 250px;
}
.instructions {
background: #f8f9fa;
border-radius: 15px;
padding: 20px;
margin-top: 30px;
}
.instructions h2 {
color: #333;
margin-bottom: 15px;
}
.instructions ol {
padding-left: 20px;
}
.instructions li {
margin: 10px 0;
line-height: 1.6;
}
.browser-support {
margin-top: 20px;
padding: 15px;
background: #fff3cd;
border-radius: 10px;
}
.error-message {
background: #fee;
border: 1px solid #fcc;
border-radius: 10px;
padding: 15px;
color: #c00;
margin-bottom: 20px;
}
Code collapsed
PWA支持
Service Worker
code
// public/sw.js
const CACHE_NAME = 'heart-monitor-v1';
const urlsToCache = [
'/',
'/index.html',
'/static/js/bundle.js',
'/static/css/main.css'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
Code collapsed
注册Service Worker
code
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// 注册Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Code collapsed
部署
构建和部署
code
# 构建
npm run build
# 部署到Netlify/Vercel等静态托管
npm install -g netlify-cli
netlify deploy dist
Code collapsed
manifest.json
code
// public/manifest.json
{
"name": "心率监测器",
"short_name": "Heart Monitor",
"description": "使用Web Bluetooth API的心率监测应用",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#667eea",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Code collapsed
关键要点
- Web Bluetooth需要HTTPS:localhost除外
- 浏览器兼容性有限:Chrome/Edge最佳
- 用户手势触发连接:不能自动连接
- 心率数据解析有标准:Heart Rate Profile规范
- PWA支持离线使用:Service Worker缓存
常见问题
为什么连接失败?
可能原因:
- 浏览器不支持
- 未使用HTTPS
- 设备未开启
- 设备未配对
如何提高兼容性?
降级策略:
- 检测API支持
- 提供手动输入功能
- 导出数据为CSV
- 提示使用原生应用
数据持久化?
使用IndexedDB:
code
// 保存历史数据
const saveToIndexedDB = (data) => {
const request = indexedDB.open('HeartMonitorDB', 1);
// ... 保存逻辑
};
Code collapsed
参考资料
- Web Bluetooth API规范
- Bluetooth Heart Rate Profile
- Chart.js文档
- PWA最佳实践
发布日期:2026年3月8日 最后更新:2026年3月8日