图表组件库封装
概述
在业务项目中,通常需要基于底层可视化库(ECharts、D3 等)封装可复用的图表组件库。好的图表组件需要统一 API、支持主题定制、处理响应式和生命周期。
组件设计原则
| 原则 | 说明 |
|---|---|
| 声明式 API | 通过 props/配置传入数据和样式,而非命令式操作 |
| 主题一致 | 统一色板、字体、间距,支持切换 |
| 响应式 | 自动适应容器尺寸变化 |
| 加载与空态 | 统一的 Loading、Empty、Error 状态 |
| 可组合 | 支持图例、标题、Tooltip 等组件组合 |
| 类型安全 | 完整的 TypeScript 类型定义 |
React 图表组件封装
基础 ECharts 封装
import { useEffect, useRef, useCallback } from 'react';
import * as echarts from 'echarts/core';
import type { EChartsOption, ECharts } from 'echarts';
interface UseChartOptions {
option: EChartsOption;
theme?: string;
renderer?: 'canvas' | 'svg';
onEvents?: Record<string, (params: unknown) => void>;
}
// 核心 Hook:管理 ECharts 实例生命周期
function useChart({ option, theme, renderer = 'canvas', onEvents }: UseChartOptions) {
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<ECharts>();
// 初始化 & 销毁
useEffect(() => {
if (!containerRef.current) return;
const instance = echarts.init(containerRef.current, theme, { renderer });
instanceRef.current = instance;
// 响应式:监听容器尺寸变化
const observer = new ResizeObserver(() => instance.resize());
observer.observe(containerRef.current);
return () => {
observer.disconnect();
instance.dispose();
instanceRef.current = undefined;
};
}, [theme, renderer]);
// 更新配置
useEffect(() => {
instanceRef.current?.setOption(option, { notMerge: true });
}, [option]);
// 绑定事件
useEffect(() => {
const instance = instanceRef.current;
if (!instance || !onEvents) return;
Object.entries(onEvents).forEach(([event, handler]) => {
instance.on(event, handler);
});
return () => {
Object.entries(onEvents).forEach(([event, handler]) => {
instance.off(event, handler);
});
};
}, [onEvents]);
return { containerRef, instance: instanceRef };
}
业务图表组件
import { useMemo } from 'react';
interface BarChartProps {
data: Array<{ label: string; value: number }>;
title?: string;
color?: string;
height?: number;
loading?: boolean;
onBarClick?: (item: { label: string; value: number }) => void;
}
function BarChart({ data, title, color, height = 400, loading, onBarClick }: BarChartProps) {
// 将业务 props 转为 ECharts option
const option = useMemo<EChartsOption>(() => ({
title: title ? { text: title } : undefined,
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: data.map((d) => d.label),
},
yAxis: { type: 'value' },
series: [{
type: 'bar',
data: data.map((d) => d.value),
itemStyle: { color },
}],
}), [data, title, color]);
const events = useMemo(() => ({
click: (params: { dataIndex: number }) => {
onBarClick?.(data[params.dataIndex]);
},
}), [data, onBarClick]);
const { containerRef } = useChart({ option, onEvents: events });
if (loading) return <ChartLoading height={height} />;
if (data.length === 0) return <ChartEmpty height={height} />;
return <div ref={containerRef} style={{ width: '100%', height }} />;
}
主题系统
Design Token
interface ChartTheme {
// 色板
colorPalette: string[];
// 背景
backgroundColor: string;
// 文字
titleColor: string;
textColor: string;
subtextColor: string;
fontFamily: string;
// 坐标轴
axisLineColor: string;
splitLineColor: string;
// 提示框
tooltipBg: string;
tooltipBorder: string;
}
const lightTheme: ChartTheme = {
colorPalette: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
backgroundColor: '#ffffff',
titleColor: '#333333',
textColor: '#666666',
subtextColor: '#999999',
fontFamily: 'Inter, system-ui, sans-serif',
axisLineColor: '#ccc',
splitLineColor: '#eee',
tooltipBg: 'rgba(255,255,255,0.95)',
tooltipBorder: '#e0e0e0',
};
const darkTheme: ChartTheme = {
colorPalette: ['#4992ff', '#7cffb2', '#fddd60', '#ff6e76', '#58d9f9'],
backgroundColor: '#1a1a2e',
titleColor: '#eeeeee',
textColor: '#aaaaaa',
subtextColor: '#777777',
fontFamily: 'Inter, system-ui, sans-serif',
axisLineColor: '#444',
splitLineColor: '#333',
tooltipBg: 'rgba(30,30,50,0.95)',
tooltipBorder: '#555',
};
主题转换为 ECharts 配置
function themeToEChartsOption(theme: ChartTheme): Record<string, unknown> {
return {
color: theme.colorPalette,
backgroundColor: theme.backgroundColor,
textStyle: { color: theme.textColor, fontFamily: theme.fontFamily },
title: { textStyle: { color: theme.titleColor } },
tooltip: {
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
textStyle: { color: theme.textColor },
},
xAxis: {
axisLine: { lineStyle: { color: theme.axisLineColor } },
splitLine: { lineStyle: { color: theme.splitLineColor } },
},
yAxis: {
axisLine: { lineStyle: { color: theme.axisLineColor } },
splitLine: { lineStyle: { color: theme.splitLineColor } },
},
};
}
状态管理
// 统一的图表状态组件
function ChartLoading({ height }: { height: number }) {
return (
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span>加载中...</span>
</div>
);
}
function ChartEmpty({ height, message = '暂无数据' }: { height: number; message?: string }) {
return (
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
<span>{message}</span>
</div>
);
}
function ChartError({ height, error, onRetry }: { height: number; error: Error; onRetry?: () => void }) {
return (
<div style={{ height, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#ff4d4f' }}>图表加载失败</span>
{onRetry && <button onClick={onRetry}>重试</button>}
</div>
);
}
常见面试问题
Q1: 如何封装一个通用的 ECharts React 组件?
答案:
核心要点:(1) useRef 持有容器 DOM 和 ECharts 实例;(2) useEffect 中 init/dispose 管理实例生命周期;(3) ResizeObserver 监听容器尺寸变化自动 resize;(4) option 变化时用 setOption 更新;(5) 支持事件绑定和 Loading/Empty 状态。
Q2: 图表组件库的主题系统如何设计?
答案:
定义 ChartTheme 接口包含色板、背景色、文字色等 Design Token。提供预设主题(light/dark),支持自定义覆盖。通过 React Context 注入主题,将 Token 转换为底层图表库的配置(如 ECharts theme 对象)。配合 CSS 变量实现运行时切换。
Q3: 图表组件需要处理哪些边界状态?
答案:
Loading(数据加载中)、Empty(数据为空)、Error(加载失败)三种状态。还需考虑:容器尺寸为 0 时延迟初始化、数据更新过渡动画、组件卸载时销毁实例防止内存泄漏、SSR 环境下不执行 init。