构建高性能 React D3 健康数据图表
概述
在健康科技应用中,数据可视化是帮助用户理解健康指标的关键工具。D3.js 提供了强大的数据绑定和 DOM 操作能力,而 React 则提供了声明式的组件模型。本文将介绍如何将两者结合,构建高性能的健康数据图表。
为什么选择 D3 + React
D3.js 的优势
D3.js (Data-Driven Documents) 是一个强大的 JavaScript 可视化库:
- 灵活的 API:可以创建任何类型的可视化
- 强大的数据处理:内置 scale、layout、shape 等工具
- 精细的控制:可以精确控制每个 DOM 元素
React 的优势
React 提供了声明式的组件模型:
- 声明式渲染:代码更易理解和维护
- 虚拟 DOM:高效更新 DOM
- 组件化:可复用的图表组件
集成策略
策略 1:React 控制 D3(推荐)
React 负责 DOM 的创建和更新,D3 负责计算和数据处理:
code
import React, { useEffect, useRef } from 'react';
import { scaleTime, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { line, curveMonotoneX } from 'd3-shape';
import { select } from 'd3-selection';
interface HealthChartProps {
data: Array<{ date: Date; value: number }>;
width: number;
height: number;
}
export const HealthChart: React.FC<HealthChartProps> = ({ data, width, height }) => {
const svgRef = useRef<SVGSVGElement>(null);
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
useEffect(() => {
if (!svgRef.current || !data.length) return;
const svg = select(svgRef.current);
// 清除之前的内容
svg.selectAll('*').remove();
// 创建比例尺
const xScale = scaleTime()
.domain([data[0].date, data[data.length - 1].date])
.range([0, chartWidth]);
const yScale = scaleLinear()
.domain([0, Math.max(...data.map(d => d.value))])
.range([chartHeight, 0]);
// 创建主容器
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// 添加 X 轴
g.append('g')
.attr('transform', `translate(0,${chartHeight})`)
.call(axisBottom(xScale))
.selectAll('text')
.style('font-size', '12px');
// 添加 Y 轴
g.append('g')
.call(axisLeft(yScale))
.selectAll('text')
.style('font-size', '12px');
// 创建线生成器
const lineGenerator = line<{ date: Date; value: number }>()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(curveMonotoneX);
// 添加路径
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 2)
.attr('d', lineGenerator);
// 添加数据点(带性能优化)
const dots = g.selectAll('.dot')
.data(data)
.enter()
.append('circle')
.attr('class', 'dot')
.attr('cx', d => xScale(d.date))
.attr('cy', d => yScale(d.value))
.attr('r', 4)
.attr('fill', '#3b82f6')
.attr('opacity', 0);
// 动画显示数据点
dots.transition()
.duration(500)
.delay((d, i) => i * 10)
.attr('opacity', 1);
}, [data, chartWidth, chartHeight]);
return (
<svg
ref={svgRef}
width={width}
height={height}
className="w-full h-auto"
role="img"
aria-label="健康数据趋势图"
/>
);
};
Code collapsed
策略 2:使用 useMemo 和 useCallback 优化
对于复杂图表,使用 React 的优化 hooks:
code
import React, { useMemo, useCallback, useRef, useEffect } from 'react';
import { scaleTime, scaleLinear } from 'd3-scale';
import { extent, max } from 'd3-array';
interface OptimizedHealthChartProps {
data: Array<{ date: Date; heartRate: number; steps: number }>;
width: number;
height: number;
}
export const OptimizedHealthChart: React.FC<OptimizedHealthChartProps> = ({
data,
width,
height
}) => {
const svgRef = useRef<SVGSVGElement>(null);
// 缓存比例尺计算
const scales = useMemo(() => {
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
const xScale = scaleTime()
.domain(extent(data, d => d.date) as [Date, Date])
.range([0, chartWidth]);
const yScale = scaleLinear()
.domain([0, max(data, d => d.heartRate) || 100])
.range([chartHeight, 0]);
return { xScale, yScale, margin, chartWidth, chartHeight };
}, [data, width, height]);
// 缓存数据转换
const processedData = useMemo(() => {
return data.map(d => ({
...d,
x: scales.xScale(d.date),
y: scales.yScale(d.heartRate)
}));
}, [data, scales]);
// 稳定的渲染函数
const renderChart = useCallback(() => {
if (!svgRef.current) return;
// 使用 requestAnimationFrame 优化渲染
requestAnimationFrame(() => {
// 渲染逻辑...
});
}, [processedData, scales]);
useEffect(() => {
renderChart();
}, [renderChart]);
return (
<svg
ref={svgRef}
width={width}
height={height}
className="w-full h-auto"
/>
);
};
Code collapsed
性能优化技巧
1. 虚拟化大数据集
对于包含数万个数据点的图表,使用数据抽样或虚拟滚动:
code
import { useMemo } from 'react';
import { timeDay } from 'd3-time';
// 数据抽样:按天聚合数据
const aggregateDataByDay = (data: Array<{ date: Date; value: number }>) => {
return useMemo(() => {
const grouped = new Map<string, number[]>();
data.forEach(d => {
const key = timeDay.floor(d.date).toISOString();
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)!.push(d.value);
});
return Array.from(grouped.entries()).map(([date, values]) => ({
date: new Date(date),
value: values.reduce((a, b) => a + b, 0) / values.length,
count: values.length
}));
}, [data]);
};
Code collapsed
2. 使用 Canvas 替代 SVG
对于 10,000+ 数据点,Canvas 性能更优:
code
import React, { useRef, useEffect } from 'react';
interface CanvasHealthChartProps {
data: Array<{ x: number; y: number }>;
width: number;
height: number;
}
export const CanvasHealthChart: React.FC<CanvasHealthChartProps> = ({
data,
width,
height
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 清除画布
ctx.clearRect(0, 0, width, height);
// 批量绘制数据点
ctx.fillStyle = '#3b82f6';
data.forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, 2, 0, Math.PI * 2);
ctx.fill();
});
}, [data, width, height]);
return <canvas ref={canvasRef} width={width} height={height} />;
};
Code collapsed
3. 防抖更新
对于频繁更新的实时数据:
code
import { useState, useEffect } from 'react';
import { debounce } from 'lodash';
export const useDebouncedData = <T,>(data: T, delay: number = 300) => {
const [debouncedData, setDebouncedData] = useState(data);
useEffect(() => {
const handler = debounce(() => {
setDebouncedData(data);
}, delay);
handler();
return () => handler.cancel();
}, [data, delay]);
return debouncedData;
};
Code collapsed
完整的健康仪表板示例
code
import React, { useState, useEffect } from 'react';
import { HealthChart } from './HealthChart';
import { CanvasHealthChart } from './CanvasHealthChart';
interface HealthMetric {
timestamp: Date;
heartRate: number;
bloodPressure: { systolic: number; diastolic: number };
steps: number;
sleepHours: number;
}
export const HealthDashboard: React.FC = () => {
const [metrics, setMetrics] = useState<HealthMetric[]>([]);
const [selectedMetric, setSelectedMetric] = useState<'heartRate' | 'steps'>('heartRate');
useEffect(() => {
// 模拟从 API 获取数据
const fetchMetrics = async () => {
const response = await fetch('/api/health-metrics');
const data = await response.json();
setMetrics(data);
};
fetchMetrics();
// 设置实时更新
const interval = setInterval(fetchMetrics, 30000); // 每30秒更新
return () => clearInterval(interval);
}, []);
// 根据数据量选择渲染方式
const useCanvas = metrics.length > 5000;
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">健康数据仪表板</h1>
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value as any)}
className="px-4 py-2 border rounded-lg"
>
<option value="heartRate">心率</option>
<option value="steps">步数</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">趋势图</h2>
{useCanvas ? (
<CanvasHealthChart
data={metrics.map(m => ({
x: m.timestamp.getTime(),
y: m[selectedMetric] as number
}))}
width={500}
height={300}
/>
) : (
<HealthChart
data={metrics.map(m => ({
date: m.timestamp,
value: m[selectedMetric] as number
}))}
width={500}
height={300}
/>
)}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">统计摘要</h2>
<StatisticsSummary data={metrics} metric={selectedMetric} />
</div>
</div>
</div>
);
};
const StatisticsSummary: React.FC<{
data: HealthMetric[];
metric: 'heartRate' | 'steps';
}> = ({ data, metric }) => {
const stats = useMemo(() => {
const values = data.map(d => d[metric]);
return {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values)
};
}, [data, metric]);
return (
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">平均值</span>
<span className="font-semibold">{stats.avg.toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">最小值</span>
<span className="font-semibold">{stats.min}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">最大值</span>
<span className="font-semibold">{stats.max}</span>
</div>
</div>
);
};
Code collapsed
可访问性考虑
1. 添加语义化标记
code
<svg
role="img"
aria-labelledby="chart-title chart-desc"
aria-describedby="chart-desc"
>
<title id="chart-title">心率趋势图</title>
<desc id="chart-desc">
显示过去7天心率变化的折线图,
平均心率 75 bpm,范围 60-100 bpm
</desc>
{/* 图表内容 */}
</svg>
Code collapsed
2. 提供数据表格作为替代
code
<div role="region" aria-labelledby="chart-heading" tabIndex={0}>
<h2 id="chart-heading">心率数据</h2>
<HealthChart data={data} />
<details>
<summary>查看数据表格</summary>
<table>
<thead>
<tr>
<th>时间</th>
<th>心率 (bpm)</th>
</tr>
</thead>
<tbody>
{data.map((d, i) => (
<tr key={i}>
<td>{d.date.toLocaleString()}</td>
<td>{d.value}</td>
</tr>
))}
</tbody>
</table>
</details>
</div>
Code collapsed