Data Visualization with Recharts: Dashboard Patterns

Build production-ready dashboards with Recharts in React. Covers bar, line, area, pie charts, responsive containers, custom tooltips, real-time data, and dark mode support.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.5k 字数|

使用 Recharts 进行数据可视化:仪表板模式

优秀的仪表板不仅仅是表格中的数字,而是一目了然地揭示趋势、异常和机会的视觉模式。 Recharts 是 React 生态系统中使用最广泛的图表库:基于 D3.js 基础构建、完全 TypeScript 类型、可组合且开箱即用。它可以处理 SVG 复杂性,因此您可以专注于数据故事。

本指南涵盖了生产仪表板的 Recharts 模式 - 收入趋势、转换漏斗、时间序列指标、地理分布和实时流数据 - 以及暗模式支持、可访问的调色板和响应式布局。

要点

  • 始终使用 width="100%" 和固定的 height 将图表包装在 ResponsiveContainer 中 — 切勿硬编码像素宽度
  • 使用 CustomTooltip 组件而不是默认组件 — 完全控制格式和样式
  • Recharts formatter 回调参数永远不应该显式键入 - 使用 Number(value) 进行转换
  • 深色模式需要动态颜色值 - 从 CSS 变量读取,而不是硬编码的十六进制颜色
  • 对于时间序列数据,在 XAxis 上使用 tickFormatter 来一致地格式化时间戳
  • 辅助功能:将 aria-label 添加到图表容器并在图例项上使用 tabIndex
  • 性能:使用 useMemo 记忆图表数据 — Recharts 在每个父渲染上重新渲染
  • 对于实时数据,更新数据数组引用 — Recharts 通过引用检测更改

设置

pnpm add recharts
pnpm add -D @types/recharts  # Usually not needed — recharts ships its own types

Recharts 2.x 附带 TypeScript 类型。不需要单独的 @types 包。


收入面积图

// src/components/charts/revenue-area-chart.tsx
'use client';

import { useMemo } from 'react';
import {
  AreaChart, Area, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';

interface RevenueDataPoint {
  date: string;       // ISO date string
  revenue: number;
  refunds: number;
  net: number;
}

interface CustomTooltipProps {
  active?: boolean;
  payload?: Array<{ name: string; value: number; color: string }>;
  label?: string;
}

function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
  if (!active || !payload || !label) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 text-sm font-medium text-muted-foreground">
        {format(parseISO(label), 'MMM d, yyyy')}
      </p>
      {payload.map((entry) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.color }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

interface RevenueAreaChartProps {
  data: RevenueDataPoint[];
  loading?: boolean;
}

export function RevenueAreaChart({ data, loading }: RevenueAreaChartProps) {
  // CRITICAL: memoize chart data — Recharts re-renders on every reference change
  const chartData = useMemo(() => data, [data]);

  if (loading) {
    return <div className="h-80 animate-pulse rounded-lg bg-muted" />;
  }

  return (
    <div role="img" aria-label="Monthly revenue area chart">
      <ResponsiveContainer width="100%" height={320}>
        <AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
          <defs>
            <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#f59e0b" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
            </linearGradient>
            <linearGradient id="colorNet" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#22c55e" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
            </linearGradient>
          </defs>

          <CartesianGrid
            strokeDasharray="3 3"
            stroke="hsl(var(--border))"
            vertical={false}
          />

          <XAxis
            dataKey="date"
            tickFormatter={(value: string) => format(parseISO(value), 'MMM d')}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
          />

          <YAxis
            tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
            width={60}
          />

          <Tooltip content={<CustomTooltip />} />

          <Legend
            wrapperStyle={{ fontSize: '14px', paddingTop: '16px' }}
            formatter={(value: string) =>
              <span className="text-foreground">{value}</span>
            }
          />

          <Area
            type="monotone"
            dataKey="revenue"
            name="Gross Revenue"
            stroke="#f59e0b"
            strokeWidth={2}
            fill="url(#colorRevenue)"
          />
          <Area
            type="monotone"
            dataKey="net"
            name="Net Revenue"
            stroke="#22c55e"
            strokeWidth={2}
            fill="url(#colorNet)"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

条形图:每月比较

// src/components/charts/monthly-bar-chart.tsx
'use client';

import {
  BarChart, Bar, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts';

interface MonthlyData {
  month: string;
  current: number;
  previous: number;
  target: number;
}

function CustomBarTooltip({ active, payload, label }: any) {
  if (!active || !payload) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 font-medium">{label}</p>
      {payload.map((entry: { name: string; value: number; fill: string }) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.fill }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

export function MonthlyBarChart({ data }: { data: MonthlyData[] }) {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
        <XAxis
          dataKey="month"
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <YAxis
          tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <Tooltip content={<CustomBarTooltip />} />
        <Legend />
        <Bar dataKey="previous" name="Previous Year" fill="hsl(var(--muted))" radius={[4, 4, 0, 0]} />
        <Bar dataKey="current"  name="Current Year"  fill="#f59e0b"            radius={[4, 4, 0, 0]} />
        <Bar dataKey="target"   name="Target"        fill="#22c55e" opacity={0.4} radius={[4, 4, 0, 0]} />
      </BarChart>
    </ResponsiveContainer>
  );
}

甜甜圈/饼图

// src/components/charts/platform-pie-chart.tsx
'use client';

import { useState } from 'react';
import {
  PieChart, Pie, Cell, Sector,
  ResponsiveContainer, Legend, Tooltip,
} from 'recharts';

interface PieDataPoint {
  name: string;
  value: number;
  color: string;
}

const PLATFORM_COLORS: Record<string, string> = {
  'Odoo':         '#714b67',
  'Shopify':      '#96bf48',
  'GoHighLevel':  '#f59e0b',
  'Power BI':     '#f2c811',
  'OpenClaw':     '#0ea5e9',
  'Other':        '#94a3b8',
};

function renderActiveShape(props: any) {
  const {
    cx, cy, innerRadius, outerRadius, startAngle, endAngle,
    fill, payload, percent, value,
  } = props;

  return (
    <g>
      <text x={cx} y={cy - 12} textAnchor="middle" className="text-base font-bold fill-foreground">
        {payload.name}
      </text>
      <text x={cx} y={cy + 12} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`${(percent * 100).toFixed(1)}%`}
      </text>
      <text x={cx} y={cy + 32} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`$${(Number(value) / 1000).toFixed(0)}k`}
      </text>
      <Sector
        cx={cx} cy={cy}
        innerRadius={innerRadius}
        outerRadius={outerRadius + 8}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
      <Sector
        cx={cx} cy={cy}
        innerRadius={outerRadius + 10}
        outerRadius={outerRadius + 12}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
    </g>
  );
}

export function PlatformPieChart({ data }: { data: PieDataPoint[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const chartData = data.map((item) => ({
    ...item,
    color: PLATFORM_COLORS[item.name] ?? '#94a3b8',
  }));

  return (
    <ResponsiveContainer width="100%" height={300}>
      <PieChart>
        <Pie
          activeIndex={activeIndex}
          activeShape={renderActiveShape}
          data={chartData}
          cx="50%"
          cy="50%"
          innerRadius={70}
          outerRadius={100}
          dataKey="value"
          onMouseEnter={(_, index) => setActiveIndex(index)}
        >
          {chartData.map((entry, index) => (
            <Cell key={`cell-${index}`} fill={entry.color} />
          ))}
        </Pie>
        <Legend
          formatter={(value: string) =>
            <span className="text-sm text-foreground">{value}</span>
          }
        />
      </PieChart>
    </ResponsiveContainer>
  );
}

实时折线图

// src/components/charts/realtime-chart.tsx
'use client';

import { useState, useEffect, useRef } from 'react';
import {
  LineChart, Line, XAxis, YAxis, CartesianGrid,
  Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';

interface MetricPoint {
  time: string;
  requests: number;
  errors: number;
  p95: number;
}

const MAX_DATA_POINTS = 60; // 60 seconds of data

export function RealtimeChart({ endpoint }: { endpoint: string }) {
  const [data, setData] = useState<MetricPoint[]>([]);
  const intervalRef = useRef<ReturnType<typeof setInterval>>();

  useEffect(() => {
    const fetchMetrics = async () => {
      try {
        const response = await fetch(endpoint);
        const point: MetricPoint = await response.json();

        setData((prev) => {
          const updated = [
            ...prev,
            { ...point, time: new Date().toLocaleTimeString() },
          ];
          // Keep only the last MAX_DATA_POINTS — sliding window
          return updated.slice(-MAX_DATA_POINTS);
        });
      } catch {
        // Metrics fetch failure is non-critical — log but do not crash
      }
    };

    fetchMetrics();
    intervalRef.current = setInterval(fetchMetrics, 1000);

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [endpoint]);

  return (
    <ResponsiveContainer width="100%" height={200}>
      <LineChart data={data}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
        <XAxis
          dataKey="time"
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          interval="preserveStartEnd"
        />
        <YAxis
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
        />
        <Tooltip />
        {/* Reference line for SLO threshold */}
        <ReferenceLine
          y={200}
          label={{ value: 'SLO 200ms', position: 'right', fontSize: 10 }}
          stroke="#ef4444"
          strokeDasharray="4 4"
        />
        <Line
          type="monotone"
          dataKey="p95"
          name="P95 Latency (ms)"
          stroke="#f59e0b"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false} // Disable animation for real-time data
        />
        <Line
          type="monotone"
          dataKey="errors"
          name="Errors/s"
          stroke="#ef4444"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

带有统计卡的仪表板布局

// src/components/dashboard/metrics-dashboard.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { RevenueAreaChart } from './revenue-area-chart';
import { MonthlyBarChart } from './monthly-bar-chart';
import { PlatformPieChart } from './platform-pie-chart';

export function MetricsDashboard() {
  const { data: metrics, isLoading } = useQuery({
    queryKey: ['dashboard-metrics'],
    queryFn: () => apiFetch<DashboardMetrics>('/analytics/dashboard'),
    refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
  });

  return (
    <div className="space-y-6">
      {/* KPI Cards */}
      <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
        {[
          { label: 'MRR', value: metrics?.mrr, format: 'currency', trend: '+12%' },
          { label: 'ARR',  value: metrics?.arr,  format: 'currency', trend: '+8%'  },
          { label: 'Customers', value: metrics?.customers, format: 'number', trend: '+23' },
          { label: 'Churn', value: metrics?.churnRate, format: 'percent', trend: '-0.3%' },
        ].map(({ label, value, format, trend }) => (
          <div key={label} className="rounded-lg border bg-card p-4">
            <p className="text-sm text-muted-foreground">{label}</p>
            <p className="mt-1 text-2xl font-bold">
              {isLoading ? (
                <span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
              ) : (
                formatMetric(value, format)
              )}
            </p>
            <p className="mt-1 text-sm text-green-500">{trend} this month</p>
          </div>
        ))}
      </div>

      {/* Charts */}
      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2 rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue Trend</h3>
          <RevenueAreaChart data={metrics?.revenueByDay ?? []} loading={isLoading} />
        </div>
        <div className="rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue by Platform</h3>
          <PlatformPieChart data={metrics?.revenueByPlatform ?? []} />
        </div>
      </div>

      <div className="rounded-lg border bg-card p-6">
        <h3 className="mb-4 text-lg font-semibold">Monthly Comparison</h3>
        <MonthlyBarChart data={metrics?.monthlyComparison ?? []} />
      </div>
    </div>
  );
}

function formatMetric(value: number | undefined, format: string): string {
  if (value === undefined) return '—';
  const fmt = new Intl.NumberFormat('en-US');
  if (format === 'currency') return `$${fmt.format(value)}`;
  if (format === 'percent') return `${value.toFixed(1)}%`;
  return fmt.format(value);
}

常见问题

为什么 Recharts Tooltip 格式化程序回调必须不显式键入其参数?

Recharts 的 formatter 属性的 TypeScript 类型重载,在注释参数时会导致类型冲突。正确的模式是使用 Number(value) 安全地转换值,而不注释 value 的类型。如果将其注释为 (value: number),TypeScript 可能会因 Recharts 内部签名中的联合类型而出错。让 TypeScript 推断类型并使用 Number(value) 进行转换。

如何让 Recharts 图表在深色模式下工作?

对所有颜色使用 CSS 自定义属性(变量),而不是硬编码的十六进制值。使用 hsl(var(--border)) 引用它们以表示网格线,使用 hsl(var(--muted-foreground)) 表示轴文本,使用 hsl(var(--background)) 表示工具提示背景。这些变量在您的 Tailwind 主题中定义,并通过 <html> 上的 dark 类自动在浅色和深色模式之间切换值。切勿硬编码 #374151 — 它不会随主题更新。

如何处理图表的空状态或加载状态?

加载:用骨架(animate-pulse bg-muted rounded-lg h-80)替换图表。对于空数据:在 ResponsiveContainer 内呈现居中的空状态消息。对于部分数据:Recharts 可以优雅地处理稀疏数据 - 只需传递您所拥有的数据,它就会连接点(或使用 connectNulls={false} 显示空值的间隙)。

我应该使用 Recharts、Chart.js 还是 D3?

React 原生仪表板的 Recharts — 它是声明性的、可组合的,并且与 React 的渲染模型自然集成。 Chart.js 适用于您需要基于 Canvas 的渲染器的更简单的用例(10,000 多个数据点的更好性能)。当您需要没有库涵盖的自定义、非标准图表类型时,使用 D3 — 预计需要更多代码。避免在一个应用程序中混合使用多个图表库。

如何将图表导出为报告的图像?

使用 html2canvas 库将图表容器捕获为图像:const canvas = await html2canvas(chartRef.current); const url = canvas.toDataURL('image/png');。或者,使用 Recharts 的内置 SVG 输出 - 由于 Recharts 呈现 SVG,因此您可以直接序列化 SVG 元素以进行矢量导出。对于 PDF 报告,使用 Puppeteer 或无头浏览器在服务器端渲染图表。


后续步骤

数据可视化将原始指标转化为商业智能。本指南中的模式(响应式容器、自定义工具提示、实时流和主题感知颜色)为您提供了在任何设备上以任何配色方案进行清晰通信的仪表板 UI 的基础。

ECOSIRE 使用 Recharts 跨 8 个仪表板模块构建分析仪表板,用于财务、运营和绩效指标 - 以及来自 NestJS API 和 Drizzle 支持的 PostgreSQL 的实时数据。对于商业智能和 Power BI 实施,探索我们的 Power BI 服务查看我们的所有服务

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天