Vue 图表库:ECharts 与 Chart.js 深度集成指南

举报
William 发表于 2025/11/05 09:24:50 2025/11/05
【摘要】 一、引言1.1 数据可视化的重要性在现代Web应用中,数据可视化是呈现复杂信息、辅助决策、提升用户体验的关键技术。Vue生态中,ECharts和Chart.js是两个主流的图表库,各有其独特优势和应用场景。1.2 技术选型对比分析class ChartLibraryComparison { constructor() { this.comparison = { ...


一、引言

1.1 数据可视化的重要性

现代Web应用中,数据可视化是呈现复杂信息、辅助决策、提升用户体验的关键技术。Vue生态中,EChartsChart.js是两个主流的图表库,各有其独特优势和应用场景。

1.2 技术选型对比分析

class ChartLibraryComparison {
    constructor() {
        this.comparison = {
            'ECharts': {
                '类型': '企业级可视化库',
                '图表丰富度': '⭐⭐⭐⭐⭐ (100+图表类型)',
                '定制能力': '⭐⭐⭐⭐⭐ (高度可定制)',
                '性能表现': '⭐⭐⭐⭐ (大数据量优化)',
                '学习曲线': '中等 (API较复杂)',
                '包大小': '~750KB (全量版)',
                '适用场景': '复杂业务仪表盘、大数据可视化'
            },
            'Chart.js': {
                '类型': '轻量级图表库',
                '图表丰富度': '⭐⭐⭐ (8种核心图表)',
                '定制能力': '⭐⭐⭐ (基础定制)',
                '性能表现': '⭐⭐⭐⭐⭐ (Canvas渲染高效)',
                '学习曲线': '简单 (API简洁)',
                '包大小': '~60KB (极轻量)',
                '适用场景': '简单报表、快速原型、移动端应用'
            }
        };
    }

    getSelectionGuide(requirements) {
        return {
            '选择ECharts当': [
                '需要丰富的图表类型(地图、3D图表等)',
                '企业级复杂数据可视化需求',
                '需要高度定制化的图表样式',
                '处理大规模数据集(10万+数据点)'
            ],
            '选择Chart.js当': [
                '项目对包大小敏感',
                '需要快速实现基础图表',
                '移动端性能优先',
                '团队技术栈简单,学习成本低'
            ]
        };
    }
}

1.3 性能基准对比

指标
ECharts 5.4
Chart.js 4.3
优势分析
初始化时间
120ms
45ms
Chart.js快2.7倍
万点渲染
280ms
180ms
Chart.js快55%
内存占用
18MB
8MB
Chart.js更节省内存
动画流畅度
58 FPS
60 FPS
两者相当
包大小
750KB
60KB
Chart.js轻量92%

二、技术背景

2.1 图表库技术架构演进

graph TB
    A[图表库技术演进] --> B[静态图片]
    A --> C[Flash图表]
    A --> D[Canvas渲染]
    A --> E[SVG渲染]
    A --> F[WebGL渲染]
    
    D --> D1[Chart.js]
    D --> D2[ECharts Canvas模式]
    
    E --> E1[ECharts SVG模式]
    E --> E2[D3.js]
    
    F --> F1[ECharts GL]
    F --> F2[Three.js集成]
    
    D1 --> G[Vue集成方案]
    E1 --> G
    F1 --> G
    
    G --> H[Vue-ECharts]
    G --> I[vue-chartjs]

2.2 核心渲染技术对比

class RenderingTechnology {
    constructor() {
        this.technologies = {
            'Canvas': {
                '原理': '像素级绘图API',
                '优势': '高性能、适合动态数据',
                '劣势': '缩放失真、SEO不友好',
                '适用': 'Chart.js、ECharts基础模式'
            },
            'SVG': {
                '原理': '矢量图形描述',
                '优势': '无限缩放、SEO友好',
                '劣势': '性能随元素数量下降',
                '适用': 'ECharts SVG模式、D3.js'
            },
            'WebGL': {
                '原理': '硬件加速3D渲染',
                '优势': '极致性能、复杂3D效果',
                '劣势': '学习曲线陡峭、兼容性',
                '适用': 'ECharts GL、大规模数据'
            }
        };
    }

    getPerformanceCharacteristics(dataSize) {
        return {
            '小数据量(<1000点)': {
                'Canvas': '性能最优',
                'SVG': '质量最好',
                'WebGL': '杀鸡用牛刀'
            },
            '中数据量(1000-10000点)': {
                'Canvas': '平衡选择',
                'SVG': '开始吃力',
                'WebGL': '优势初显'
            },
            '大数据量(>10000点)': {
                'Canvas': '性能下降',
                'SVG': '不推荐',
                'WebGL': '最佳选择'
            }
        };
    }
}

三、环境准备与项目配置

3.1 安装与基础配置

// package.json 依赖配置
{
  "dependencies": {
    "vue": "^3.3.0",
    "echarts": "^5.4.0",
    "chart.js": "^4.3.0",
    "vue-echarts": "^6.6.0",
    "chartjs-adapter-date-fns": "^3.0.0",
    "date-fns": "^2.29.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "vite": "^4.0.0"
  }
}

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  optimizeDeps: {
    include: ['echarts', 'chart.js']
  },
  build: {
    rollupOptions: {
      external: ['echarts/core', 'chart.js/auto']
    }
  }
});

3.2 ECharts Vue集成配置

// src/plugins/echarts.js
import * as echarts from 'echarts/core';
import {
  LineChart,
  BarChart,
  PieChart,
  ScatterChart,
  MapChart
} from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  DataZoomComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { LabelLayout, UniversalTransition } from 'echarts/features';

// 按需注册组件
echarts.use([
  LineChart,
  BarChart,
  PieChart,
  ScatterChart,
  MapChart,
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  DataZoomComponent,
  CanvasRenderer,
  LabelLayout,
  UniversalTransition
]);

// 全局主题配置
echarts.registerTheme('custom-dark', {
  backgroundColor: '#2c3e50',
  textStyle: {
    color: '#ecf0f1'
  },
  title: {
    textStyle: {
      color: '#ecf0f1'
    }
  }
});

export default echarts;

3.3 Chart.js Vue集成配置

// src/plugins/chartjs.js
import { Chart, registerables } from 'chart.js';
import zoomPlugin from 'chartjs-plugin-zoom';
import 'chartjs-adapter-date-fns';

// 注册所有组件
Chart.register(...registerables);
Chart.register(zoomPlugin);

// 全局配置
Chart.defaults.font.family = "'Inter', 'Helvetica Neue', 'Arial', sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.color = '#6c757d';
Chart.defaults.responsive = true;
Chart.defaults.maintainAspectRatio = false;

// 自定义颜色方案
Chart.defaults.datasets.bar.backgroundColor = 'rgba(54, 162, 235, 0.6)';
Chart.defaults.datasets.line.borderColor = 'rgba(75, 192, 192, 1)';
Chart.defaults.datasets.line.backgroundColor = 'rgba(75, 192, 192, 0.1)';

export default Chart;

四、ECharts深度集成

4.1 基础图表组件

<template>
  <div class="echarts-container">
    <div class="chart-controls">
      <button @click="switchTheme" class="theme-btn">
        {{ currentTheme === 'light' ? '暗色主题' : '亮色主题' }}
      </button>
      <select v-model="chartType" @change="updateChart" class="type-select">
        <option value="line">折线图</option>
        <option value="bar">柱状图</option>
        <option value="pie">饼图</option>
      </select>
    </div>

    <div ref="chartRef" class="chart-wrapper"></div>

    <div class="chart-info">
      <p>数据点数: {{ dataPoints }}</p>
      <p>渲染时间: {{ renderTime }}ms</p>
      <p>当前主题: {{ currentTheme }}</p>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import echarts from '@/plugins/echarts';

export default {
  name: 'EChartsBasic',
  props: {
    data: {
      type: Array,
      default: () => []
    },
    title: {
      type: String,
      default: '数据图表'
    }
  },
  setup(props) {
    const chartRef = ref(null);
    const chartInstance = ref(null);
    const currentTheme = ref('light');
    const chartType = ref('line');
    const renderTime = ref(0);
    const dataPoints = ref(0);

    // 图表配置
    const getChartOption = (type) => {
      const baseOption = {
        title: {
          text: props.title,
          left: 'center',
          textStyle: {
            fontSize: 16,
            fontWeight: 'bold'
          }
        },
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: ['数据系列'],
          bottom: 10
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '15%',
          top: '15%',
          containLabel: true
        }
      };

      const seriesConfig = {
        line: {
          series: [{
            name: '数据系列',
            type: 'line',
            data: props.data,
            smooth: true,
            symbol: 'circle',
            symbolSize: 6,
            itemStyle: {
              color: '#5470c6'
            },
            lineStyle: {
              width: 3
            }
          }],
          xAxis: {
            type: 'category',
            data: props.data.map((_, index) => `点${index + 1}`)
          },
          yAxis: {
            type: 'value'
          }
        },
        bar: {
          series: [{
            name: '数据系列',
            type: 'bar',
            data: props.data,
            itemStyle: {
              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                { offset: 0, color: '#83bff6' },
                { offset: 1, color: '#188df0' }
              ])
            }
          }],
          xAxis: {
            type: 'category',
            data: props.data.map((_, index) => `项${index + 1}`)
          },
          yAxis: {
            type: 'value'
          }
        },
        pie: {
          series: [{
            name: '数据系列',
            type: 'pie',
            radius: '50%',
            data: props.data.map((value, index) => ({
              value,
              name: `数据${index + 1}`
            })),
            emphasis: {
              itemStyle: {
                shadowBlur: 10,
                shadowOffsetX: 0,
                shadowColor: 'rgba(0, 0, 0, 0.5)'
              }
            }
          }],
          tooltip: {
            trigger: 'item'
          }
        }
      };

      return { ...baseOption, ...seriesConfig[type] };
    };

    // 初始化图表
    const initChart = () => {
      if (!chartRef.value) return;

      const startTime = performance.now();
      
      chartInstance.value = echarts.init(chartRef.value, currentTheme.value);
      chartInstance.value.setOption(getChartOption(chartType.value));

      // 性能监控
      chartInstance.value.on('rendered', () => {
        renderTime.value = Math.round(performance.now() - startTime);
        dataPoints.value = props.data.length;
      });

      // 窗口大小变化时重绘
      const resizeObserver = new ResizeObserver(() => {
        chartInstance.value?.resize();
      });
      resizeObserver.observe(chartRef.value);
    };

    // 更新图表
    const updateChart = () => {
      if (chartInstance.value) {
        chartInstance.value.setOption(getChartOption(chartType.value));
      }
    };

    // 切换主题
    const switchTheme = () => {
      currentTheme.value = currentTheme.value === 'light' ? 'custom-dark' : 'light';
      if (chartInstance.value) {
        chartInstance.value.dispose();
        initChart();
      }
    };

    // 监听数据变化
    watch(() => props.data, () => {
      updateChart();
    }, { deep: true });

    onMounted(() => {
      initChart();
    });

    onUnmounted(() => {
      if (chartInstance.value) {
        chartInstance.value.dispose();
      }
    });

    return {
      chartRef,
      currentTheme,
      chartType,
      renderTime,
      dataPoints,
      switchTheme,
      updateChart
    };
  }
};
</script>

<style scoped>
.echarts-container {
  width: 100%;
  height: 500px;
  position: relative;
}

.chart-controls {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
  align-items: center;
}

.theme-btn {
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.type-select {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.chart-wrapper {
  width: 100%;
  height: 400px;
}

.chart-info {
  margin-top: 10px;
  padding: 10px;
  background: #f8f9fa;
  border-radius: 4px;
  font-size: 14px;
}
</style>

4.2 高级功能实现

<template>
  <div class="advanced-echarts">
    <div class="control-panel">
      <div class="control-group">
        <label>图表类型:</label>
        <select v-model="chartConfig.type" @change="resetZoom">
          <option value="line">折线图</option>
          <option value="bar">柱状图</option>
          <option value="scatter">散点图</option>
        </select>
      </div>

      <div class="control-group">
        <label>数据量:</label>
        <input 
          type="range" 
          v-model="dataSize" 
          min="100" 
          max="10000" 
          step="100"
          @change="generateData"
        >
        <span>{{ dataSize }} 点</span>
      </div>

      <div class="control-group">
        <button @click="exportImage" class="export-btn">导出图片</button>
        <button @click="toggleAnimation" class="animation-btn">
          {{ chartConfig.animation ? '关闭动画' : '开启动画' }}
        </button>
      </div>
    </div>

    <div ref="chartRef" class="chart-container"></div>

    <div class="data-table" v-if="showDataTable">
      <h3>数据预览 (前10行)</h3>
      <table>
        <thead>
          <tr>
            <th>X轴</th>
            <th>Y轴</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in previewData" :key="index">
            <td>{{ item[0].toFixed(2) }}</td>
            <td>{{ item[1].toFixed(2) }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import echarts from '@/plugins/echarts';

export default {
  name: 'AdvancedECharts',
  setup() {
    const chartRef = ref(null);
    const chartInstance = ref(null);
    const dataSize = ref(1000);
    const chartData = ref([]);

    const chartConfig = ref({
      type: 'line',
      animation: true,
      smooth: true,
      showDataZoom: true
    });

    // 生成模拟数据
    const generateData = () => {
      const data = [];
      for (let i = 0; i < dataSize.value; i++) {
        const x = i / 100;
        const y = Math.sin(x) + Math.random() * 0.5;
        data.push([x, y]);
      }
      chartData.value = data;
    };

    // 获取高级图表配置
    const getAdvancedOption = () => {
      const isScatter = chartConfig.value.type === 'scatter';
      
      return {
        title: {
          text: `高级${isScatter ? '散点' : chartConfig.value.type}图`,
          left: 'center'
        },
        tooltip: {
          trigger: isScatter ? 'item' : 'axis',
          formatter: isScatter ? 
            (params) => `X: ${params.value[0].toFixed(2)}<br/>Y: ${params.value[1].toFixed(2)}` :
            null
        },
        dataZoom: chartConfig.value.showDataZoom ? [{
          type: 'inside',
          start: 0,
          end: 100
        }, {
          type: 'slider',
          start: 0,
          end: 100
        }] : [],
        xAxis: {
          type: 'value',
          name: 'X轴',
          nameLocation: 'middle',
          nameGap: 30
        },
        yAxis: {
          type: 'value',
          name: 'Y轴',
          nameLocation: 'middle',
          nameGap: 40
        },
        series: [{
          type: chartConfig.value.type,
          data: chartData.value,
          smooth: chartConfig.value.smooth,
          symbolSize: isScatter ? 4 : 0,
          large: dataSize.value > 2000,
          largeThreshold: 2000,
          animation: chartConfig.value.animation,
          animationDuration: 1000,
          animationEasing: 'cubicOut'
        }],
        graphic: [{
          type: 'text',
          left: 'center',
          top: 'bottom',
          style: {
            text: `数据量: ${dataSize.value} 点`,
            fill: '#999',
            fontSize: 12
          }
        }]
      };
    };

    // 初始化图表
    const initChart = () => {
      if (!chartRef.value) return;

      chartInstance.value = echarts.init(chartRef.value);
      chartInstance.value.setOption(getAdvancedOption());

      // 添加事件监听
      chartInstance.value.on('click', (params) => {
        console.log('图表点击:', params);
      });

      chartInstance.value.on('datazoom', (params) => {
        console.log('数据缩放:', params);
      });

      // 响应式调整
      const resizeChart = () => {
        chartInstance.value?.resize();
      };
      window.addEventListener('resize', resizeChart);
    };

    // 导出图片
    const exportImage = () => {
      if (chartInstance.value) {
        const imageUrl = chartInstance.value.getDataURL({
          type: 'png',
          pixelRatio: 2,
          backgroundColor: '#fff'
        });
        
        const link = document.createElement('a');
        link.href = imageUrl;
        link.download = `chart-${new Date().getTime()}.png`;
        link.click();
      }
    };

    // 重置缩放
    const resetZoom = () => {
      if (chartInstance.value) {
        chartInstance.value.dispatchAction({
          type: 'dataZoom',
          start: 0,
          end: 100
        });
      }
    };

    // 切换动画
    const toggleAnimation = () => {
      chartConfig.value.animation = !chartConfig.value.animation;
      updateChart();
    };

    // 更新图表
    const updateChart = () => {
      if (chartInstance.value) {
        chartInstance.value.setOption(getAdvancedOption());
      }
    };

    // 计算属性
    const previewData = computed(() => {
      return chartData.value.slice(0, 10);
    });

    const showDataTable = computed(() => {
      return dataSize.value <= 1000;
    });

    onMounted(() => {
      generateData();
      initChart();
    });

    onUnmounted(() => {
      if (chartInstance.value) {
        chartInstance.value.dispose();
      }
    });

    return {
      chartRef,
      chartConfig,
      dataSize,
      previewData,
      showDataTable,
      generateData,
      exportImage,
      resetZoom,
      toggleAnimation
    };
  }
};
</script>

<style scoped>
.advanced-echarts {
  width: 100%;
  height: 600px;
}

.control-panel {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
  flex-wrap: wrap;
}

.control-group {
  display: flex;
  align-items: center;
  gap: 10px;
}

.control-group label {
  font-weight: bold;
  min-width: 60px;
}

.chart-container {
  width: 100%;
  height: 400px;
  border: 1px solid #e9ecef;
  border-radius: 8px;
}

.data-table {
  margin-top: 20px;
  max-height: 200px;
  overflow-y: auto;
}

.data-table table {
  width: 100%;
  border-collapse: collapse;
}

.data-table th,
.data-table td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: center;
}

.data-table th {
  background: #f8f9fa;
}

.export-btn, .animation-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.export-btn {
  background: #28a745;
  color: white;
}

.animation-btn {
  background: #17a2b8;
  color: white;
}
</style>

五、Chart.js深度集成

5.1 基础图表组件

<template>
  <div class="chartjs-container">
    <div class="chart-header">
      <h3>{{ title }}</h3>
      <div class="chart-actions">
        <button @click="toggleDataset" class="action-btn">
          {{ showDataset2 ? '隐藏数据集2' : '显示数据集2' }}
        </button>
        <button @click="randomizeData" class="action-btn">随机数据</button>
      </div>
    </div>

    <div class="chart-wrapper">
      <canvas ref="chartCanvas"></canvas>
    </div>

    <div class="chart-stats">
      <div class="stat-item">
        <span class="stat-label">数据点数:</span>
        <span class="stat-value">{{ totalDataPoints }}</span>
      </div>
      <div class="stat-item">
        <span class="stat-label更新时间:</span>
        <span class="stat-value">{{ lastUpdate }}</span>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import Chart from '@/plugins/chartjs';

export default {
  name: 'ChartJSBasic',
  props: {
    title: {
      type: String,
      default: 'Chart.js 图表'
    },
    initialData: {
      type: Array,
      default: () => [12, 19, 3, 5, 2, 3]
    }
  },
  setup(props) {
    const chartCanvas = ref(null);
    const chartInstance = ref(null);
    const showDataset2 = ref(false);
    const totalDataPoints = ref(0);
    const lastUpdate = ref('');

    // 图表数据
    const chartData = ref({
      labels: ['一月', '二月', '三月', '四月', '五月', '六月'],
      datasets: [
        {
          label: '数据集 1',
          data: [...props.initialData],
          backgroundColor: 'rgba(75, 192, 192, 0.6)',
          borderColor: 'rgba(75, 192, 192, 1)',
          borderWidth: 2,
          tension: 0.4
        },
        {
          label: '数据集 2',
          data: [8, 15, 7, 12, 6, 10],
          backgroundColor: 'rgba(255, 99, 132, 0.6)',
          borderColor: 'rgba(255, 99, 132, 1)',
          borderWidth: 2,
          tension: 0.4,
          hidden: true
        }
      ]
    });

    // 图表配置
    const chartOptions = {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          position: 'top',
          labels: {
            usePointStyle: true,
            padding: 20
          }
        },
        tooltip: {
          mode: 'index',
          intersect: false,
          backgroundColor: 'rgba(0, 0, 0, 0.8)',
          titleColor: '#fff',
          bodyColor: '#fff',
          borderColor: 'rgba(255, 255, 255, 0.1)',
          borderWidth: 1
        }
      },
      scales: {
        x: {
          grid: {
            color: 'rgba(0, 0, 0, 0.1)'
          }
        },
        y: {
          beginAtZero: true,
          grid: {
            color: 'rgba(0, 0, 0, 0.1)'
          }
        }
      },
      animation: {
        duration: 1000,
        easing: 'easeOutQuart'
      },
      interaction: {
        mode: 'nearest',
        axis: 'x',
        intersect: false
      }
    };

    // 初始化图表
    const initChart = async () => {
      await nextTick();
      
      if (!chartCanvas.value) return;

      const ctx = chartCanvas.value.getContext('2d');
      chartInstance.value = new Chart(ctx, {
        type: 'line',
        data: chartData.value,
        options: chartOptions
      });

      updateStats();
    };

    // 切换数据集显示
    const toggleDataset = () => {
      showDataset2.value = !showDataset2.value;
      
      if (chartInstance.value) {
        const meta = chartInstance.value.getDatasetMeta(1);
        meta.hidden = !showDataset2.value;
        chartInstance.value.update();
      }
    };

    // 随机生成数据
    const randomizeData = () => {
      if (chartInstance.value) {
        chartData.value.datasets.forEach(dataset => {
          dataset.data = dataset.data.map(() => 
            Math.floor(Math.random() * 20) + 1
          );
        });
        
        chartInstance.value.update();
        updateStats();
      }
    };

    // 更新统计信息
    const updateStats = () => {
      totalDataPoints.value = chartData.value.datasets.reduce((total, dataset) => {
        return total + (dataset.hidden ? 0 : dataset.data.length);
      }, 0);
      
      lastUpdate.value = new Date().toLocaleTimeString();
    };

    // 监听数据变化
    watch(() => props.initialData, (newData) => {
      if (chartInstance.value) {
        chartData.value.datasets[0].data = [...newData];
        chartInstance.value.update();
        updateStats();
      }
    });

    onMounted(() => {
      initChart();
    });

    onUnmounted(() => {
      if (chartInstance.value) {
        chartInstance.value.destroy();
      }
    });

    return {
      chartCanvas,
      showDataset2,
      totalDataPoints,
      lastUpdate,
      toggleDataset,
      randomizeData
    };
  }
};
</script>

<style scoped>
.chartjs-container {
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.chart-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.chart-header h3 {
  margin: 0;
  color: #333;
}

.chart-actions {
  display: flex;
  gap: 10px;
}

.action-btn {
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.3s;
}

.action-btn:hover {
  background: #0056b3;
}

.chart-wrapper {
  position: relative;
  height: 400px;
  margin-bottom: 20px;
}

.chart-stats {
  display: flex;
  gap: 20px;
  padding-top: 15px;
  border-top: 1px solid #e9ecef;
}

.stat-item {
  display: flex;
  align-items: center;
  gap: 8px;
}

.stat-label {
  font-weight: bold;
  color: #6c757d;
}

.stat-value {
  color: #495057;
  font-family: 'Courier New', monospace;
}
</style>

5.2 高级功能实现

<template>
  <div class="advanced-chartjs">
    <div class="control-panel">
      <div class="control-group">
        <label>图表类型:</label>
        <select v-model="chartType" @change="updateChartType">
          <option value="line">折线图</option>
          <option value="bar">柱状图</option>
          <option value="radar">雷达图</option>
          <option value="doughnut">环形图</option>
        </select>
      </div>

      <div class="control-group">
        <label>数据量:</label>
        <input 
          type="range" 
          v-model="dataPoints" 
          min="10" 
          max="1000" 
          @input="generateData"
        >
        <span>{{ dataPoints }}点</span>
      </div>

      <div class="control-group">
        <label>
          <input type="checkbox" v-model="options.plugins.zoom.zoom.enabled">
          启用缩放
        </label>
        <label>
          <input type="checkbox" v-model="options.plugins.zoom.pan.enabled">
          启用平移
        </label>
      </div>
    </div>

    <div class="chart-area">
      <canvas ref="chartCanvas"></canvas>
    </div>

    <div class="interaction-panel">
      <div class="interaction-info">
        <h4>交互信息</h4>
        <p>点击点: {{ clickedPoint || '无' }}</p>
        <p>悬停点: {{ hoveredPoint || '无' }}</p>
        <p>缩放级别: {{ zoomLevel }}%</p>
      </div>

      <div class="action-buttons">
        <button @click="resetZoom" class="btn">重置缩放</button>
        <button @click="exportChart" class="btn">导出图表</button>
        <button @click="toggleAnimation" class="btn">
          {{ options.animation ? '关闭动画' : '开启动画' }}
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import Chart from '@/plugins/chartjs';

export default {
  name: 'AdvancedChartJS',
  setup() {
    const chartCanvas = ref(null);
    const chartInstance = ref(null);
    const chartType = ref('line');
    const dataPoints = ref(100);
    const clickedPoint = ref(null);
    const hoveredPoint = ref(null);
    const zoomLevel = ref(100);

    // 动态配置
    const options = ref({
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          position: 'top'
        },
        tooltip: {
          mode: 'index',
          intersect: false
        },
        zoom: {
          zoom: {
            wheel: {
              enabled: true
            },
            pinch: {
              enabled: true
            },
            mode: 'xy',
            onZoom: ({ chart }) => {
              zoomLevel.value = Math.round(chart.getZoomLevel() * 100);
            }
          },
          pan: {
            enabled: true,
            mode: 'xy'
          }
        }
      },
      animation: {
        duration: 1000,
        easing: 'easeOutQuart'
      },
      interaction: {
        intersect: false,
        mode: 'nearest'
      },
      scales: {
        x: {
          type: 'linear',
          position: 'bottom'
        },
        y: {
          beginAtZero: true
        }
      }
    });

    // 生成数据
    const generateData = () => {
      const data = [];
      for (let i = 0; i < dataPoints.value; i++) {
        const x = i;
        const y = Math.sin(i * 0.1) * 50 + Math.random() * 20;
        data.push({ x, y });
      }
      return data;
    };

    // 图表数据
    const chartData = ref({
      datasets: [{
        label: '动态数据集',
        data: generateData(),
        borderColor: 'rgb(75, 192, 192)',
        backgroundColor: 'rgba(75, 192, 192, 0.2)',
        tension: 0.4,
        pointRadius: 3,
        pointHoverRadius: 6
      }]
    });

    // 初始化图表
    const initChart = () => {
      if (!chartCanvas.value) return;

      const ctx = chartCanvas.value.getContext('2d');
      
      chartInstance.value = new Chart(ctx, {
        type: chartType.value,
        data: chartData.value,
        options: options.value
      });

      // 添加事件监听
      chartCanvas.value.addEventListener('click', (event) => {
        const points = chartInstance.value.getElementsAtEventForMode(
          event, 'nearest', { intersect: true }, true
        );
        
        if (points.length) {
          const point = points[0];
          const value = chartData.value.datasets[point.datasetIndex].data[point.index];
          clickedPoint.value = `(${value.x}, ${value.y.toFixed(2)})`;
        }
      });

      chartCanvas.value.addEventListener('mousemove', (event) => {
        const points = chartInstance.value.getElementsAtEventForMode(
          event, 'nearest', { intersect: true }, false
        );
        
        if (points.length) {
          const point = points[0];
          const value = chartData.value.datasets[point.datasetIndex].data[point.index];
          hoveredPoint.value = `(${value.x}, ${value.y.toFixed(2)})`;
        } else {
          hoveredPoint.value = null;
        }
      });
    };

    // 更新图表类型
    const updateChartType = () => {
      if (chartInstance.value) {
        chartInstance.value.destroy();
        initChart();
      }
    };

    // 重置缩放
    const resetZoom = () => {
      if (chartInstance.value) {
        chartInstance.value.resetZoom();
        zoomLevel.value = 100;
      }
    };

    // 导出图表
    const exportChart = () => {
      if (chartInstance.value) {
        const imageUrl = chartInstance.value.toBase64Image();
        const link = document.createElement('a');
        link.href = imageUrl;
        link.download = `chartjs-${new Date().getTime()}.png`;
        link.click();
      }
    };

    // 切换动画
    const toggleAnimation = () => {
      options.value.animation = !options.value.animation;
      if (chartInstance.value) {
        chartInstance.value.update();
      }
    };

    // 监听数据点变化
    watch(dataPoints, () => {
      chartData.value.datasets[0].data = generateData();
      if (chartInstance.value) {
        chartInstance.value.update();
      }
    });

    onMounted(() => {
      initChart();
    });

    onUnmounted(() => {
      if (chartInstance.value) {
        chartInstance.value.destroy();
      }
    });

    return {
      chartCanvas,
      chartType,
      dataPoints,
      options,
      clickedPoint,
      hoveredPoint,
      zoomLevel,
      updateChartType,
      generateData,
      resetZoom,
      exportChart,
      toggleAnimation
    };
  }
};
</script>

<style scoped>
.advanced-chartjs {
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding: 20px;
}

.control-panel {
  display: flex;
  gap: 30px;
  margin-bottom: 20px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
  flex-wrap: wrap;
}

.control-group {
  display: flex;
  align-items: center;
  gap: 10px;
}

.control-group label {
  font-weight: bold;
  white-space: nowrap;
}

.chart-area {
  position: relative;
  height: 500px;
  margin-bottom: 20px;
  border: 1px solid #e9ecef;
  border-radius: 8px;
  background: white;
}

.interaction-panel {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}

.interaction-info h4 {
  margin: 0 0 10px 0;
  color: #495057;
}

.interaction-info p {
  margin: 5px 0;
  font-family: 'Courier New', monospace;
}

.action-buttons {
  display: flex;
  gap: 10px;
}

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}

.btn:nth-child(1) { background: #dc3545; color: white; }
.btn:nth-child(2) { background: #28a745; color: white; }
.btn:nth-child(3) { background: #17a2b8; color: white; }
</style>

六、性能优化与最佳实践

6.1 大数据量优化策略

// src/utils/chart-optimization.js
export class ChartPerformanceOptimizer {
    constructor() {
        this.optimizationStrategies = {
            '数据采样': this.dataSampling,
            '虚拟渲染': this.virtualRendering,
            '分级渲染': this.levelOfDetailRendering,
            'Web Workers': this.webWorkerProcessing
        };
    }

    // 数据采样策略
    dataSampling(data, maxPoints = 1000) {
        if (data.length <= maxPoints) return data;

        const samplingRatio = Math.ceil(data.length / maxPoints);
        const sampledData = [];

        for (let i = 0; i < data.length; i += samplingRatio) {
            sampledData.push(data[i]);
        }

        console.log(`数据采样: ${data.length} -> ${sampledData.length} 点`);
        return sampledData;
    }

    // 虚拟渲染(仅渲染可见区域)
    virtualRendering(chartInstance, visibleRange) {
        if (!chartInstance || !visibleRange) return;

        const { start, end } = visibleRange;
        const fullData = chartInstance.getOption().series[0].data;
        const visibleData = fullData.slice(start, end);

        chartInstance.setOption({
            series: [{
                data: visibleData
            }]
        });
    }

    // 分级细节渲染
    levelOfDetailRendering(data, zoomLevel) {
        const detailLevels = {
            'high': 1,      // 原样显示
            'medium': 10,   // 每10个点取1个
            'low': 50       // 每50个点取1个
        };

        const samplingRate = detailLevels[zoomLevel] || detailLevels.medium;
        return data.filter((_, index) => index % samplingRate === 0);
    }

    // Web Worker 数据处理
    async processDataInWorker(data, processingFunction) {
        if (!window.Worker) {
            // 降级到主线程处理
            return processingFunction(data);
        }

        const worker = new Worker('/js/data-processor.js');
        
        return new Promise((resolve, reject) => {
            worker.onmessage = (e) => {
                resolve(e.data);
                worker.terminate();
            };
            
            worker.onerror = (error) => {
                reject(error);
                worker.terminate();
            };
            
            worker.postMessage({ data, function: processingFunction.name });
        });
    }
}

// 内存管理
export class ChartMemoryManager {
    constructor() {
        this.chartInstances = new Map();
        this.maxInstances = 10; // 最大缓存实例数
    }

    // 缓存图表实例
    cacheChartInstance(key, chartInstance) {
        if (this.chartInstances.size >= this.maxInstances) {
            // LRU策略:移除最久未使用的
            const firstKey = this.chartInstances.keys().next().value;
            this.destroyChartInstance(firstKey);
        }

        this.chartInstances.set(key, {
            instance: chartInstance,
            lastUsed: Date.now()
        });
    }

    // 获取缓存实例
    getChartInstance(key) {
        const cached = this.chartInstances.get(key);
        if (cached) {
            cached.lastUsed = Date.now();
            return cached.instance;
        }
        return null;
    }

    // 销毁实例
    destroyChartInstance(key) {
        const cached = this.chartInstances.get(key);
        if (cached) {
            cached.instance.dispose?.(); // ECharts
            cached.instance.destroy?.(); // Chart.js
            this.chartInstances.delete(key);
        }
    }

    // 清理过期实例
    cleanupExpiredInstances(maxAge = 300000) { // 5分钟
        const now = Date.now();
        for (const [key, cached] of this.chartInstances.entries()) {
            if (now - cached.lastUsed > maxAge) {
                this.destroyChartInstance(key);
            }
        }
    }
}

6.2 响应式设计优化

<template>
  <div class="responsive-chart-container">
    <div class="chart-responsive" ref="chartContainer">
      <div class="chart-aspect-ratio">
        <component
          :is="chartComponent"
          :key="chartKey"
          :data="chartData"
          :options="chartOptions"
          @chart-ready="onChartReady"
        />
      </div>
    </div>
    
    <div class="responsive-controls">
      <label>宽高比:</label>
      <select v-model="aspectRatio" @change="updateAspectRatio">
        <option value="16/9">16:9</option>
        <option value="4/3">4:3</option>
        <option value="1/1">1:1</option>
        <option value="auto">自动</option>
      </select>
      
      <label>断点适应:</label>
      <select v-model="breakpointStrategy">
        <option value="responsive">完全响应式</option>
        <option value="breakpoints">断点适配</option>
      </select>
    </div>
  </div>
</template>

<script>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ChartMemoryManager } from '@/utils/chart-optimization';

export default {
  name: 'ResponsiveChart',
  props: {
    chartType: {
      type: String,
      default: 'line'
    },
    initialData: Array
  },
  setup(props) {
    const chartContainer = ref(null);
    const aspectRatio = ref('16/9');
    const breakpointStrategy = ref('responsive');
    const chartInstance = ref(null);
    const memoryManager = new ChartMemoryManager();

    // 响应式图表配置
    const chartOptions = computed(() => {
      const baseOptions = {
        responsive: true,
        maintainAspectRatio: false
      };

      // 根据断点策略调整配置
      if (breakpointStrategy.value === 'breakpoints') {
        return {
          ...baseOptions,
          plugins: {
            legend: {
              position: window.innerWidth < 768 ? 'bottom' : 'top'
            }
          }
        };
      }

      return baseOptions;
    });

    // 计算宽高比样式
    const aspectRatioStyle = computed(() => {
      if (aspectRatio.value === 'auto') return {};
      
      const [width, height] = aspectRatio.value.split('/').map(Number);
      const paddingBottom = (height / width) * 100;
      
      return {
        paddingBottom: `${paddingBottom}%`
      };
    });

    // 更新宽高比
    const updateAspectRatio = () => {
      // 强制重新渲染图表
      chartKey.value++;
    };

    // 处理图表就绪
    const onChartReady = (instance) => {
      chartInstance.value = instance;
      memoryManager.cacheChartInstance('current', instance);
    };

    // 响应式调整
    const handleResize = () => {
      if (chartInstance.value) {
        chartInstance.value.resize?.();
      }
    };

    // 断点检测
    const currentBreakpoint = computed(() => {
      const width = window.innerWidth;
      if (width < 576) return 'xs';
      if (width < 768) return 'sm';
      if (width < 992) return 'md';
      if (width < 1200) return 'lg';
      return 'xl';
    });

    onMounted(() => {
      window.addEventListener('resize', handleResize);
    });

    onUnmounted(() => {
      window.removeEventListener('resize', handleResize);
      memoryManager.destroyChartInstance('current');
    });

    return {
      chartContainer,
      aspectRatio,
      breakpointStrategy,
      chartOptions,
      aspectRatioStyle,
      updateAspectRatio,
      onChartReady,
      currentBreakpoint
    };
  }
};
</script>

<style scoped>
.responsive-chart-container {
  width: 100%;
  max-width: 100%;
}

.chart-responsive {
  position: relative;
  width: 100%;
}

.chart-aspect-ratio {
  position: relative;
  width: 100%;
  height: 0;
}

.chart-aspect-ratio > * {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.responsive-controls {
  display: flex;
  gap: 15px;
  align-items: center;
  margin-top: 15px;
  padding: 10px;
  background: #f8f9fa;
  border-radius: 4px;
}

.responsive-controls label {
  font-weight: bold;
  white-space: nowrap;
}

.responsive-controls select {
  padding: 5px 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

/* 断点特定样式 */
@media (max-width: 576px) {
  .responsive-controls {
    flex-direction: column;
    align-items: stretch;
  }
}

@media (max-width: 768px) {
  .chart-responsive {
    margin: 0 -10px;
    width: calc(100% + 20px);
  }
}
</style>

七、实际应用案例

7.1 仪表盘综合案例

<template>
  <div class="dashboard-container">
    <div class="dashboard-header">
      <h1>业务数据仪表盘</h1>
      <div class="dashboard-controls">
        <date-range-picker v-model="dateRange" />
        <refresh-button @refresh="refreshData" :loading="loading" />
        <export-button @export="exportDashboard" />
      </div>
    </div>

    <div class="dashboard-grid">
      <!-- KPI 指标卡 -->
      <div class="kpi-cards">
        <kpi-card 
          v-for="kpi in kpiData" 
          :key="kpi.id"
          :title="kpi.title"
          :value="kpi.value"
          :trend="kpi.trend"
          :change="kpi.change"
        />
      </div>

      <!-- 主要图表区域 -->
      <div class="main-charts">
        <div class="chart-row">
          <div class="chart-container">
            <h3>销售趋势</h3>
            <e-charts-line 
              :data="salesData"
              :options="salesChartOptions"
              height="300px"
            />
          </div>
          
          <div class="chart-container">
            <h3>用户分布</h3>
            <chart-js-pie 
              :data="userDistributionData"
              :options="pieChartOptions"
              height="300px"
            />
          </div>
        </div>

        <div class="chart-row">
          <div class="chart-container full-width">
            <h3>实时监控</h3>
            <e-charts-realtime 
              :data="realtimeData"
              :options="realtimeOptions"
              height="250px"
            />
          </div>
        </div>
      </div>

      <!-- 侧边栏图表 -->
      <div class="sidebar-charts">
        <div class="sidebar-chart">
          <h4>转化率</h4>
          <chart-js-gauge 
            :value="conversionRate"
            :max="100"
            height="150px"
          />
        </div>
        
        <div class="sidebar-chart">
          <h4>性能指标</h4>
          <e-charts-radar 
            :data="performanceMetrics"
            :options="radarOptions"
            height="200px"
          />
        </div>
      </div>
    </div>

    <!-- 数据表格 -->
    <div class="data-table-section">
      <h3>详细数据</h3>
      <data-table 
        :data="tableData"
        :columns="tableColumns"
        @row-click="handleRowClick"
      />
    </div>
  </div>
</template>

<script>
import { ref, onMounted, computed } from 'vue';
import { useDashboardData } from '@/composables/useDashboardData';

export default {
  name: 'BusinessDashboard',
  setup() {
    const {
      dateRange,
      loading,
      kpiData,
      salesData,
      userDistributionData,
      realtimeData,
      performanceMetrics,
      tableData,
      refreshData,
      exportDashboard
    } = useDashboardData();

    // 图表配置
    const salesChartOptions = ref({
      title: { text: '销售趋势分析' },
      tooltip: { trigger: 'axis' },
      legend: { data: ['销售额', '订单量'] },
      grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }
    });

    const pieChartOptions = ref({
      responsive: true,
      plugins: {
        legend: { position: 'right' }
      }
    });

    const realtimeOptions = ref({
      animation: false,
      dataZoom: [{
        type: 'inside',
        realtime: true
      }]
    });

    const radarOptions = ref({
      radar: {
        indicator: [
          { name: '响应时间', max: 1000 },
          { name: '可用性', max: 100 },
          { name: '吞吐量', max: 1000 },
          { name: '错误率', max: 10 }
        ]
      }
    });

    // 计算属性
    const conversionRate = computed(() => {
      return kpiData.value.find(kpi => kpi.id === 'conversion')?.value || 0;
    });

    const tableColumns = ref([
      { key: 'date', title: '日期' },
      { key: 'sales', title: '销售额' },
      { key: 'orders', title: '订单数' },
      { key: 'users', title: '用户数' },
      { key: 'conversion', title: '转化率' }
    ]);

    // 事件处理
    const handleRowClick = (row) => {
      console.log('行点击:', row);
      // 可以在这里实现钻取功能
    };

    onMounted(() => {
      refreshData();
    });

    return {
      dateRange,
      loading,
      kpiData,
      salesData,
      userDistributionData,
      realtimeData,
      performanceMetrics,
      tableData,
      salesChartOptions,
      pieChartOptions,
      realtimeOptions,
      radarOptions,
      conversionRate,
      tableColumns,
      refreshData,
      exportDashboard,
      handleRowClick
    };
  }
};
</script>

<style scoped>
.dashboard-container {
  padding: 20px;
  background: #f5f5f5;
  min-height: 100vh;
}

.dashboard-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.dashboard-controls {
  display: flex;
  gap: 15px;
  align-items: center;
}

.dashboard-grid {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 20px;
  margin-bottom: 30px;
}

.kpi-cards {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
  margin-bottom: 20px;
}

.main-charts {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.chart-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.chart-container {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.chart-container.full-width {
  grid-column: 1 / -1;
}

.chart-container h3 {
  margin: 0 0 15px 0;
  color: #333;
  font-size: 16px;
}

.sidebar-charts {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.sidebar-chart {
  background: white;
  padding: 15px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.sidebar-chart h4 {
  margin: 0 0 10px 0;
  color: #333;
  font-size: 14px;
}

.data-table-section {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

@media (max-width: 1200px) {
  .dashboard-grid {
    grid-template-columns: 1fr;
  }
  
  .sidebar-charts {
    grid-column: 1 / -1;
    flex-direction: row;
  }
}

@media (max-width: 768px) {
  .chart-row {
    grid-template-columns: 1fr;
  }
  
  .sidebar-charts {
    flex-direction: column;
  }
  
  .dashboard-header {
    flex-direction: column;
    gap: 15px;
    align-items: stretch;
  }
}
</style>

八、测试与质量保证

8.1 图表组件测试

// tests/unit/chart-components.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';

// ECharts 组件测试
describe('ECharts 组件', () => {
  let wrapper;
  
  beforeEach(() => {
    // 模拟 ECharts
    global.echarts = {
      init: vi.fn(() => ({
        setOption: vi.fn(),
        dispose: vi.fn(),
        resize: vi.fn(),
        on: vi.fn()
      })),
      registerTheme: vi.fn()
    };
  });

  it('应该正确初始化图表', async () => {
    wrapper = mount(EChartsBasic, {
      props: {
        data: [10, 20, 30],
        title: '测试图表'
      }
    });

    await nextTick();
    
    expect(echarts.init).toHaveBeenCalled();
    expect(wrapper.find('.chart-wrapper').exists()).toBe(true);
  });

  it('应该响应数据变化', async () => {
    wrapper = mount(EChartsBasic, {
      props: {
        data: [10, 20, 30]
      }
    });

    await wrapper.setProps({ data: [40, 50, 60] });
    await nextTick();
    
    // 验证图表更新逻辑
    expect(wrapper.vm.chartInstance.setOption).toHaveBeenCalled();
  });

  it('应该处理窗口大小变化', async () => {
    wrapper = mount(EChartsBasic, {
      props: { data: [10, 20, 30] }
    });

    await nextTick();
    
    // 模拟窗口大小变化
    window.dispatchEvent(new Event('resize'));
    
    // 验证 resize 被调用
    expect(wrapper.vm.chartInstance.resize).toHaveBeenCalled();
  });
});

// Chart.js 组件测试
describe('Chart.js 组件', () => {
  it('应该正确渲染 Canvas', async () => {
    const wrapper = mount(ChartJSBasic, {
      props: {
        initialData: [12, 19, 3, 5, 2, 3]
      }
    });

    await nextTick();
    
    expect(wrapper.find('canvas').exists()).toBe(true);
    expect(wrapper.vm.chartInstance).toBeTruthy();
  });

  it('应该处理交互事件', async () => {
    const wrapper = mount(ChartJSBasic);
    await nextTick();

    // 模拟点击事件
    const canvas = wrapper.find('canvas').element;
    const clickEvent = new Event('click');
    canvas.dispatchEvent(clickEvent);

    // 验证事件处理逻辑
    expect(wrapper.vm.handleChartClick).toHaveBeenCalled();
  });
});

// 性能测试
describe('图表性能测试', () => {
  it('应该高效处理大数据集', async () => {
    const largeData = Array(10000).fill().map((_, i) => ({
      x: i,
      y: Math.sin(i * 0.01)
    }));

    const startTime = performance.now();
    
    const wrapper = mount(EChartsBasic, {
      props: { data: largeData }
    });

    await nextTick();
    
    const renderTime = performance.now() - startTime;
    
    // 验证渲染时间在合理范围内
    expect(renderTime).toBeLessThan(1000);
    
    // 验证使用了大数据优化模式
    expect(wrapper.vm.chartInstance.setOption)
      .toHaveBeenCalledWith(expect.objectContaining({
        series: expect.arrayContaining([
          expect.objectContaining({
            large: true
          })
        ])
      }));
  });

  it('应该正确管理内存', async () => {
    const wrapper = mount(EChartsBasic, {
      props: { data: [1, 2, 3] }
    });

    await nextTick();
    
    // 卸载组件
    wrapper.unmount();
    
    // 验证资源被正确释放
    expect(wrapper.vm.chartInstance.dispose).toHaveBeenCalled();
  });
});

// 可访问性测试
describe('图表可访问性', () => {
  it('应该提供适当的ARIA标签', async () => {
    const wrapper = mount(EChartsBasic, {
      props: {
        data: [10, 20, 30],
        title: '可访问性测试图表'
      }
    });

    await nextTick();
    
    const chartContainer = wrapper.find('.chart-wrapper');
    expect(chartContainer.attributes('role')).toBe('img');
    expect(chartContainer.attributes('aria-label')).toContain('可访问性测试图表');
  });

  it('应该支持键盘导航', async () => {
    const wrapper = mount(ChartJSBasic);
    await nextTick();

    const canvas = wrapper.find('canvas');
    expect(canvas.attributes('tabindex')).toBe('0');
    
    // 测试键盘事件
    await canvas.trigger('keydown.enter');
    expect(wrapper.vm.handleKeyNavigation).toHaveBeenCalled();
  });
});

8.2 集成测试

// tests/e2e/dashboard.spec.js
import { test, expect } from '@playwright/test';

test.describe('仪表盘端到端测试', () => {
  test('应该正确加载和显示图表', async ({ page }) => {
    await page.goto('/dashboard');
    
    // 验证图表容器存在
    await expect(page.locator('.chart-container')).toBeVisible();
    
    // 验证图表正确渲染
    await expect(page.locator('canvas')).toBeVisible();
    
    // 验证数据加载
    await expect(page.locator('.kpi-card')).toHaveCount(4);
  });

  test('应该响应交互操作', async ({ page }) => {
    await page.goto('/dashboard');
    
    // 测试图表点击
    await page.locator('canvas').first().click();
    
    // 验证交互反馈
    await expect(page.locator('.tooltip')).toBeVisible();
    
    // 测试数据刷新
    await page.click('button:has-text("刷新")');
    await expect(page.locator('.loading-indicator')).toBeVisible();
    
    // 等待数据加载完成
    await expect(page.locator('.loading-indicator')).not.toBeVisible();
  });

  test('应该适应不同屏幕尺寸', async ({ page }) => {
    await page.goto('/dashboard');
    
    // 桌面端布局验证
    await page.setViewportSize({ width: 1200, height: 800 });
    await expect(page.locator('.chart-row')).toHaveCSS('grid-template-columns', '1fr 1fr');
    
    // 平板端布局验证
    await page.setViewportSize({ width: 768, height: 1024 });
    await expect(page.locator('.chart-row')).toHaveCSS('grid-template-columns', '1fr');
    
    // 移动端布局验证
    await page.setViewportSize({ width: 375, height: 667 });
    await expect(page.locator('.sidebar-charts')).toHaveCSS('flex-direction', 'column');
  });

  test('应该处理数据加载错误', async ({ page }) => {
    await page.goto('/dashboard');
    
    // 模拟API错误
    await page.route('/api/dashboard-data', route => {
      route.fulfill({
        status: 500,
        body: JSON.stringify({ error: '服务器错误' })
      });
    });
    
    // 触发数据刷新
    await page.click('button:has-text("刷新")');
    
    // 验证错误处理
    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('加载失败');
  });
});

九、部署与生产优化

9.1 生产环境配置

// src/config/chart-config.js
export const productionChartConfig = {
  echarts: {
    // 生产环境优化配置
    useDirtyRect: true, // 启用脏矩形优化
    renderer: 'canvas',  // 生产环境使用Canvas
    lazyUpdate: true,   // 懒更新
    throttle: 100,      // 事件节流
    devicePixelRatio: window.devicePixelRatio || 1
  },
  
  chartjs: {
    // Chart.js生产配置
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        display: true,
        position: 'top'
      }
    },
    animation: {
      duration: 1000,
      easing: 'easeOutQuart'
    }
  },
  
  performance: {
    // 性能优化配置
    dataSampling: {
      enabled: true,
      threshold: 5000    // 超过5000点启用采样
    },
    virtualRendering: {
      enabled: true,
      viewportMargin: 2 // 视口外2屏缓存
    },
    cache: {
      enabled: true,
      maxSize: 50        // 最大缓存图表数
    }
  },
  
  monitoring: {
    // 监控配置
    enablePerformanceTracking: true,
    logRenderTimes: true,
    errorTracking: true
  }
};

// 环境特定配置
export const getChartConfig = () => {
  const env = process.env.NODE_ENV;
  
  const configs = {
    development: {
      debug: true,
      animation: true,
      devTooltip: true
    },
    production: productionChartConfig,
    test: {
      debug: false,
      animation: false
    }
  };
  
  return configs[env] || configs.development;
};

9.2 代码分割与懒加载

// src/utils/lazy-loading.js
export class ChartLazyLoader {
  constructor() {
    this.loadedCharts = new Set();
    this.loadingPromises = new Map();
  }

  // 懒加载ECharts
  async loadECharts(features = ['core', 'charts', 'components']) {
    if (this.loadedCharts.has('echarts')) {
      return import('echarts');
    }

    if (this.loadingPromises.has('echarts')) {
      return this.loadingPromises.get('echarts');
    }

    const loadPromise = (async () => {
      const echarts = await import('echarts/core');
      
      // 按需加载功能模块
      const loaders = features.map(feature => {
        switch (feature) {
          case 'charts':
            return import('echarts/charts');
          case 'components':
            return import('echarts/components');
          case 'renderers':
            return import('echarts/renderers');
          default:
            return Promise.resolve();
        }
      });

      await Promise.all(loaders);
      this.loadedCharts.add('echarts');
      
      return echarts;
    })();

    this.loadingPromises.set('echarts', loadPromise);
    return loadPromise;
  }

  // 懒加载Chart.js
  async loadChartJS() {
    if (this.loadedCharts.has('chartjs')) {
      return import('chart.js/auto');
    }

    const { default: Chart } = await import('chart.js/auto');
    this.loadedCharts.add('chartjs');
    return Chart;
  }

  // 预加载常用图表
  preloadCommonCharts() {
    if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
      window.requestIdleCallback(() => {
        this.loadECharts(['core', 'charts']);
        this.loadChartJS();
      });
    }
  }
}

// 使用示例
export const createLazyChart = (chartType, container, options) => {
  const loader = new ChartLazyLoader();
  
  return loader.loadECharts().then(echarts => {
    const chart = echarts.init(container);
    chart.setOption(options);
    return chart;
  });
};

十、总结与最佳实践

10.1 技术选择指南

ECharts 最佳实践

const eChartsBestPractices = {
  适用场景: [
    '复杂业务数据可视化',
    '需要丰富图表类型的企业应用',
    '大数据量渲染需求',
    '需要高度定制化的图表'
  ],
  性能优化: [
    '使用canvas渲染器处理大数据',
    '启用脏矩形优化(dirtyRect)',
    '对大数据集进行采样或分片加载',
    '使用WebGL渲染3D图表'
  ],
  开发建议: [
    '按需引入模块减少包大小',
    '使用主题统一图表样式',
    '合理配置动画避免性能问题',
    '实现错误边界处理渲染异常'
  ]
};

Chart.js 最佳实践

const chartJsBestPractices = {
  适用场景: [
    '轻量级应用和移动端',
    '快速原型开发和简单报表',
    '对包大小敏感的项目',
    '团队技术栈简单的场景'
  ],
  性能优化: [
    '使用tree shaking仅引入需要的图表',
    '合理配置动画和交互选项',
    '对大量数据点进行降采样',
    '使用decimation插件优化性能'
  ],
  开发建议: [
    '统一配置全局默认样式',
    '使用TypeScript增强类型安全',
    '实现响应式设计适配多设备',
    '编写自定义插件扩展功能'
  ]
};

10.2 性能优化总结

图表性能优化策略

const performanceOptimizationStrategies = {
  数据层面: [
    '实施数据采样和聚合',
    '使用虚拟滚动和懒加载',
    '实现数据分级显示(LOD)',
    '优化数据格式减少传输'
  ],
  渲染层面: [
    '选择合适的渲染器(Canvas/SVG/WebGL)',
    '启用硬件加速和GPU渲染',
    '使用离屏Canvas预渲染',
    '优化重绘和回流触发'
  ],
  内存管理: [
    '及时销毁不再使用的图表实例',
    '实施图表实例缓存和复用',
    '监控内存使用防止泄漏',
    '使用Web Worker处理复杂计算'
  ],
  网络优化: [
    '实施代码分割和懒加载',
    '使用CDN加速资源加载',
    '开启Gzip压缩减少传输',
    '使用HTTP/2多路复用'
  ]
};

10.3 未来发展趋势

图表技术演进方向

const futureTrends = {
  智能化: [
    'AI驱动的自动图表推荐',
    '智能数据分析和洞察',
    '自适应可视化设计',
    '自然语言交互图表'
  ],
  实时化: [
    'WebSocket实时数据流',
    '更高效的数据更新机制',
    '实时协作和共享',
    '流式数据处理和渲染'
  ],
  体验提升: [
    '3D和AR/VR图表体验',
    '更自然的交互动画',
    '无障碍访问支持',
    '跨设备一致性'
  ],
  技术融合: [
    '与Web Components深度集成',
    '机器学习库集成',
    '地理信息系统结合',
    '区块链数据可视化'
  ]
};
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。