康心伴Logo
康心伴WellAlly
前端开发

构建高性能 React D3 健康数据图表

5 分钟阅读

构建高性能 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

参考资料

#

文章标签

react
d3
可视化
性能优化
健康科技

觉得这篇文章有帮助?

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