关键要点
- 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
总结
通过本教程,你学会了如何构建一个完整的实时健康数据仪表板:
- 使用 WebSocket 建立实时数据连接
- 创建可复用的 D3.js 图表组件
- 使用 Zustand 管理全局状态
- 实现自动重连和错误处理
- 优化高频更新性能
扩展建议
- 添加数据导出功能(CSV、PDF)
- 实现多用户支持和权限控制
- 添加报警功能(心率异常等)
- 集成真实可穿戴设备 API
参考资料
常见问题
Q: 如何处理大量历史数据?
A: 实现数据分页或虚拟滚动,只渲染可见区域的数据点。对于图表,可以使用数据聚合(如按小时/天平均)。
Q: WebSocket 连接断开后如何保证数据不丢失?
A: 实现客户端本地缓存,在重连后同步数据。服务端可以维护每个客户端的数据队列。
Q: 如何支持移动端?
A: 使用响应式设计,图表大小使用相对单位。考虑使用 Canvas 代替 SVG 提高移动端性能。