康心伴Logo
康心伴WellAlly
Health

React Web蓝牙心率监测器:浏览器端健康应用开发 | WellAlly康心伴

5 分钟阅读

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>休息 (&lt;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>极限 (&gt;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

关键要点

  1. Web Bluetooth需要HTTPS:localhost除外
  2. 浏览器兼容性有限:Chrome/Edge最佳
  3. 用户手势触发连接:不能自动连接
  4. 心率数据解析有标准:Heart Rate Profile规范
  5. PWA支持离线使用:Service Worker缓存

常见问题

为什么连接失败?

可能原因:

  1. 浏览器不支持
  2. 未使用HTTPS
  3. 设备未开启
  4. 设备未配对

如何提高兼容性?

降级策略

  1. 检测API支持
  2. 提供手动输入功能
  3. 导出数据为CSV
  4. 提示使用原生应用

数据持久化?

使用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日

免责声明: 本内容仅供教育参考,不能替代专业医疗建议。请咨询医生获取个性化诊断和治疗方案。

#

文章标签

React
Web Bluetooth
心率监测
健康应用
PWA

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅