可视化与图表库

What — 是什么

前端可视化是将数据转化为图形/图表的过程,通过视觉编码(位置、长度、颜色、面积等)让数据关系和模式一目了然。前端可视化库分为两大阵营:声明式图表库(配置驱动,开箱即用)和命令式图形库(底层 API,灵活定制)。

两大阵营:

┌───────────────────────────────────────────────────────────┐
│                   前端可视化技术栈                         │
├─────────────────────────┬─────────────────────────────────┤
│    声明式图表库          │       命令式图形库              │
│    配置驱动,开箱即用    │       底层 API,灵活定制        │
├─────────────────────────┼─────────────────────────────────┤
│  ECharts — 百度         │  D3.js — 数据驱动文档           │
│  AntV — 蚂蚁            │  Three.js — 3D 渲染            │
│  Chart.js — 轻量        │  PixiJS — 2D WebGL             │
│  Recharts — React       │  Canvas 2D — 原生              │
│  Nivo — React           │  SVG — 原生                    │
│  Victory — React        │  ZRender — ECharts 底层        │
│  Highcharts — 商业      │  G — AntV 底层                 │
└─────────────────────────┴─────────────────────────────────┘

核心技术对比:

技术渲染方式适用场景性能上限学习成本
SVGDOM 节点少量图形、交互丰富~1000 元素
Canvas 2D像素绘制中量数据、实时刷新~10万点
WebGL/WebGPUGPU 渲染大量数据、3D 场景~100万点

主流图表库对比:

维度EChartsAntV(G2/S2)D3.jsChart.jsRecharts
开发者百度/Apache蚂蚁集团Mike BostockChart.js 团队Recharts
渲染Canvas/SVGCanvas/SVGSVG/CanvasCanvasSVG
声明方式Option 配置API 链式数据绑定ConfigReact 组件
图表类型30+20+无限(底层)815+
大数据★★★★★★★★★★★★★★★★★
定制能力★★★★★★★★★★★★★★★★★
React 整合echarts-for-react@antv/g2d3 + Reactreact-chartjs原生
包体积~800KB~500KB~250KB~200KB~300KB
TypeScript★★★★★★★★★★★★★★★★★★★★★
商业授权Apache 2.0MITISCMITMIT

Why — 为什么

选型建议:

场景推荐理由
仪表盘/BI 报表ECharts图表类型最全,大数据性能好
数据分析平台AntV G2语法更优雅,API 设计更现代
高度定制可视化D3.js底层控制力最强,可做任意图形
简单报表图表Chart.js轻量,5 分钟上手
React 项目报表Recharts原生 React 组件,声明式
大规模数据可视化ECharts (Canvas) + 增量渲染百万级数据点
地理可视化ECharts (GL) / AntV L7专门的地图/3D 支持
图关系可视化AntV G6图/网络专精
表格透视分析AntV S2多维交叉表格

How — 怎么用

ECharts 核心用法

npm install echarts echarts-for-react
// 基础折线图
import ReactECharts from 'echarts-for-react';

function LineChart() {
  const option = {
    title: { text: '月度销售趋势' },
    tooltip: { trigger: 'axis' },
    legend: { data: ['线上', '线下'] },
    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: ['1月', '2月', '3月', '4月', '5月', '6月'],
    },
    yAxis: { type: 'value' },
    series: [
      {
        name: '线上',
        type: 'line',
        smooth: true,
        data: [120, 132, 101, 134, 90, 230],
        areaStyle: { opacity: 0.3 },
      },
      {
        name: '线下',
        type: 'line',
        smooth: true,
        data: [220, 182, 191, 234, 290, 330],
        areaStyle: { opacity: 0.3 },
      },
    ],
  };

  return <ReactECharts option={option} style={{ height: 400 }} />;
}

柱状图 + 多 Y 轴:

const barOption = {
  title: { text: '营收与增长率' },
  tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
  legend: { data: ['营收(万)', '增长率(%)'] },
  xAxis: {
    type: 'category',
    data: ['Q1', 'Q2', 'Q3', 'Q4'],
  },
  yAxis: [
    { type: 'value', name: '营收', axisLabel: { formatter: '{value}万' } },
    { type: 'value', name: '增长率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
  ],
  series: [
    {
      name: '营收(万)',
      type: 'bar',
      data: [320, 432, 501, 634],
      itemStyle: { borderRadius: [4, 4, 0, 0] },
    },
    {
      name: '增长率(%)',
      type: 'line',
      yAxisIndex: 1,
      data: [12, 35, 16, 26],
      smooth: true,
    },
  ],
};

饼图 + 环形图:

const pieOption = {
  title: { text: '流量来源', left: 'center' },
  tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
  legend: { orient: 'vertical', left: 'left' },
  series: [
    {
      name: '流量来源',
      type: 'pie',
      radius: ['40%', '70%'], // 环形图
      avoidLabelOverlap: false,
      itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
      label: { show: true, formatter: '{b}: {d}%' },
      emphasis: {
        label: { show: true, fontSize: 16, fontWeight: 'bold' },
        itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' },
      },
      data: [
        { value: 1048, name: '搜索引擎' },
        { value: 735, name: '直接访问' },
        { value: 580, name: '邮件营销' },
        { value: 484, name: '联盟广告' },
        { value: 300, name: '视频广告' },
      ],
    },
  ],
};

仪表盘组合:

const dashboardOption = {
  title: { text: '系统监控' },
  series: [
    {
      type: 'gauge',
      center: ['25%', '55%'],
      detail: { formatter: '{value}%' },
      data: [{ value: 72, name: 'CPU' }],
      axisLine: {
        lineStyle: {
          width: 15,
          color: [[0.3, '#67e0e3'], [0.7, '#37a2da'], [1, '#fd666d']],
        },
      },
    },
    {
      type: 'gauge',
      center: ['75%', '55%'],
      detail: { formatter: '{value}%' },
      data: [{ value: 45, name: '内存' }],
      axisLine: {
        lineStyle: {
          width: 15,
          color: [[0.3, '#67e0e3'], [0.7, '#37a2da'], [1, '#fd666d']],
        },
      },
    },
  ],
};

ECharts 大数据优化:

// 1. 增量渲染(百万级数据)
const largeDataOption = {
  title: { text: '百万级数据散点图' },
  tooltip: { trigger: 'item' },
  series: [{
    type: 'scatter',
    large: true,               // 开启大数据模式
    largeThreshold: 2000,      // 超过 2000 点启用
    progressive: 400,          // 分片渲染,每片 400 点
    data: generateMillionPoints(),
  }],
};

// 2. 数据采样(降采样)
// 安装 echarts-stat
// import { sampling } from 'echarts-stat';
// const sampledData = sampling('lttb', rawData, 1000); // 降采样到 1000 点

// 3. 增量追加数据
function appendData(chart: echarts.ECharts, newData: number[]) {
  chart.appendData({
    seriesIndex: 0,
    data: newData,
  });
}

ECharts 自定义主题:

// 自定义主题
const darkTheme = {
  color: ['#1677ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1'],
  backgroundColor: '#1a1a2e',
  textStyle: { color: '#e0e0e0' },
  title: { textStyle: { color: '#fff' } },
  legend: { textStyle: { color: '#ccc' } },
  categoryAxis: {
    axisLine: { lineStyle: { color: '#555' } },
    axisLabel: { color: '#aaa' },
    splitLine: { lineStyle: { color: '#333' } },
  },
  valueAxis: {
    axisLine: { lineStyle: { color: '#555' } },
    axisLabel: { color: '#aaa' },
    splitLine: { lineStyle: { color: '#333' } },
  },
};

// 注册并使用
echarts.registerTheme('dark-custom', darkTheme);
// <ReactECharts option={option} theme="dark-custom" />

AntV G2 核心用法

npm install @antv/g2
// 基础用法
import { Chart } from '@antv/g2';

const chart = new Chart({
  container: 'container',
  autoFit: true,
  height: 400,
});

chart
  .interval()
  .data([
    { genre: 'Sports', sold: 275 },
    { genre: 'Strategy', sold: 115 },
    { genre: 'Action', sold: 120 },
    { genre: 'Shooter', sold: 350 },
    { genre: 'Other', sold: 150 },
  ])
  .encode('x', 'genre')
  .encode('y', 'sold')
  .encode('color', 'genre')
  .style('radiusTopLeft', 4)
  .style('radiusTopRight', 4)
  .axis('y', { labelFormatter: (v) => `${v}万` })
  .tooltip({ channel: 'y', valueFormatter: (d) => `${d}万` });

chart.render();

折线图 + 面积:

const lineChart = new Chart({
  container: 'line-container',
  autoFit: true,
  height: 400,
});

lineChart
  .data(monthlyData)
  .encode('x', 'month')
  .encode('y', 'value')
  .encode('color', 'type');

lineChart.line().encode('shape', 'smooth');
lineChart.area().encode('shape', 'smooth').style('fillOpacity', 0.3);

lineChart.render();

AntV G6 图关系可视化:

npm install @antv/g6
import G6 from '@antv/g6';

const graph = new G6.Graph({
  container: 'graph-container',
  width: 800,
  height: 600,
  modes: {
    default: ['drag-canvas', 'zoom-canvas', 'drag-node'],
  },
  layout: {
    type: 'force',         // 力导向布局
    preventOverlap: true,
    nodeSize: 40,
  },
  defaultNode: {
    size: 40,
    style: { fill: '#1677ff', stroke: '#0958d9', lineWidth: 2 },
    labelCfg: { style: { fill: '#fff', fontSize: 12 } },
  },
  defaultEdge: {
    style: { stroke: '#e0e0e0', lineWidth: 1 },
  },
  nodeStateStyles: {
    hover: { fill: '#4096ff', lineWidth: 3 },
  },
});

graph.data({
  nodes: [
    { id: 'node1', label: '用户服务' },
    { id: 'node2', label: '订单服务' },
    { id: 'node3', label: '支付服务' },
    { id: 'node4', label: '库存服务' },
  ],
  edges: [
    { source: 'node1', target: 'node2' },
    { source: 'node2', target: 'node3' },
    { source: 'node2', target: 'node4' },
    { source: 'node1', target: 'node3' },
  ],
});

graph.render();

D3.js 核心用法

npm install d3 @types/d3

数据绑定与选集:

import * as d3 from 'd3';

// 选集操作
const svg = d3.select('#chart')
  .append('svg')
  .attr('width', 600)
  .attr('height', 400);

const data = [30, 86, 168, 281, 303, 365];

// 数据绑定(Enter-Update-Exit 模式)
const bars = svg.selectAll('.bar')
  .data(data)
  .join('rect')
  .attr('class', 'bar')
  .attr('x', 0)
  .attr('y', (_, i) => i * 40)
  .attr('width', (d) => d * 1.5)
  .attr('height', 30)
  .attr('fill', '#1677ff')
  .attr('rx', 4);

// 文字标签
svg.selectAll('.label')
  .data(data)
  .join('text')
  .attr('x', (d) => d * 1.5 + 8)
  .attr('y', (_, i) => i * 40 + 20)
  .text((d) => d)
  .attr('fill', '#333')
  .attr('font-size', '12px');

比例尺与坐标轴:

// 比例尺
const xScale = d3.scaleLinear()
  .domain([0, d3.max(data)!])   // 数据范围
  .range([0, 500]);              // 像素范围

const yScale = d3.scaleBand()
  .domain(data.map((_, i) => `Item ${i + 1}`))
  .range([0, 400])
  .padding(0.2);

const colorScale = d3.scaleOrdinal()
  .domain(['A', 'B', 'C', 'D'])
  .range(['#1677ff', '#52c41a', '#faad14', '#ff4d4f']);

// 坐标轴
const xAxis = d3.axisBottom(xScale).ticks(5);
const yAxis = d3.axisLeft(yScale);

svg.append('g')
  .attr('transform', 'translate(60, 360)')
  .call(xAxis);

svg.append('g')
  .attr('transform', 'translate(60, 0)')
  .call(yAxis);

交互式折线图:

function createLineChart(container: string, data: { date: Date; value: number }[]) {
  const margin = { top: 20, right: 30, bottom: 30, left: 40 };
  const width = 600 - margin.left - margin.right;
  const height = 400 - margin.top - margin.bottom;

  const svg = d3.select(container)
    .append('svg')
    .attr('viewBox', `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
    .append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);

  // 比例尺
  const x = d3.scaleTime()
    .domain(d3.extent(data, (d) => d.date) as [Date, Date])
    .range([0, width]);

  const y = d3.scaleLinear()
    .domain([0, d3.max(data, (d) => d.value)!])
    .nice()
    .range([height, 0]);

  // 坐标轴
  svg.append('g').call(d3.axisBottom(x)).attr('transform', `translate(0,${height})`);
  svg.append('g').call(d3.axisLeft(y));

  // 面积
  const area = d3.area<{ date: Date; value: number }>()
    .x((d) => x(d.date))
    .y0(height)
    .y1((d) => y(d.value));

  svg.append('path')
    .datum(data)
    .attr('fill', 'rgba(22, 119, 255, 0.2)')
    .attr('d', area);

  // 折线
  const line = d3.line<{ date: Date; value: number }>()
    .x((d) => x(d.date))
    .y((d) => y(d.value));

  svg.append('path')
    .datum(data)
    .attr('fill', 'none')
    .attr('stroke', '#1677ff')
    .attr('stroke-width', 2)
    .attr('d', line);

  // 交互:Tooltip
  const tooltip = d3.select('body').append('div')
    .attr('class', 'tooltip')
    .style('position', 'absolute')
    .style('padding', '8px 12px')
    .style('background', 'rgba(0,0,0,0.8)')
    .style('color', '#fff')
    .style('border-radius', '4px')
    .style('font-size', '12px')
    .style('pointer-events', 'none')
    .style('opacity', 0);

  const bisect = d3.bisector<{ date: Date; value: number }, Date>((d) => d.date).left;

  svg.append('rect')
    .attr('width', width)
    .attr('height', height)
    .attr('fill', 'none')
    .attr('pointer-events', 'all')
    .on('mousemove', (event) => {
      const [mx] = d3.pointer(event);
      const x0 = x.invert(mx);
      const i = bisect(data, x0, 1);
      const d0 = data[i - 1];
      const d1 = data[i];
      if (!d0 || !d1) return;
      const d = x0.getTime() - d0.date.getTime() > d1.date.getTime() - x0.getTime() ? d1 : d0;

      tooltip
        .style('opacity', 1)
        .html(`${d3.timeFormat('%Y-%m-%d')(d.date)}<br/>值: ${d.value}`)
        .style('left', `${event.pageX + 10}px`)
        .style('top', `${event.pageY - 20}px`);
    })
    .on('mouseleave', () => tooltip.style('opacity', 0));
}

D3 力导向图:

function createForceGraph(container: string) {
  const width = 800, height = 600;

  const nodes = [
    { id: 'A' }, { id: 'B' }, { id: 'C' }, { id: 'D' }, { id: 'E' },
  ];
  const links = [
    { source: 'A', target: 'B' }, { source: 'A', target: 'C' },
    { source: 'B', target: 'D' }, { source: 'C', target: 'D' },
    { source: 'D', target: 'E' },
  ];

  const svg = d3.select(container).append('svg').attr('viewBox', `0 0 ${width} ${height}`);

  const simulation = d3.forceSimulation(nodes as any)
    .force('link', d3.forceLink(links).id((d: any) => d.id).distance(100))
    .force('charge', d3.forceManyBody().strength(-300))
    .force('center', d3.forceCenter(width / 2, height / 2));

  const link = svg.append('g')
    .selectAll('line')
    .data(links)
    .join('line')
    .attr('stroke', '#999')
    .attr('stroke-width', 1);

  const node = svg.append('g')
    .selectAll('circle')
    .data(nodes)
    .join('circle')
    .attr('r', 20)
    .attr('fill', '#1677ff')
    .call(d3.drag()
      .on('start', (event, d: any) => {
        if (!event.active) simulation.alphaTarget(0.3).restart();
        d.fx = d.x; d.fy = d.y;
      })
      .on('drag', (event, d: any) => { d.fx = event.x; d.fy = event.y; })
      .on('end', (event, d: any) => {
        if (!event.active) simulation.alphaTarget(0);
        d.fx = null; d.fy = null;
      })
    );

  const label = svg.append('g')
    .selectAll('text')
    .data(nodes)
    .join('text')
    .text((d) => d.id)
    .attr('text-anchor', 'middle')
    .attr('dy', '0.35em')
    .attr('fill', '#fff')
    .attr('font-size', '12px')
    .attr('pointer-events', 'none');

  simulation.on('tick', () => {
    link.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
        .attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
    node.attr('cx', (d: any) => d.x).attr('cy', (d: any) => d.y);
    label.attr('x', (d: any) => d.x).attr('y', (d: any) => d.y);
  });
}

Chart.js 轻量用法

npm install chart.js react-chartjs-2
import { Line } from 'react-chartjs-2';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler } from 'chart.js';

ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);

function SimpleLineChart() {
  const data = {
    labels: ['1月', '2月', '3月', '4月', '5月', '6月'],
    datasets: [
      {
        label: '销售额',
        data: [12, 19, 3, 5, 2, 3],
        borderColor: '#1677ff',
        backgroundColor: 'rgba(22, 119, 255, 0.2)',
        fill: true,
        tension: 0.4,
      },
    ],
  };

  const options = {
    responsive: true,
    plugins: { legend: { position: 'top' as const } },
    scales: { y: { beginAtZero: true } },
  };

  return <Line data={data} options={options} />;
}

Recharts React 原生

npm install recharts
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts';

const data = [
  { name: '1月', uv: 4000, pv: 2400 },
  { name: '2月', uv: 3000, pv: 1398 },
  { name: '3月', uv: 2000, pv: 9800 },
  { name: '4月', uv: 2780, pv: 3908 },
  { name: '5月', uv: 1890, pv: 4800 },
  { name: '6月', uv: 2390, pv: 3800 },
];

function RechartsArea() {
  return (
    <ResponsiveContainer width="100%" height={400}>
      <AreaChart data={data}>
        <CartesianGrid strokeDasharray="3 3" />
        <XAxis dataKey="name" />
        <YAxis />
        <Tooltip />
        <Legend />
        <Area type="monotone" dataKey="uv" stroke="#1677ff" fill="rgba(22,119,255,0.2)" />
        <Area type="monotone" dataKey="pv" stroke="#52c41a" fill="rgba(82,196,26,0.2)" />
      </AreaChart>
    </ResponsiveContainer>
  );
}

可视化性能优化

// 1. 渲染引擎选择
// SVG:少量图形(< 1000),交互丰富
// Canvas:中量数据(1K - 10万),实时刷新
// WebGL:大量数据(10万+),3D 场景

// ECharts 切换渲染引擎
const option = {
  // Canvas 模式(默认,性能好)
  // renderer: 'canvas',
  // SVG 模式(交互好,DOM 可操作)
  // renderer: 'svg',
};

// 2. 大数据降采样
function lttbDownsample(data: { x: number; y: number }[], threshold: number) {
  // Largest-Triangle-Three-Buckets 算法
  // 保留数据视觉特征的同时降低数据量
  if (data.length <= threshold) return data;

  const bucketSize = (data.length - 2) / (threshold - 2);
  const sampled = [data[0]]; // 保留第一个点

  let a = 0; // 前一个选中点
  for (let i = 1; i < threshold - 1; i++) {
    const bucketStart = Math.floor((i - 1) * bucketSize) + 1;
    const bucketEnd = Math.min(Math.floor(i * bucketSize) + 1, data.length);
    const nextBucketStart = Math.floor(i * bucketSize) + 1;
    const nextBucketEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, data.length);

    // 计算下一个 bucket 的平均点
    let avgX = 0, avgY = 0;
    for (let j = nextBucketStart; j < nextBucketEnd; j++) {
      avgX += data[j].x;
      avgY += data[j].y;
    }
    avgX /= (nextBucketEnd - nextBucketStart);
    avgY /= (nextBucketEnd - nextBucketStart);

    // 在当前 bucket 中找三角形面积最大的点
    let maxArea = -1, maxIdx = bucketStart;
    for (let j = bucketStart; j < bucketEnd; j++) {
      const area = Math.abs(
        (data[a].x - avgX) * (data[j].y - data[a].y) -
        (data[a].x - data[j].x) * (avgY - data[a].y)
      ) * 0.5;
      if (area > maxArea) { maxArea = area; maxIdx = j; }
    }

    sampled.push(data[maxIdx]);
    a = maxIdx;
  }

  sampled.push(data[data.length - 1]); // 保留最后一个点
  return sampled;
}

// 3. 按需渲染
// ECharts: lazy 渲染 + resize 防抖
const chartRef = useRef<ReactECharts>(null);

useEffect(() => {
  const handleResize = debounce(() => {
    chartRef.current?.getEchartsInstance().resize();
  }, 300);
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

// 4. 虚拟化:大数据场景只渲染可见范围
// ECharts dataZoom 组件
const option = {
  dataZoom: [
    { type: 'inside', start: 0, end: 10 },  // 默认显示 10%
    { type: 'slider', start: 0, end: 10 },   // 滑动条
  ],
  series: [{ type: 'line', data: largeData }],
};

可视化无障碍

// 1. 图表替代文本
// ECharts
const option = {
  title: { text: '月度销售趋势图' },
  // aria 描述
  aria: {
    show: true,
    description: '该折线图展示了1月至6月的线上和线下销售额趋势,线上从120万增长至230万,线下从220万增长至330万',
  },
};

// 2. HTML 表格替代
function AccessibleChart() {
  return (
    <figure>
      <ReactECharts option={chartOption} />
      <figcaption>月度销售趋势图</figcaption>
      {/* 屏幕阅读器可访问的数据表格 */}
      <table className="sr-only">
        <caption>月度销售数据</caption>
        <thead><tr><th>月份</th><th>线上(万)</th><th>线下(万)</th></tr></thead>
        <tbody>
          {data.map((d) => <tr key={d.month}><td>{d.month}</td><td>{d.online}</td><td>{d.offline}</td></tr>)}
        </tbody>
      </table>
    </figure>
  );
}

// 3. 键盘交互
// ECharts 支持键盘导航焦点
// chart.setOption({ series: [{ emphasis: { focus: 'series' } }] });

常见问题与踩坑

问题原因解决方案
图表不显示容器宽高为 0确保容器有明确宽高,或用 autoFit: true
Resize 后图表变形未监听 resize 事件添加 window.addEventListener('resize', () => chart.resize())
内存泄漏组件卸载未销毁图表useEffect return 中调用 chart.dispose()
ECharts 包体积大全量引入按需引入:import * as echarts from 'echarts/core'
D3 数据绑定不更新未理解 Enter-Update-Exit使用 selection.join() 统一处理
Canvas 模糊DPR 未处理Canvas 设置 width * devicePixelRatio,CSS 设原始尺寸
多图表性能差同时渲染太多实例懒加载 + IntersectionObserver,可见时再渲染
Tooltip 闪烁事件冒泡/容器滚动调整 tooltip.confine: true 或固定定位
移动端交互差触摸事件与鼠标事件不同ECharts 移动端自适应,D3 需手动处理 touch 事件
数据更新动画卡顿全量重绘ECharts 用 setOption 差量更新,D3 用 transition

最佳实践

  • 简单报表用 ECharts/Chart.js(配置驱动),复杂定制用 D3(底层控制)
  • 大数据场景优先选 Canvas/WebGL 渲染,避免 SVG(DOM 节点过多)
  • 超过 1 万数据点使用降采样(LTTB 算法)或增量渲染
  • 图表组件懒加载 + IntersectionObserver,不可见时不渲染
  • 组件卸载必须 dispose() 销毁实例,防止内存泄漏
  • Resize 事件防抖处理,避免频繁重绘
  • ECharts 按需引入模块,减小包体积
  • 无障碍:提供替代文本和 HTML 数据表格
  • 设计系统统一图表主题(颜色、字体、间距),避免各页面风格不一致
  • 响应式:ResponsiveContainer(Recharts)或 autoFit: true(G2)

面试题

Q1: SVG 和 Canvas 在可视化中各有什么优劣?怎么选择?

SVG 优势:DOM 节点可单独操作/绑定事件,SEO 友好,缩放不失真,调试方便。劣势:大量元素时 DOM 开销大,性能急剧下降(~1000 元素为界)。Canvas 优势:像素级绘制,性能好,适合大量数据。劣势:无 DOM 结构,事件处理需手动计算坐标,不可缩放,不能被屏幕阅读器访问。选择:< 1000 元素且交互丰富 → SVG;> 1000 元素或实时刷新 → Canvas;3D/百万级 → WebGL。ECharts 默认 Canvas,可切换 SVG。

Q2: ECharts 的大数据优化策略有哪些?

large: true + largeThreshold:超过阈值启用大数据模式,使用简化渲染路径;② progressive 分片渲染:将数据分成多个片段逐步渲染,避免主线程长时间阻塞;③ appendData 增量追加:不重绘已有数据,只追加新数据;④ 降采样:LTTB 算法将百万数据降到千级,保留视觉特征;⑤ dataZoom 数据缩放:只渲染可见范围的数据;⑥ 切换 Canvas 渲染:SVG 模式无法使用 large 模式。结合使用:降采样 + large 模式 + dataZoom 可处理百万级数据。

Q3: D3 的 Enter-Update-Exit 模式是什么?join() 简化了什么?

Enter-Update-Exit 是 D3 数据绑定的核心模式:Enter = 新数据没有对应 DOM → 创建元素;Update = 数据和 DOM 都存在 → 更新属性;Exit = DOM 没有对应数据 → 移除元素。传统写法需要三段代码分别处理。selection.join() 将三者统一:.join('rect').attr('width', d => d) 自动处理 Enter(创建 rect)、Update(更新 width)、Exit(移除)。代码量减少 60%,且不易遗漏 Exit 清理。

Q4: 如何实现可视化图表的无障碍?

四个层面:① 替代文本:<figcaption>aria-label 描述图表内容;② 数据表格:<table class="sr-only"> 提供屏幕阅读器可访问的原始数据;③ 键盘导航:图表支持 Tab 焦点和方向键切换数据点,ECharts 开启 aria.show 和键盘交互;④ 高对比度:颜色不是唯一的信息编码方式,同时使用形状/纹理区分,色盲安全色板(如 Viridis)。ECharts 的 aria 配置可自动生成描述文本。

Q5: ECharts 和 AntV G2 的核心设计理念有什么不同?

ECharts 是”配置驱动”:通过 JSON option 描述图表,{ xAxis: {}, yAxis: {}, series: [{ type: 'line', data: [] }] },上手快但灵活性受限于 option 结构。G2 是”语法驱动”:基于 Leland Wilkinson 的《The Grammar of Graphics》,通过 API 链式调用描述数据映射关系,chart.interval().encode('x', 'genre').encode('y', 'sold'),更灵活但学习曲线更陡。选择:快速出图 + 图表类型多 → ECharts;精细控制 + 可视化分析 → G2。

Q6: 如何处理图表的内存泄漏问题?

三个关键点:① 组件卸载时销毁实例:useEffect(() => { const chart = new Chart(); return () => chart.dispose(); }, []);② 移除事件监听:resize、click 等 listener 必须 removeEventListener;③ 清除定时器:实时数据刷新的 setInterval 必须 clearInterval。React 中常见问题:ReactECharts 组件自动处理 dispose,但直接使用 echarts.init 需手动清理。D3 中 selection.on('click', handler)selection.on('click', null) 移除。

Q7: 实时数据刷新的可视化如何优化性能?

① 增量更新:ECharts setOption 只传新数据 + replaceMerge,避免全量重绘;D3 使用 Enter-Update-Exit 只更新变化的部分;② 数据窗口:维护固定长度队列,新数据入队旧数据出队,Array.push + Array.shift;③ 降低刷新频率:requestAnimationFrame 节流,数据产生频率 > 渲染频率时合并中间帧;④ 双缓冲:离屏 Canvas 绘制完成后一次性交换到显示 Canvas,避免闪烁;⑤ Web Worker:数据计算(聚合/降采样)放到 Worker 线程,不阻塞主线程渲染。

Q8: D3 的比例尺(Scale)有哪些类型?分别适用什么场景?

连续型:scaleLinear(线性映射,适用于数值轴)、scalePow(幂次映射)、scaleSqrt(平方根映射,面积编码)、scaleLog(对数映射,大跨度数据)、scaleTime(时间轴)、scaleSequential(连续色带)。离散型:scaleOrdinal(分类色映射)、scaleBand(条形图定位)、scalePoint(散点定位)。量化型:scaleQuantize(连续→离散分段)、scaleQuantile(分位数分段)、scaleThreshold(自定义阈值分段)。选择:数值轴 → Linear,时间轴 → Time,分类色 → Ordinal,柱状图定位 → Band。


相关链接: