返回笔记首页

ECharts 封装实践完整指南

主题配置

一、通用图表组件封装

1.1 简历描述模板

项目经验:数据可视化平台图表组件库

负责封装通用 ECharts 图表组件库,实现了 15+ 常用图表类型的统一封装。通过高阶组件模式和配置驱动设计,实现了图表的快速复用和灵活定制。封装后的组件使用量降低 60% 的重复代码,图表开发效率提升 3 倍。核心实现包括:

  • 设计了基于配置驱动的图表封装架构,支持主题切换、响应式布局和自动刷新
  • 实现了图表生命周期管理机制,解决了组件销毁时的内存泄漏问题
  • 开发了图表配置合并策略,支持默认配置、主题配置和业务配置的智能合并
  • 封装了图表加载状态、空数据和错误处理的统一方案

1.2 SOP 标准回答

面试官:你是如何封装 ECharts 组件的?

我们当时做数据可视化平台时,发现各个业务模块都在重复写图表代码,而且每个人写的风格不一样,维护起来很麻烦。我主导设计了一套通用的图表封装方案。

首先我分析了业务中常用的图表类型,提取了共性需求,比如加载状态、空数据处理、主题切换这些。然后我设计了一个基础的 BaseChart 组件,把这些通用逻辑都封装进去。

在实现上,我用了配置驱动的思路。业务方只需要传入图表类型和数据,组件内部会根据类型生成对应的 ECharts 配置。为了保证灵活性,我还支持传入自定义配置来覆盖默认配置。

有个技术难点是图表的响应式更新。我通过 watch 监听数据变化,并用防抖来优化更新频率,避免数据频繁变化导致的性能问题。另外我还实现了图表实例的缓存和复用机制,减少了重复初始化的开销。

最后在销毁阶段,我特别注意了内存泄漏的问题。在组件卸载时会主动 dispose 图表实例,并清理所有的事件监听器和定时器。

这套封装方案上线后,新增图表的开发时间从原来的半天缩短到 1 小时左右,而且代码质量和一致性都有明显提升。

面试官:如何处理图表的响应式布局?

响应式布局我是从两个层面来处理的。

第一个是容器层面。我在组件外层包了一个响应式容器,使用 ResizeObserver 来监听容器尺寸变化。当容器大小改变时,会自动调用 ECharts 的 resize 方法重新计算布局。为了避免频繁触发,我加了 150ms 的防抖处理。

第二个是配置层面。针对不同屏幕尺寸,我定义了不同的配置策略。比如移动端会自动调整图表的内边距、字体大小、图例位置等。具体实现是通过媒体查询或者根据容器宽度动态生成配置。

还有个细节是,在窗口缩小到一定程度时,有些复杂图表会自动简化显示。比如柱状图在小屏下会隐藏部分标签,折线图会减少坐标轴刻度数量,确保图表在小屏上也能清晰展示。

1.3 难点与亮点分析

难点 1:图表配置的灵活性与复用性平衡

问题:既要保证封装后使用简单,又要支持复杂业务场景的深度定制。

解决方案:

  • 采用三层配置合并策略:默认配置 → 主题配置 → 业务配置
  • 使用深度合并算法,支持配置的增量更新而非全量覆盖
  • 提供配置钩子函数,允许业务方在最终配置生成前进行修改

技术亮点:

  • 实现了智能配置合并,支持数组的追加而非替换
  • 配置校验机制,避免无效配置导致图表渲染失败
  • 配置热更新,无需重新创建图表实例
难点 2:内存泄漏与性能优化

问题:图表实例未正确销毁导致内存累积,大量图表同时渲染造成页面卡顿。

解决方案:

  • 实现完整的生命周期管理,确保组件销毁时清理所有资源
  • 使用图表实例池,复用已创建的实例
  • 实现懒加载和虚拟滚动,按需渲染可视区域内的图表

技术亮点:

  • 自动检测并清理僵尸图表实例
  • 实现了图表的延迟初始化,提升首屏加载速度
  • 使用 requestAnimationFrame 优化动画性能
难点 3:数据更新的时机和方式

问题:数据频繁更新导致图表闪烁,大数据量更新造成界面卡顿。

解决方案:

  • 实现数据 diff 算法,只更新变化的部分
  • 使用防抖和节流控制更新频率
  • 对于大数据量更新,使用分批更新策略

技术亮点:

  • 智能判断是否需要全量重绘还是增量更新
  • 实现了平滑过渡动画,提升用户体验
  • 支持数据流式更新,适配实时监控场景

1.4 完整技术实现

基础图表组件封装

vue
<!-- BaseChart.vue -->
<template>
  <div class="base-chart-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="chart-loading">
      <div class="loading-spinner"></div>
      <p>图表加载中...</p>
    </div>

    <!-- 空数据状态 -->
    <div v-else-if="isEmpty" class="chart-empty">
      <div class="empty-icon">📊</div>
      <p>{{ emptyText }}</p>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="chart-error">
      <div class="error-icon">⚠️</div>
      <p>{{ error }}</p>
      <button @click="handleRetry">重试</button>
    </div>

    <!-- 图表容器 -->
    <div
      v-else
      ref="chartRef"
      class="chart-wrapper"
      :style="{ height: height, width: width }"
    ></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue'
import * as echarts from 'echarts'
import { debounce, merge } from 'lodash-es'
import { useTheme } from './useTheme'
import { getDefaultOptions } from './chartConfig'

const props = defineProps({
  // 图表类型
  type: {
    type: String,
    required: true,
    validator: (value) => ['line', 'bar', 'pie', 'scatter', 'radar'].includes(value)
  },
  // 图表数据
  data: {
    type: [Array, Object],
    default: () => []
  },
  // 自定义配置
  options: {
    type: Object,
    default: () => ({})
  },
  // 图表高度
  height: {
    type: String,
    default: '400px'
  },
  // 图表宽度
  width: {
    type: String,
    default: '100%'
  },
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  // 是否自动响应式
  responsive: {
    type: Boolean,
    default: true
  },
  // 主题
  theme: {
    type: String,
    default: 'default'
  },
  // 是否显示工具栏
  showToolbox: {
    type: Boolean,
    default: false
  },
  // 自动刷新间隔(毫秒)
  autoRefresh: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['click', 'ready', 'error', 'refresh'])

// 图表实例(使用 shallowRef 避免深度响应)
const chartInstance = shallowRef(null)
// 图表容器引用
const chartRef = ref(null)
// 错误信息
const error = ref(null)
// 是否为空数据
const isEmpty = ref(false)
// ResizeObserver 实例
let resizeObserver = null
// 自动刷新定时器
let refreshTimer = null

// 获取主题配置
const { getThemeOptions } = useTheme()

// 初始化图表
const initChart = async () => {
  try {
    error.value = null

    // 检查数据是否为空
    if (!checkData()) {
      isEmpty.value = true
      return
    }

    isEmpty.value = false

    await nextTick()

    if (!chartRef.value) return

    // 如果实例已存在,先销毁
    if (chartInstance.value) {
      chartInstance.value.dispose()
    }

    // 创建图表实例
    chartInstance.value = echarts.init(chartRef.value, props.theme)

    // 生成图表配置
    const finalOptions = generateOptions()

    // 设置配置
    chartInstance.value.setOption(finalOptions, true)

    // 绑定事件
    bindEvents()

    // 通知图表就绪
    emit('ready', chartInstance.value)

  } catch (err) {
    console.error('图表初始化失败:', err)
    error.value = err.message || '图表渲染失败'
    emit('error', err)
  }
}

// 检查数据是否为空
const checkData = () => {
  if (!props.data) return false

  if (Array.isArray(props.data)) {
    return props.data.length > 0
  }

  if (typeof props.data === 'object') {
    return Object.keys(props.data).length > 0
  }

  return false
}

// 生成图表配置
const generateOptions = () => {
  // 获取默认配置
  const defaultOpts = getDefaultOptions(props.type, props.data)

  // 获取主题配置
  const themeOpts = getThemeOptions(props.theme)

  // 工具栏配置
  const toolboxOpts = props.showToolbox ? {
    toolbox: {
      feature: {
        saveAsImage: { title: '保存为图片' },
        dataView: { title: '数据视图', readOnly: false },
        restore: { title: '还原' },
        dataZoom: { title: { zoom: '区域缩放', back: '还原缩放' } }
      }
    }
  } : {}

  // 合并所有配置(深度合并)
  const finalOptions = merge(
    {},
    defaultOpts,
    themeOpts,
    toolboxOpts,
    props.options
  )

  return finalOptions
}

// 绑定事件
const bindEvents = () => {
  if (!chartInstance.value) return

  // 点击事件
  chartInstance.value.on('click', (params) => {
    emit('click', params)
  })

  // 可以添加更多事件监听
}

// 更新图表
const updateChart = () => {
  if (!chartInstance.value) {
    initChart()
    return
  }

  if (!checkData()) {
    isEmpty.value = true
    return
  }

  isEmpty.value = false

  try {
    const finalOptions = generateOptions()
    chartInstance.value.setOption(finalOptions, true)
  } catch (err) {
    console.error('图表更新失败:', err)
    error.value = err.message
    emit('error', err)
  }
}

// 防抖更新
const debouncedUpdate = debounce(updateChart, 300)

// 调整图表大小
const resizeChart = () => {
  if (chartInstance.value && !chartInstance.value.isDisposed()) {
    chartInstance.value.resize()
  }
}

// 防抖 resize
const debouncedResize = debounce(resizeChart, 150)

// 设置响应式监听
const setupResponsive = () => {
  if (!props.responsive || !chartRef.value) return

  // 使用 ResizeObserver 监听容器尺寸变化
  resizeObserver = new ResizeObserver(() => {
    debouncedResize()
  })

  resizeObserver.observe(chartRef.value)
}

// 设置自动刷新
const setupAutoRefresh = () => {
  if (props.autoRefresh > 0) {
    refreshTimer = setInterval(() => {
      emit('refresh')
      updateChart()
    }, props.autoRefresh)
  }
}

// 清理自动刷新
const clearAutoRefresh = () => {
  if (refreshTimer) {
    clearInterval(refreshTimer)
    refreshTimer = null
  }
}

// 重试
const handleRetry = () => {
  error.value = null
  initChart()
}

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

// 监听配置变化
watch(
  () => props.options,
  () => {
    debouncedUpdate()
  },
  { deep: true }
)

// 监听主题变化
watch(
  () => props.theme,
  () => {
    // 主题变化需要重新初始化
    initChart()
  }
)

// 监听加载状态
watch(
  () => props.loading,
  (newVal) => {
    if (!newVal && chartInstance.value) {
      // 加载完成后更新图表
      nextTick(() => {
        updateChart()
      })
    }
  }
)

// 生命周期
onMounted(() => {
  initChart()
  setupResponsive()
  setupAutoRefresh()
})

onUnmounted(() => {
  // 清理图表实例
  if (chartInstance.value && !chartInstance.value.isDisposed()) {
    chartInstance.value.dispose()
    chartInstance.value = null
  }

  // 清理 ResizeObserver
  if (resizeObserver) {
    resizeObserver.disconnect()
    resizeObserver = null
  }

  // 清理定时器
  clearAutoRefresh()
})

// 暴露方法给父组件
defineExpose({
  chartInstance,
  refresh: updateChart,
  resize: resizeChart,
  getImage: () => {
    if (chartInstance.value) {
      return chartInstance.value.getDataURL({
        type: 'png',
        pixelRatio: 2,
        backgroundColor: '#fff'
      })
    }
    return null
  }
})
</script>

<style scoped>
.base-chart-container {
  position: relative;
  width: 100%;
  height: 100%;
}

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

.chart-loading,
.chart-empty,
.chart-error {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: #999;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.empty-icon,
.error-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.chart-error button {
  margin-top: 16px;
  padding: 8px 24px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.chart-error button:hover {
  background: #2980b9;
}
</style>

图表配置生成器

javascript
// chartConfig.js
export const getDefaultOptions = (type, data) => {
  const configs = {
    line: getLineOptions(data),
    bar: getBarOptions(data),
    pie: getPieOptions(data),
    scatter: getScatterOptions(data),
    radar: getRadarOptions(data)
  }

  return configs[type] || {}
}

// 折线图默认配置
const getLineOptions = (data) => {
  return {
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'cross',
        label: {
          backgroundColor: '#6a7985'
        }
      }
    },
    legend: {
      data: data.series?.map(s => s.name) || []
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: data.xAxis || []
    },
    yAxis: {
      type: 'value'
    },
    series: data.series?.map(s => ({
      name: s.name,
      type: 'line',
      smooth: true,
      data: s.data,
      emphasis: {
        focus: 'series'
      }
    })) || []
  }
}

// 柱状图默认配置
const getBarOptions = (data) => {
  return {
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'shadow'
      }
    },
    legend: {
      data: data.series?.map(s => s.name) || []
    },
    xAxis: {
      type: 'category',
      data: data.xAxis || []
    },
    yAxis: {
      type: 'value'
    },
    series: data.series?.map(s => ({
      name: s.name,
      type: 'bar',
      data: s.data,
      emphasis: {
        focus: 'series'
      },
      itemStyle: {
        borderRadius: [4, 4, 0, 0]
      }
    })) || []
  }
}

// 饼图默认配置
const getPieOptions = (data) => {
  return {
    tooltip: {
      trigger: 'item',
      formatter: '{a} <br/>{b}: {c} ({d}%)'
    },
    legend: {
      orient: 'vertical',
      left: 'left',
      data: data.map(d => d.name) || []
    },
    series: [
      {
        name: '数据',
        type: 'pie',
        radius: '50%',
        data: data || [],
        emphasis: {
          itemStyle: {
            shadowBlur: 10,
            shadowOffsetX: 0,
            shadowColor: 'rgba(0, 0, 0, 0.5)'
          }
        },
        label: {
          formatter: '{b}: {d}%'
        }
      }
    ]
  }
}

// 散点图默认配置
const getScatterOptions = (data) => {
  return {
    grid: {
      left: '3%',
      right: '7%',
      bottom: '3%',
      containLabel: true
    },
    tooltip: {
      trigger: 'item'
    },
    xAxis: {
      type: 'value',
      scale: true
    },
    yAxis: {
      type: 'value',
      scale: true
    },
    series: data.series?.map(s => ({
      name: s.name,
      type: 'scatter',
      data: s.data,
      symbolSize: 8,
      emphasis: {
        focus: 'series'
      }
    })) || []
  }
}

// 雷达图默认配置
const getRadarOptions = (data) => {
  return {
    tooltip: {
      trigger: 'item'
    },
    legend: {
      data: data.series?.map(s => s.name) || []
    },
    radar: {
      indicator: data.indicator || []
    },
    series: [
      {
        type: 'radar',
        data: data.series || []
      }
    ]
  }
}

二、主题配置系统

2.1 主题配置实现

javascript
// useTheme.js
import { ref, computed } from 'vue'

// 预定义主题
const themes = {
  default: {
    color: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'],
    backgroundColor: '#ffffff',
    textStyle: {
      color: '#333333'
    },
    title: {
      textStyle: {
        color: '#333333'
      }
    },
    legend: {
      textStyle: {
        color: '#333333'
      }
    },
    tooltip: {
      backgroundColor: 'rgba(50,50,50,0.9)',
      borderColor: '#333',
      textStyle: {
        color: '#fff'
      }
    }
  },

  dark: {
    color: ['#4992ff', '#7cffb2', '#fddd60', '#ff6e76', '#58d9f9', '#05c091', '#ff8a45', '#8d48e3', '#dd79ff'],
    backgroundColor: '#1e1e1e',
    textStyle: {
      color: '#dddddd'
    },
    title: {
      textStyle: {
        color: '#eeeeee'
      }
    },
    legend: {
      textStyle: {
        color: '#dddddd'
      }
    },
    tooltip: {
      backgroundColor: 'rgba(0,0,0,0.9)',
      borderColor: '#777',
      textStyle: {
        color: '#fff'
      }
    },
    axisLine: {
      lineStyle: {
        color: '#555'
      }
    },
    splitLine: {
      lineStyle: {
        color: '#333'
      }
    }
  },

  blue: {
    color: ['#1890ff', '#13c2c2', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#fa8c16', '#eb2f96', '#2f54eb'],
    backgroundColor: '#f0f5ff',
    textStyle: {
      color: '#333333'
    },
    title: {
      textStyle: {
        color: '#1890ff'
      }
    }
  },

  green: {
    color: ['#52c41a', '#13c2c2', '#1890ff', '#faad14', '#f5222d', '#722ed1', '#fa8c16', '#eb2f96', '#2f54eb'],
    backgroundColor: '#f6ffed',
    textStyle: {
      color: '#333333'
    },
    title: {
      textStyle: {
        color: '#52c41a'
      }
    }
  }
}

// 当前主题
const currentTheme = ref('default')

export const useTheme = () => {
  // 获取主题配置
  const getThemeOptions = (themeName = currentTheme.value) => {
    return themes[themeName] || themes.default
  }

  // 设置主题
  const setTheme = (themeName) => {
    if (themes[themeName]) {
      currentTheme.value = themeName
    }
  }

  // 注册自定义主题
  const registerTheme = (name, config) => {
    themes[name] = config
  }

  // 获取所有主题名称
  const getThemeNames = () => {
    return Object.keys(themes)
  }

  return {
    currentTheme: computed(() => currentTheme.value),
    getThemeOptions,
    setTheme,
    registerTheme,
    getThemeNames
  }
}

2.2 主题切换组件

vue
<!-- ThemeSelector.vue -->
<template>
  <div class="theme-selector">
    <button
      v-for="name in themeNames"
      :key="name"
      :class="['theme-btn', { active: currentTheme === name }]"
      @click="handleThemeChange(name)"
    >
      {{ name }}
    </button>
  </div>
</template>

<script setup>
import { useTheme } from './useTheme'

const { currentTheme, getThemeNames, setTheme } = useTheme()
const themeNames = getThemeNames()

const emit = defineEmits(['change'])

const handleThemeChange = (name) => {
  setTheme(name)
  emit('change', name)
}
</script>

<style scoped>
.theme-selector {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}

.theme-btn {
  padding: 8px 16px;
  border: 1px solid #d9d9d9;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.theme-btn:hover {
  border-color: #40a9ff;
  color: #40a9ff;
}

.theme-btn.active {
  background: #1890ff;
  border-color: #1890ff;
  color: white;
}
</style>

三、图表联动与钻取

3.1 图表联动实现

vue
<!-- ChartLinkage.vue -->
<template>
  <div class="chart-linkage">
    <div class="chart-row">
      <BaseChart
        ref="chart1Ref"
        type="bar"
        :data="barData"
        :options="barOptions"
        @click="handleBarClick"
        height="300px"
      />
    </div>

    <div class="chart-row">
      <BaseChart
        ref="chart2Ref"
        type="line"
        :data="lineData"
        :options="lineOptions"
        height="300px"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import BaseChart from './BaseChart.vue'
import * as echarts from 'echarts'

const chart1Ref = ref(null)
const chart2Ref = ref(null)

// 选中的类别
const selectedCategory = ref(null)

// 柱状图数据
const barData = ref({
  xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
  series: [{
    name: '销售额',
    data: [120, 200, 150, 80, 70, 110, 130]
  }]
})

const barOptions = ref({
  emphasis: {
    focus: 'series',
    blurScope: 'coordinateSystem'
  }
})

// 折线图数据
const lineData = ref({
  xAxis: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
  series: [{
    name: '访问量',
    data: [10, 20, 30, 40, 35, 25, 15]
  }]
})

const lineOptions = ref({})

// 处理柱状图点击
const handleBarClick = (params) => {
  selectedCategory.value = params.name

  // 高亮当前选中的柱子
  const chart1 = chart1Ref.value?.chartInstance
  if (chart1) {
    chart1.dispatchAction({
      type: 'highlight',
      seriesIndex: 0,
      dataIndex: params.dataIndex
    })
  }

  // 更新折线图数据(模拟钻取)
  updateLineChart(params.name)
}

// 更新折线图数据
const updateLineChart = (category) => {
  // 模拟根据选中类别获取详细数据
  const detailData = {
    '周一': [12, 15, 20, 35, 30, 25, 18],
    '周二': [20, 25, 30, 45, 40, 35, 28],
    '周三': [15, 18, 25, 35, 32, 28, 20],
    '周四': [8, 12, 18, 25, 22, 18, 10],
    '周五': [7, 10, 15, 22, 20, 15, 8],
    '周六': [11, 15, 22, 32, 28, 22, 15],
    '周日': [13, 18, 25, 35, 30, 25, 18]
  }

  lineData.value = {
    xAxis: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
    series: [{
      name: `${category}访问量`,
      data: detailData[category] || []
    }]
  }

  lineOptions.value = {
    title: {
      text: `${category} 详细数据`,
      left: 'center'
    }
  }
}

// 实现双向联动
const setupLinkage = () => {
  const chart1 = chart1Ref.value?.chartInstance
  const chart2 = chart2Ref.value?.chartInstance

  if (!chart1 || !chart2) return

  // 连接图表
  echarts.connect([chart1, chart2])

  // 可以实现更多联动效果,比如同步缩放、同步提示等
}

// 组件挂载后设置联动
watch([chart1Ref, chart2Ref], () => {
  if (chart1Ref.value && chart2Ref.value) {
    setupLinkage()
  }
})
</script>

<style scoped>
.chart-linkage {
  padding: 20px;
}

.chart-row {
  margin-bottom: 20px;
}
</style>

四、动态数据更新

4.1 实时数据更新实现

vue
<!-- RealtimeChart.vue -->
<template>
  <div class="realtime-chart">
    <div class="control-bar">
      <button @click="toggleUpdate">
        {{ isUpdating ? '暂停' : '开始' }}更新
      </button>
      <button @click="resetData">重置数据</button>
      <span>更新间隔:</span>
      <select v-model.number="updateInterval">
        <option :value="500">500ms</option>
        <option :value="1000">1s</option>
        <option :value="2000">2s</option>
        <option :value="5000">5s</option>
      </select>
    </div>

    <BaseChart
      type="line"
      :data="chartData"
      :options="chartOptions"
      height="400px"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import BaseChart from './BaseChart.vue'

// 是否正在更新
const isUpdating = ref(false)
// 更新间隔
const updateInterval = ref(1000)
// 定时器
let timer = null

// 图表数据
const chartData = ref({
  xAxis: [],
  series: [{
    name: '实时数据',
    data: []
  }]
})

// 图表配置
const chartOptions = ref({
  title: {
    text: '实时数据监控',
    left: 'center'
  },
  xAxis: {
    type: 'category',
    boundaryGap: false
  },
  yAxis: {
    type: 'value',
    boundaryGap: [0, '10%']
  },
  dataZoom: [{
    type: 'inside',
    start: 0,
    end: 100
  }, {
    start: 0,
    end: 100
  }]
})

// 最大数据点数
const MAX_DATA_COUNT = 50

// 初始化数据
const initData = () => {
  const now = new Date()
  const data = []
  const xAxis = []

  for (let i = 0; i < 20; i++) {
    const time = new Date(now.getTime() - (19 - i) * 1000)
    xAxis.push(formatTime(time))
    data.push(Math.random() * 100)
  }

  chartData.value = {
    xAxis,
    series: [{
      name: '实时数据',
      data
    }]
  }
}

// 格式化时间
const formatTime = (date) => {
  const hours = date.getHours().toString().padStart(2, '0')
  const minutes = date.getMinutes().toString().padStart(2, '0')
  const seconds = date.getSeconds().toString().padStart(2, '0')
  return `${hours}:${minutes}:${seconds}`
}

// 更新数据
const updateData = () => {
  const now = new Date()
  const time = formatTime(now)
  const value = Math.random() * 100

  // 添加新数据
  chartData.value.xAxis.push(time)
  chartData.value.series[0].data.push(value)

  // 移除超出限制的数据
  if (chartData.value.xAxis.length > MAX_DATA_COUNT) {
    chartData.value.xAxis.shift()
    chartData.value.series[0].data.shift()
  }

  // 触发响应式更新
  chartData.value = { ...chartData.value }
}

// 开始/暂停更新
const toggleUpdate = () => {
  isUpdating.value = !isUpdating.value

  if (isUpdating.value) {
    startUpdate()
  } else {
    stopUpdate()
  }
}

// 开始更新
const startUpdate = () => {
  if (timer) return

  timer = setInterval(() => {
    updateData()
  }, updateInterval.value)
}

// 停止更新
const stopUpdate = () => {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
}

// 重置数据
const resetData = () => {
  stopUpdate()
  isUpdating.value = false
  initData()
}

// 监听更新间隔变化
watch(updateInterval, () => {
  if (isUpdating.value) {
    stopUpdate()
    startUpdate()
  }
})

// 生命周期
onMounted(() => {
  initData()
})

onUnmounted(() => {
  stopUpdate()
})
</script>

<style scoped>
.realtime-chart {
  padding: 20px;
}

.control-bar {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 20px;
  padding: 12px;
  background: #f5f5f5;
  border-radius: 4px;
}

.control-bar button {
  padding: 6px 16px;
  border: none;
  background: #1890ff;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

.control-bar button:hover {
  background: #40a9ff;
}

.control-bar select {
  padding: 6px 12px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}
</style>

五、图表导出与打印

5.1 图表导出实现

vue
<!-- ChartExport.vue -->
<template>
  <div class="chart-export">
    <div class="toolbar">
      <button @click="exportImage('png')">导出PNG</button>
      <button @click="exportImage('jpg')">导出JPG</button>
      <button @click="exportSVG">导出SVG</button>
      <button @click="exportPDF">导出PDF</button>
      <button @click="printChart">打印图表</button>
    </div>

    <BaseChart
      ref="chartRef"
      type="bar"
      :data="chartData"
      height="400px"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import BaseChart from './BaseChart.vue'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'

const chartRef = ref(null)

const chartData = ref({
  xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
  series: [{
    name: '销售额',
    data: [120, 200, 150, 80, 70, 110, 130]
  }]
})

// 导出图片
const exportImage = (type = 'png') => {
  const chart = chartRef.value?.chartInstance
  if (!chart) return

  try {
    const url = chart.getDataURL({
      type: type,
      pixelRatio: 2,
      backgroundColor: '#fff'
    })

    downloadFile(url, `chart.${type}`)
  } catch (error) {
    console.error('导出图片失败:', error)
    alert('导出失败,请重试')
  }
}

// 导出SVG
const exportSVG = () => {
  const chart = chartRef.value?.chartInstance
  if (!chart) return

  try {
    const svg = chart.renderToSVGString()
    const blob = new Blob([svg], { type: 'image/svg+xml' })
    const url = URL.createObjectURL(blob)

    downloadFile(url, 'chart.svg')

    // 释放URL对象
    setTimeout(() => URL.revokeObjectURL(url), 100)
  } catch (error) {
    console.error('导出SVG失败:', error)
    alert('导出失败,请重试')
  }
}

// 导出PDF
const exportPDF = async () => {
  const chart = chartRef.value?.chartInstance
  if (!chart) return

  try {
    // 获取图表图片
    const url = chart.getDataURL({
      type: 'png',
      pixelRatio: 2,
      backgroundColor: '#fff'
    })

    // 创建PDF
    const pdf = new jsPDF({
      orientation: 'landscape',
      unit: 'px',
      format: [800, 600]
    })

    // 添加图片到PDF
    pdf.addImage(url, 'PNG', 0, 0, 800, 600)

    // 保存PDF
    pdf.save('chart.pdf')
  } catch (error) {
    console.error('导出PDF失败:', error)
    alert('导出失败,请重试')
  }
}

// 打印图表
const printChart = () => {
  const chart = chartRef.value?.chartInstance
  if (!chart) return

  try {
    const url = chart.getDataURL({
      type: 'png',
      pixelRatio: 2,
      backgroundColor: '#fff'
    })

    // 创建打印窗口
    const printWindow = window.open('', '_blank')
    printWindow.document.write(`
      <html>
        <head>
          <title>打印图表</title>
          <style>
            body {
              margin: 0;
              display: flex;
              justify-content: center;
              align-items: center;
              min-height: 100vh;
            }
            img {
              max-width: 100%;
              height: auto;
            }
            @media print {
              body {
                margin: 0;
              }
              img {
                max-width: 100%;
                page-break-inside: avoid;
              }
            }
          </style>
        </head>
        <body>
          <img src="${url}" onload="window.print();window.close();" />
        </body>
      </html>
    `)
    printWindow.document.close()
  } catch (error) {
    console.error('打印失败:', error)
    alert('打印失败,请重试')
  }
}

// 下载文件
const downloadFile = (url, filename) => {
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
}
</script>

<style scoped>
.chart-export {
  padding: 20px;
}

.toolbar {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

.toolbar button {
  padding: 8px 16px;
  border: 1px solid #d9d9d9;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.toolbar button:hover {
  border-color: #40a9ff;
  color: #40a9ff;
}
</style>

5.2 使用示例

vue
<!-- App.vue -->
<template>
  <div class="app">
    <h1>ECharts 封装实践示例</h1>

    <!-- 主题切换 -->
    <section>
      <h2>主题切换</h2>
      <ThemeSelector @change="handleThemeChange" />
      <BaseChart
        type="line"
        :data="lineData"
        :theme="currentTheme"
        height="300px"
      />
    </section>

    <!-- 图表联动 -->
    <section>
      <h2>图表联动</h2>
      <ChartLinkage />
    </section>

    <!-- 实时数据 -->
    <section>
      <h2>实时数据更新</h2>
      <RealtimeChart />
    </section>

    <!-- 图表导出 -->
    <section>
      <h2>图表导出</h2>
      <ChartExport />
    </section>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import BaseChart from './components/BaseChart.vue'
import ThemeSelector from './components/ThemeSelector.vue'
import ChartLinkage from './components/ChartLinkage.vue'
import RealtimeChart from './components/RealtimeChart.vue'
import ChartExport from './components/ChartExport.vue'

const currentTheme = ref('default')

const lineData = ref({
  xAxis: ['1月', '2月', '3月', '4月', '5月', '6月'],
  series: [{
    name: '销售额',
    data: [120, 200, 150, 80, 70, 110]
  }]
})

const handleThemeChange = (theme) => {
  currentTheme.value = theme
}
</script>

<style>
.app {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

section {
  margin-bottom: 60px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 40px;
}

h2 {
  color: #666;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 2px solid #1890ff;
}
</style>

六、真实项目经验总结

6.1 项目背景

在一个大型数据可视化平台项目中,我们需要展示超过 50 种不同类型的图表。最初每个图表都是独立实现的,代码重复率很高,维护成本也很大。项目经理要求我们对图表进行统一封装,提升开发效率和代码质量。

6.2 遇到的实际问题

问题1:不同业务场景对图表的需求差异很大,很难做到完全统一。

解决方案:采用了"默认配置 + 灵活扩展"的设计思路。提供80%常用场景的默认配置,同时允许业务方通过配置覆盖来实现特殊需求。实际使用中,大部分场景只需要传数据就能渲染,特殊场景也能通过自定义配置搞定。

问题2:图表实例没有正确销毁,导致页面切换后内存持续增长。

解决方案:在组件的 onUnmounted 钩子中统一处理资源清理。不仅要 dispose 图表实例,还要清理 ResizeObserver、定时器等。我还加了一个全局的图表实例管理器,定期检查并清理僵尸实例。上线后,内存泄漏问题彻底解决了。

问题3:大数据量图表渲染卡顿,影响用户体验。

解决方案:针对不同场景采用了不同的优化策略。对于静态大数据,使用 ECharts 的数据采样功能;对于实时数据流,实现了分批更新和滑动窗口;对于多图表页面,使用了虚拟滚动和懒加载。这些优化让页面流畅度提升了一个档次。

6.3 关键技术点

  1. 配置合并策略:使用 lodash 的 merge 方法实现深度合并,但针对数组类型做了特殊处理,支持追加而非替换。
  2. 响应式布局:使用 ResizeObserver 替代 window.resize 事件,提供更精确的尺寸监听。配合防抖优化,避免频繁触发。
  3. 生命周期管理:从初始化、更新、销毁各个阶段都有完整的处理逻辑,确保图表的稳定运行。
  4. 主题系统:预定义多套主题,支持运行时切换,也支持业务方注册自定义主题。
  5. 导出功能:支持多种格式导出,包括 PNG、JPG、SVG、PDF,还实现了打印功能。

6.4 项目成果

这套封装方案在公司内部推广后,新增图表的开发时间从平均 4 小时缩短到 1 小时以内。代码重复率从 70% 降低到 20%。而且因为统一了实现方式,后续的维护和升级也变得很容易。

最重要的是,团队新人上手很快。以前需要花一周时间学习 ECharts,现在只需要半天就能开始开发图表功能了。