康心伴Logo
康心伴WellAlly
数据可视化

构建实时健康数据仪表板:React、WebSocket 和 D3.js

学习如何使用 React、WebSocket 和 D3.js 构建实时健康数据可视化仪表板。包含心率监测、步数追踪、睡眠分析等组件,支持多数据源并发更新和性能优化。

W
WellAlly 开发团队
2026-03-08
22 分钟阅读

关键要点

  • WebSocket 实现实时数据推送:使用原生 WebSocket 或 Socket.IO 建立持久连接,接收来自可穿戴设备的实时健康数据
  • D3.js 灵活可视化:利用 D3.js 强大的数据绑定和 DOM 操作能力,创建自定义图表组件
  • React 状态管理优化:使用 useReducer 和 useMemo 管理高频更新数据,避免不必要的重渲染
  • 性能优化策略:虚拟化长列表、节流更新、Canvas 渲染大量数据点
  • 错误处理与重连机制:处理网络中断、服务器重启等场景,自动重连并恢复数据流

实时健康数据监控在远程医疗、慢性病管理和运动健康领域有着广泛应用。本教程将教你如何构建一个专业的实时健康数据仪表板。

前置条件:

  • React 18+ 和 TypeScript 基础
  • D3.js v7 基础知识
  • WebSocket 概念了解
  • Node.js 后端(可选,用于演示数据推送)

项目架构

code
┌─────────────────────────────────────────────────┐
│              React 前端仪表板                    │
│  ┌─────────────────────────────────────────┐   │
│  │        WebSocket 连接管理器             │   │
│  └─────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────┐   │
│  │           数据缓冲与聚合                 │   │
│  └─────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────┐   │
│  │           D3.js 图表组件                 │   │
│  │  - 心率实时图  - 步数进度条              │   │
│  │  - 睡眠热力图  - 活动趋势图              │   │
│  └─────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘
                    │ WebSocket
                    ▼
┌─────────────────────────────────────────────────┐
│           Node.js WebSocket 服务器              │
│  (接收可穿戴设备数据,广播给客户端)              │
└─────────────────────────────────────────────────┘
Code collapsed

步骤 1:创建项目

code
npx create-react-app health-dashboard --template typescript
cd health-dashboard
npm install d3 @types/d3 socket.io-client zustand
npm install recharts lucide-react clsx tailwind-merge
npm install -D @types/node
Code collapsed

配置 Tailwind CSS(可选):

code
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Code collapsed

步骤 2:实现 WebSocket 连接管理

创建 hooks/useWebSocket.ts

code
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';

interface WebSocketMessage {
  type: 'heart_rate' | 'steps' | 'sleep' | 'activity' | 'error';
  timestamp: number;
  data: any;
}

interface UseWebSocketOptions {
  url: string;
  reconnectInterval?: number;
  maxReconnectAttempts?: number;
  onMessage?: (message: WebSocketMessage) => void;
  onConnect?: () => void;
  onDisconnect?: () => void;
  onError?: (error: Event) => void;
}

interface WebSocketState {
  isConnected: boolean;
  isReconnecting: boolean;
  reconnectAttempt: number;
  lastMessage: WebSocketMessage | null;
}

export function useWebSocket(options: UseWebSocketOptions) {
  const wsRef = useRef<WebSocket | null>(null);
  const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
  const [state, setState] = useState<WebSocketState>({
    isConnected: false,
    isReconnecting: false,
    reconnectAttempt: 0,
    lastMessage: null,
  });

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      return;
    }

    try {
      const ws = new WebSocket(options.url);
      wsRef.current = ws;

      ws.onopen = () => {
        console.log('WebSocket connected');
        setState(prev => ({
          ...prev,
          isConnected: true,
          isReconnecting: false,
          reconnectAttempt: 0,
        }));
        options.onConnect?.();
      };

      ws.onmessage = (event) => {
        try {
          const message: WebSocketMessage = JSON.parse(event.data);
          setState(prev => ({ ...prev, lastMessage: message }));
          options.onMessage?.(message);
        } catch (error) {
          console.error('Failed to parse WebSocket message:', error);
        }
      };

      ws.onclose = (event) => {
        console.log('WebSocket disconnected:', event.code, event.reason);
        setState(prev => ({
          ...prev,
          isConnected: false,
        }));
        options.onDisconnect?.();

        // 尝试重连
        if (!event.wasClean && state.reconnectAttempt < (options.maxReconnectAttempts || 5)) {
          setState(prev => ({
            ...prev,
            isReconnecting: true,
            reconnectAttempt: prev.reconnectAttempt + 1,
          }));

          reconnectTimeoutRef.current = setTimeout(() => {
            connect();
          }, options.reconnectInterval || 3000);
        }
      };

      ws.onerror = (error) => {
        console.error('WebSocket error:', error);
        options.onError?.(error);
      };

    } catch (error) {
      console.error('Failed to create WebSocket connection:', error);
    }
  }, [options.url]);

  const disconnect = useCallback(() => {
    if (reconnectTimeoutRef.current) {
      clearTimeout(reconnectTimeoutRef.current);
    }
    if (wsRef.current) {
      wsRef.current.close();
      wsRef.current = null;
    }
    setState({
      isConnected: false,
      isReconnecting: false,
      reconnectAttempt: 0,
      lastMessage: null,
    });
  }, []);

  const send = useCallback((data: any) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    } else {
      console.warn('Cannot send message: WebSocket is not connected');
    }
  }, []);

  useEffect(() => {
    connect();

    return () => {
      disconnect();
    };
  }, [connect, disconnect]);

  return {
    ...state,
    send,
    connect,
    disconnect,
  };
}
Code collapsed

步骤 3:创建健康数据状态管理

使用 Zustand 创建全局状态:

code
// store/healthDataStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

export interface HeartRateDataPoint {
  timestamp: number;
  value: number;
}

export interface StepsDataPoint {
  timestamp: number;
  value: number;
}

export interface SleepStage {
  stage: 'awake' | 'rem' | 'light' | 'deep';
  start: number;
  end: number;
}

interface HealthDataState {
  // 心率数据(保留最近300个点,约5分钟)
  heartRate: HeartRateDataPoint[];
  // 今日步数
  steps: StepsDataPoint[];
  totalSteps: number;
  // 睡眠数据
  lastNightSleep: SleepStage[];
  // 连接状态
  isLive: boolean;
  lastUpdate: number;

  // Actions
  addHeartRatePoint: (value: number) => void;
  setSteps: (steps: number) => void;
  incrementSteps: (amount: number) => void;
  setSleepData: (stages: SleepStage[]) => void;
  setLiveStatus: (isLive: boolean) => void;
  clearData: () => void;
}

export const useHealthDataStore = create<HealthDataState>()(
  subscribeWithSelector((set, get) => ({
    heartRate: [],
    steps: [],
    totalSteps: 0,
    lastNightSleep: [],
    isLive: false,
    lastUpdate: 0,

    addHeartRatePoint: (value: number) => {
      const timestamp = Date.now();
      set((state) => {
        const newPoints = [...state.heartRate, { timestamp, value }];
        // 只保留最近的300个点
        const trimmedPoints = newPoints.slice(-300);
        return {
          heartRate: trimmedPoints,
          lastUpdate: timestamp,
        };
      });
    },

    setSteps: (steps: number) => {
      const timestamp = Date.now();
      set((state) => ({
        totalSteps: steps,
        steps: [...state.steps, { timestamp, value: steps }],
        lastUpdate: timestamp,
      }));
    },

    incrementSteps: (amount: number) => {
      const timestamp = Date.now();
      set((state) => ({
        totalSteps: state.totalSteps + amount,
        steps: [...state.steps, { timestamp, value: state.totalSteps + amount }],
        lastUpdate: timestamp,
      }));
    },

    setSleepData: (stages: SleepStage[]) => {
      set({ lastNightSleep: stages });
    },

    setLiveStatus: (isLive: boolean) => {
      set({ isLive });
    },

    clearData: () => {
      set({
        heartRate: [],
        steps: [],
        totalSteps: 0,
        lastNightSleep: [],
        isLive: false,
        lastUpdate: 0,
      });
    },
  }))
);
Code collapsed

步骤 4:创建 D3.js 心率实时图表组件

code
// components/HeartRateChart.tsx
import React, { useEffect, useRef, useMemo } from 'react';
import * as d3 from 'd3';
import { useHealthDataStore } from '@/store/healthDataStore';

interface HeartRateChartProps {
  width?: number;
  height?: number;
  showGrid?: boolean;
  color?: string;
}

export const HeartRateChart: React.FC<HeartRateChartProps> = ({
  width = 600,
  height = 200,
  showGrid = true,
  color = '#ef4444',
}) => {
  const svgRef = useRef<SVGSVGElement>(null);
  const heartRate = useHealthDataStore((state) => state.heartRate);

  // 创建比例尺
  const scales = useMemo(() => {
    const xScale = d3.scaleTime()
      .domain([
        Date.now() - 5 * 60 * 1000, // 5分钟前
        Date.now(),
      ])
      .range([0, width]);

    const yScale = d3.scaleLinear()
      .domain([40, 180]) // 心率范围
      .range([height, 0])
      .nice();

    return { xScale, yScale };
  }, [width, height]);

  // 更新图表
  useEffect(() => {
    if (!svgRef.current || heartRate.length === 0) return;

    const svg = d3.select(svgRef.current);
    const { xScale, yScale } = scales;

    // 清除旧内容
    svg.selectAll('*').remove();

    const g = svg.append('g');

    // 绘制网格线
    if (showGrid) {
      const xAxisGrid = d3.axisTop(xScale)
        .ticks(width / 80)
        .tickSize(-height)
        .tickFormat(() => '');

      const yAxisGrid = d3.axisRight(yScale)
        .ticks(height / 40)
        .tickSize(-width)
        .tickFormat(() => '');

      g.append('g')
        .attr('class', 'x-grid')
        .attr('transform', `translate(0,${height})`)
        .call(xAxisGrid)
        .attr('stroke', '#e5e7eb')
        .attr('stroke-dasharray', '2,2');

      g.append('g')
        .attr('class', 'y-grid')
        .attr('transform', `translate(${width},0)`)
        .call(yAxisGrid)
        .attr('stroke', '#e5e7eb')
        .attr('stroke-dasharray', '2,2');
    }

    // 创建线生成器
    const line = d3.line<HeartRateDataPoint>()
      .x((d) => xScale(new Date(d.timestamp)))
      .y((d) => yScale(d.value))
      .curve(d3.curveMonotoneX);

    // 绘制面积
    const area = d3.area<HeartRateDataPoint>()
      .x((d) => xScale(new Date(d.timestamp)))
      .y0(height)
      .y1((d) => yScale(d.value))
      .curve(d3.curveMonotoneX);

    // 添加渐变
    const defs = svg.append('defs');
    const gradient = defs.append('linearGradient')
      .attr('id', 'heartRateGradient')
      .attr('x1', '0%')
      .attr('y1', '0%')
      .attr('x2', '0%')
      .attr('y2', '100%');

    gradient.append('stop')
      .attr('offset', '0%')
      .attr('stop-color', color)
      .attr('stop-opacity', 0.3);

    gradient.append('stop')
      .attr('offset', '100%')
      .attr('stop-color', color)
      .attr('stop-opacity', 0);

    // 绘制面积
    g.append('path')
      .datum(heartRate)
      .attr('fill', 'url(#heartRateGradient)')
      .attr('d', area);

    // 绘制线条
    g.append('path')
      .datum(heartRate)
      .attr('fill', 'none')
      .attr('stroke', color)
      .attr('stroke-width', 2)
      .attr('d', line);

    // 添加当前值指示器
    const lastPoint = heartRate[heartRate.length - 1];
    if (lastPoint) {
      const cx = xScale(new Date(lastPoint.timestamp));
      const cy = yScale(lastPoint.value);

      g.append('circle')
        .attr('cx', cx)
        .attr('cy', cy)
        .attr('r', 6)
        .attr('fill', color)
        .attr('stroke', 'white')
        .attr('stroke-width', 2);

      // 添加数值标签
      g.append('text')
        .attr('x', cx)
        .attr('y', cy - 15)
        .attr('text-anchor', 'middle')
        .attr('font-size', '14px')
        .attr('font-weight', 'bold')
        .attr('fill', color)
        .text(`${lastPoint.value} BPM`);
    }

    // 添加 Y 轴标签
    g.append('text')
      .attr('x', -10)
      .attr('y', 15)
      .attr('text-anchor', 'end')
      .attr('font-size', '12px')
      .attr('fill', '#6b7280')
      .text('BPM');

  }, [heartRate, scales, width, height, showGrid, color]);

  return (
    <div className: "relative">
      <svg
        ref={svgRef}
        width={width}
        height={height}
        className: "overflow-visible"
      />
      <div className: "absolute top-2 right-2 flex items-center gap-2">
        <div className={`w-2 h-2 rounded-full ${heartRate.length > 0 ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
        <span className: "text-xs text-gray-500">
          {heartRate.length > 0 ? '实时' : '离线'}
        </span>
      </div>
    </div>
  );
};
Code collapsed

步骤 5:创建步数环形进度组件

code
// components/StepsRingChart.tsx
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { useHealthDataStore } from '@/store/healthDataStore';

interface StepsRingChartProps {
  size?: number;
  strokeWidth?: number;
  goal?: number;
}

export const StepsRingChart: React.FC<StepsRingChartProps> = ({
  size = 200,
  strokeWidth = 12,
  goal = 10000,
}) => {
  const svgRef = useRef<SVGSVGElement>(null);
  const totalSteps = useHealthDataStore((state) => state.totalSteps);

  useEffect(() => {
    if (!svgRef.current) return;

    const svg = d3.select(svgRef.current);
    const radius = (size - strokeWidth) / 2;
    const center = size / 2;

    // 清除旧内容
    svg.selectAll('*').remove();

    const g = svg.append('g').attr('transform', `translate(${center},${center})`);

    // 计算进度
    const percentage = Math.min(totalSteps / goal, 1);
    const startAngle = -Math.PI / 2;
    const endAngle = startAngle + percentage * 2 * Math.PI;

    // 绘制背景圆环
    const backgroundArc = d3.arc()
      .innerRadius(radius - strokeWidth / 2)
      .outerRadius(radius + strokeWidth / 2)
      .startAngle(0)
      .endAngle(2 * Math.PI);

    g.append('path')
      .datum({ startAngle: 0, endAngle: 2 * Math.PI })
      .style('fill', '#e5e7eb')
      .attr('d', backgroundArc as any);

    // 绘制进度圆环
    if (percentage > 0) {
      const progressArc = d3.arc()
        .innerRadius(radius - strokeWidth / 2)
        .outerRadius(radius + strokeWidth / 2)
        .startAngle(startAngle)
        .endAngle(endAngle)
        .cornerRadius(strokeWidth / 2);

      // 根据进度选择颜色
      const color = percentage >= 1 ? '#22c55e' : percentage >= 0.7 ? '#3b82f6' : '#f59e0b';

      g.append('path')
        .datum({ startAngle, endAngle })
        .style('fill', color)
        .attr('d', progressArc as any);
    }

    // 添加中心文字
    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '-0.5em')
      .attr('font-size', '32px')
      .attr('font-weight', 'bold')
      .attr('fill', '#1f2937')
      .text(totalSteps.toLocaleString());

    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '1.5em')
      .attr('font-size', '14px')
      .attr('fill', '#6b7280')
      .text('步');

    g.append('text')
      .attr('text-anchor', 'middle')
      .attr('dy', '3em')
      .attr('font-size', '12px')
      .attr('fill', '#9ca3af')
      .text(`目标: ${goal.toLocaleString()}`);

  }, [totalSteps, goal, size, strokeWidth]);

  return <svg ref={svgRef} width={size} height={size} />;
};
Code collapsed

步骤 6:创建睡眠热力图组件

code
// components/SleepHeatmap.tsx
import React, { useMemo } from 'react';
import * as d3 from 'd3';
import { useHealthDataStore } from '@/store/healthDataStore';

const STAGE_COLORS: Record<string, string> = {
  awake: '#fbbf24',
  rem: '#8b5cf6',
  light: '#60a5fa',
  deep: '#1e40af',
};

const STAGE_LABELS: Record<string, string> = {
  awake: '清醒',
  rem: 'REM',
  light: '浅睡',
  deep: '深睡',
};

interface SleepHeatmapProps {
  width?: number;
  height?: number;
}

export const SleepHeatmap: React.FC<SleepHeatmapProps> = ({
  width = 800,
  height = 120,
}) => {
  const lastNightSleep = useHealthDataStore((state) => state.lastNightSleep);

  const sleepData = useMemo(() => {
    if (lastNightSleep.length === 0) return [];

    // 计算总睡眠时长和各阶段时长
    const stageDurations = new Map<string, number>();

    lastNightSleep.forEach((stage) => {
      const duration = stage.end - stage.start;
      stageDurations.set(
        stage.stage,
        (stageDurations.get(stage.stage) || 0) + duration
      );
    });

    return Array.from(stageDurations.entries()).map(([stage, duration]) => ({
      stage,
      duration: Math.round(duration / 60000), // 转换为分钟
      color: STAGE_COLORS[stage],
      label: STAGE_LABELS[stage],
    }));
  }, [lastNightSleep]);

  const totalSleepMinutes = useMemo(() => {
    return sleepData.reduce((sum, d) => sum + d.duration, 0);
  }, [sleepData]);

  if (lastNightSleep.length === 0) {
    return (
      <div className: "flex items-center justify-center h-32 bg-gray-50 rounded-lg">
        <p className: "text-gray-500">暂无睡眠数据</p>
      </div>
    );
  }

  return (
    <div className: "space-y-4">
      {/* 睡眠时间轴 */}
      <div className: "relative">
        <svg width={width} height={height}>
          {/* 绘制睡眠阶段条 */}
          {lastNightSleep.map((stage, index) => {
            const x = ((stage.start - lastNightSleep[0].start) /
              (lastNightSleep[lastNightSleep.length - 1].end - lastNightSleep[0].start)) * width;
            const width = ((stage.end - stage.start) /
              (lastNightSleep[lastNightSleep.length - 1].end - lastNightSleep[0].start)) * width;

            return (
              <rect
                key={index}
                x={x}
                y={0}
                width={width}
                height={height}
                fill={STAGE_COLORS[stage.stage]}
                opacity={0.8}
              />
            );
          })}
        </svg>

        {/* 时间标签 */}
        <div className: "flex justify-between text-xs text-gray-500 mt-1">
          <span>
            {new Date(lastNightSleep[0].start).toLocaleTimeString('zh-CN', {
              hour: '2-digit',
              minute: '2-digit',
            })}
          </span>
          <span>
            {new Date(lastNightSleep[lastNightSleep.length - 1].end).toLocaleTimeString('zh-CN', {
              hour: '2-digit',
              minute: '2-digit',
            })}
          </span>
        </div>
      </div>

      {/* 睡眠阶段统计 */}
      <div className: "grid grid-cols-4 gap-4">
        {sleepData.map((item) => (
          <div key={item.stage} className: "bg-gray-50 rounded-lg p-4">
            <div className: "flex items-center gap-2 mb-2">
              <div
                className: "w-3 h-3 rounded-full"
                style={{ backgroundColor: item.color }}
              />
              <span className: "text-sm font-medium">{item.label}</span>
            </div>
            <p className: "text-2xl font-bold text-gray-900">
              {Math.floor(item.duration / 60)}h {item.duration % 60}m
            </p>
          </div>
        ))}
      </div>

      {/* 总睡眠时长 */}
      <div className: "bg-blue-50 rounded-lg p-4 flex items-center justify-between">
        <div>
          <p className: "text-sm text-blue-600">总睡眠时长</p>
          <p className: "text-3xl font-bold text-blue-900">
            {Math.floor(totalSleepMinutes / 60)}h {totalSleepMinutes % 60}m
          </p>
        </div>
        <div className: "text-right">
          <p className: "text-sm text-blue-600">睡眠效率</p>
          <p className: "text-3xl font-bold text-blue-900">
            {Math.round((totalSleepMinutes / (8 * 60)) * 100)}%
          </p>
        </div>
      </div>
    </div>
  );
};
Code collapsed

步骤 7:创建主仪表板组件

code
// components/HealthDashboard.tsx
import React, { useEffect } from 'react';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useHealthDataStore } from '@/store/healthDataStore';
import { HeartRateChart } from './HeartRateChart';
import { StepsRingChart } from './StepsRingChart';
import { SleepHeatmap } from './SleepHeatmap';
import { Activity, Heart, Moon, Footprints } from 'lucide-react';

export const HealthDashboard: React.FC = () => {
  const { isConnected, isReconnecting, lastMessage } = useWebSocket({
    url: process.env.REACT_APP_WS_URL || 'ws://localhost:8080',
    onMessage: (message) => {
      const store = useHealthDataStore.getState();

      switch (message.type) {
        case 'heart_rate':
          store.addHeartRatePoint(message.data.value);
          break;
        case 'steps':
          store.setSteps(message.data.total);
          break;
        case 'sleep':
          store.setSleepData(message.data.stages);
          break;
        case 'activity':
          store.incrementSteps(message.data.steps);
          break;
      }
    },
    onConnect: () => {
      useHealthDataStore.getState().setLiveStatus(true);
    },
    onDisconnect: () => {
      useHealthDataStore.getState().setLiveStatus(false);
    },
  });

  const totalSteps = useHealthDataStore((state) => state.totalSteps);
  const isLive = useHealthDataStore((state) => state.isLive);

  return (
    <div className: "min-h-screen bg-gray-50 p-6">
      {/* 头部 */}
      <header className: "mb-8">
        <div className: "flex items-center justify-between">
          <div>
            <h1 className: "text-3xl font-bold text-gray-900">健康数据仪表板</h1>
            <p className: "text-gray-500 mt-1">实时监测您的健康指标</p>
          </div>
          <div className: "flex items-center gap-3">
            <div className={`flex items-center gap-2 px-4 py-2 rounded-full ${
              isConnected ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
            }`}>
              <div className={`w-2 h-2 rounded-full ${isConnected && isLive ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
              <span className: "text-sm font-medium">
                {isReconnecting ? '重连中...' : isConnected ? '已连接' : '离线'}
              </span>
            </div>
          </div>
        </div>
      </header>

      {/* 卡片网格 */}
      <div className: "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
        {/* 步数卡片 */}
        <div className: "bg-white rounded-xl shadow-sm p-6">
          <div className: "flex items-center justify-between mb-4">
            <h3 className: "text-lg font-semibold text-gray-900">今日步数</h3>
            <Footprints className: "w-5 h-5 text-blue-500" />
          </div>
          <div className: "flex justify-center">
            <StepsRingChart size={180} goal={10000} />
          </div>
        </div>

        {/* 心率卡片 */}
        <div className: "bg-white rounded-xl shadow-sm p-6 lg:col-span-2">
          <div className: "flex items-center justify-between mb-4">
            <h3 className: "text-lg font-semibold text-gray-900">实时心率</h3>
            <Heart className: "w-5 h-5 text-red-500" />
          </div>
          <HeartRateChart width={600} height={200} />
        </div>
      </div>

      {/* 睡眠分析 */}
      <div className: "bg-white rounded-xl shadow-sm p-6 mb-8">
        <div className: "flex items-center justify-between mb-6">
          <div className: "flex items-center gap-2">
            <Moon className: "w-5 h-5 text-purple-500" />
            <h3 className: "text-lg font-semibold text-gray-900">昨晚睡眠分析</h3>
          </div>
        </div>
        <SleepHeatmap width={800} height={120} />
      </div>

      {/* 活动趋势 */}
      <div className: "bg-white rounded-xl shadow-sm p-6">
        <div className: "flex items-center justify-between mb-6">
          <div className: "flex items-center gap-2">
            <Activity className: "w-5 h-5 text-green-500" />
            <h3 className: "text-lg font-semibold text-gray-900">本周活动趋势</h3>
          </div>
        </div>
        {/* 这里可以添加活动趋势图表 */}
        <p className: "text-gray-500 text-center py-8">活动趋势图表即将推出</p>
      </div>
    </div>
  );
};
Code collapsed

步骤 8:创建简单的 WebSocket 服务器(用于测试)

code
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// 模拟健康数据生成器
function generateHeartRate() {
  return Math.floor(Math.random() * (100 - 60) + 60);
}

function generateSteps() {
  return Math.floor(Math.random() * 50);
}

// 存储连接的客户端
const clients = new Set();

wss.on('connection', (ws) => {
  console.log('New client connected');
  clients.add(ws);

  // 发送初始数据
  ws.send(JSON.stringify({
    type: 'steps',
    timestamp: Date.now(),
    data: { total: Math.floor(Math.random() * 5000) },
  }));

  // 定期发送心率数据
  const heartRateInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({
        type: 'heart_rate',
        timestamp: Date.now(),
        data: { value: generateHeartRate() },
      }));
    }
  }, 1000);

  // 每5秒更新步数
  const stepsInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({
        type: 'activity',
        timestamp: Date.now(),
        data: { steps: generateSteps() },
      }));
    }
  }, 5000);

  ws.on('close', () => {
    console.log('Client disconnected');
    clients.delete(ws);
    clearInterval(heartRateInterval);
    clearInterval(stepsInterval);
  });

  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
  });
});

console.log('WebSocket server running on ws://localhost:8080');
Code collapsed

性能优化策略

1. 使用 requestAnimationFrame 节流更新

code
// hooks/useThrottledUpdate.ts
import { useRef, useCallback } from 'react';

export function useThrottledUpdate<T>(
  updateFn: (data: T) => void,
  delay: number = 100
) {
  const lastUpdateRef = useRef<number>(0);
  const pendingDataRef = useRef<T | null>(null);
  const rafRef = useRef<number>();

  return useCallback((data: T) => {
    pendingDataRef.current = data;

    if (rafRef.current !== undefined) {
      return;
    }

    rafRef.current = requestAnimationFrame(() => {
      const now = Date.now();
      const timeSinceLastUpdate = now - lastUpdateRef.current;

      if (timeSinceLastUpdate >= delay) {
        if (pendingDataRef.current !== null) {
          updateFn(pendingDataRef.current);
          pendingDataRef.current = null;
          lastUpdateRef.current = now;
        }
      }

      rafRef.current = undefined;
    });
  }, [updateFn, delay]);
}
Code collapsed

2. 虚拟化长列表渲染

使用 react-window 处理大量历史数据:

code
import { FixedSizeList } from 'react-window';

const HistoryList = ({ items }) => (
  <FixedSizeList
    height={400}
    itemCount={items.length}
    itemSize={50}
    width: "100%"
  >
    {({ index, style }) => (
      <div style={style}>
        {/* 渲染单个历史记录项 */}
      </div>
    )}
  </FixedSizeList>
);
Code collapsed

总结

通过本教程,你学会了如何构建一个完整的实时健康数据仪表板:

  1. 使用 WebSocket 建立实时数据连接
  2. 创建可复用的 D3.js 图表组件
  3. 使用 Zustand 管理全局状态
  4. 实现自动重连和错误处理
  5. 优化高频更新性能

扩展建议

  • 添加数据导出功能(CSV、PDF)
  • 实现多用户支持和权限控制
  • 添加报警功能(心率异常等)
  • 集成真实可穿戴设备 API

参考资料

常见问题

Q: 如何处理大量历史数据?

A: 实现数据分页或虚拟滚动,只渲染可见区域的数据点。对于图表,可以使用数据聚合(如按小时/天平均)。

Q: WebSocket 连接断开后如何保证数据不丢失?

A: 实现客户端本地缓存,在重连后同步数据。服务端可以维护每个客户端的数据队列。

Q: 如何支持移动端?

A: 使用响应式设计,图表大小使用相对单位。考虑使用 Canvas 代替 SVG 提高移动端性能。

相关文章

#

文章标签

react
websockets
d3
实时数据
可视化

觉得这篇文章有帮助?

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