echarts封装

249 阅读8分钟

echarts封装

import * as echarts from 'echarts'
import {
  capitalize,
  defineComponent,
  getCurrentInstance,
  h,
  nextTick,
  onBeforeUnmount,
  onMounted,
  reactive,
  ref,
  watch,
} from 'vue'
import { requestAnimFrame } from '../../utils/scroll-to'
import './index.scss'

// https://echarts.apache.org/zh/api.html#events
const Events = [
  'click',
  'dblclick',
  'mousedown',
  'mouseup',
  'mouseover',
  'mouseout',
  'globalout', // 鼠标移出坐标系触发
  'contextmenu',
  'highlight', // 高亮事件
  'downplay', // 取消高亮事件

  'legendselectchanged', // 切换图例选中状态后的事件
  'legendselected', // 图例选中后的事件
  'legendunselected', // 图例取消选中后的事件
  'legendscroll', //  图例滚动事件
  'datazoom', // 数据区域缩放后的事件
  'datarangeselected', // 视觉映射组件中,range 值改变后触发的事件
  'timelinechanged', // 时间轴中的时间点改变后的事件
  'timelineplaychanged', // 时间轴中播放状态的切换事件
  'restore', // 重置 option 事件
  'dataviewchanged', // 工具栏中数据视图的修改事件
  'magictypechanged', // 工具栏中动态类型切换的切换事件
  'geoselectchanged', // geo 中地图区域切换选中状态的事件
  'geoselected', // geo 中地图区域选中后的事件
  'geounselected', // geo 中地图区域取消选中后的事件
  'pieselectchanged',
  'pieselected',
  'pieunselected',
  'mapselectchanged',
  'mapselected',
  'mapunselected',
  'axisareaselected', // 平行坐标轴 (Parallel)范围选取事件
  'focusnodeadjacency',
  'unfocusnodeadjacency',
  'brush', // “选框正在添加”事件。即发出 brush action 得到的事件
  'brushselected', // 对外通知当前选中了什么
  'rendered', // 渲染结束事件-只代表本帧的渲染结束
  'finished', // 渲染完成事件
]

const chartMap = new Map()

export default defineComponent({
  name: 'Echarts',
  props: {
    id: {
      type: String,
      default: '',
      required: true,
    },
    width: {
      type: String,
      default: '100%',
    },
    height: {
      type: String,
      default: '300px',
    },
    offsetLeft: {
      type: [Number, String],
      default: 0,
    },
    theme: {
      type: String,
      default: 'default',
      required: false,
    },
    styles: {
      type: Object,
      default: () => ({
        width: '100%',
        height: '300px',
      }),
      required: false,
    },
    groupId: {
      type: Number, // https://echarts.apache.org/zh/api.html#echarts.connect
      default: -1, // 要在图表中使用的组名
      required: false,
    },
    loading: {
      type: Boolean,
      default: true, // 是否显示加载
      required: false,
    },
    loadingOption: {
      type: Object,
      default: () => ({
        text: '图表加载中',
        color: '#489CFF',
        textColor: '#489CFF',
        maskColor: 'rgba(255, 255, 255, 0.8)',
        zlevel: 0,
        fontSize: 14,
        showSpinner: true, // 是否显示旋转动画(spinner)
        spinnerRadius: 8, // 旋转动画(spinner)的半径
        lineWidth: 4, // 旋转动画(spinner)的线宽
        fontWeight: 'normal', // 字体粗细
        fontStyle: 'normal', // 字体风格
        fontFamily: 'sans-serif', // 字体系列
      }),
      required: false,
    },
    initCfg: {
      type: Object,
      default: () => ({ renderer: 'canvas' }), // 选择渲染器类型
      required: false,
    },
    notMerge: {
      type: Boolean,
      default: true, // 是否不跟之前设置的 option 进行合并。默认为 false。即表示合并。如果为 true,表示所有组件都会被删除,然后根据新 option 创建所有新组件
      required: false,
    },
    lazyUpdate: {
      type: Boolean,
      default: false, // 在设置完 option 后是否不立即更新图表,默认为 false,即同步立即更新。如果为 true,则会在下一个 animation frame 中,才更新图表
      required: false,
    },
    option: {
      type: Object, // https://echarts.apache.org/zh/option.html#title
      default: () => {}, // 配置项
      required: true,
    },
    eventQuery: {
      type: [String, Object], // https://echarts.apache.org/zh/api.html#echartsInstance.on
      default: '', // 指定事件范围
    },
  },
  emits: ['ready', ...Events],
  setup(props, context) {
    const resizing = ref(false)
    let $el = reactive({})
    let $proxy = null

    watch(
      () => props.theme,
      () => {
        refreshChart()
      }
    )
    watch(
      () => props.option,
      () => {
        refreshOption()
      }
    )

    onMounted(() => {
      const { proxy } = getCurrentInstance()
      $proxy = proxy
      $el = proxy?.$el

      refreshChart()

      if (!$el.ro) {
        $el.ro = new ResizeObserver((entries) => {
          entries.forEach((entry) => {
            const width = entry.contentRect.width
            const height = entry.contentRect.height
            onChangeSize(width, height)
          })
        })
      }

      $el.ro.observe($el)
      window.addEventListener('resize', onResize, false)
    })

    const onChangeSize = (width, height) => {
      if (width && height && !resizing.value) {
        resizing.value = true
        requestAnimFrame(() => {
          const chart = getChart()
          if (chart) {
            chart.resize({
              width,
              height,
            })
          }
          resizing.value = false
        })
      }
    }

    const onResize = () => {
      if (parseFloat(props.offsetLeft) === 0) return
      const width = window.innerWidth - parseFloat(props.offsetLeft)
      const height = $el.clientHeight
      onChangeSize(width, height)
    }

    onBeforeUnmount(() => {
      const chart = getChart()
      if (chart) {
        chart.dispose()
        chartMap.delete(props.id)
      }

      $el.ro?.unobserve($el)
      window.removeEventListener('resize', onResize)
    })

    const getChart = () => {
      return chartMap.get(props.id)
    }

    const refreshOption = () => {
      const chart = getChart()
      if (!chart) return

      if (
        props.option &&
        Object.keys(props.option).some((x) => /^[a-z]/.test(x))
      ) {
        // 合并新的参数和数据,然后刷新图表
        chart.setOption(props.option, props.notMerge, props.lazyUpdate)
        if ($el.clientHeight) chart.resize() // 改变图表尺寸,在容器大小发生改变时需要手动调用
        props.loading && chart.hideLoading() // 隐藏动画加载效果
      } else {
        props.loading && chart.showLoading('default', props.loadingOption) // 显示加载动画效果
      }
    }

    const refreshChart = () => {
      let chart = getChart()
      if (chart) {
        // 销毁实例,在图表容器重新被添加后再次调用 echarts.init 初始化
        chart?.dispose()
        chartMap.delete(props.id)
      }

      const instance = echarts.getInstanceByDom($el) // 获取 dom 容器上的实例

      if (!instance) {
        // 基于准备好的 dom,初始化 echarts 实例
        chart = echarts.init($el, props.theme, props.initCfg)
        chart.group = props.groupId
        chartMap.set(props.id, chart)
        context.emit('ready', chart, echarts)
      }

      nextTick(() => {
        refreshOption()

        Events.forEach((x) => {
          const eventName = `on${capitalize(x)}`
          const eventFn =
            $proxy.$.vnode.props && $proxy.$.vnode.props[eventName]
          if ($proxy && eventFn && typeof eventFn === 'function') {
            if (props.eventQuery) {
              chart.on(x, props.eventQuery, context.emit.bind($proxy, x))
            } else {
              chart.on(x, context.emit.bind($proxy, x))
            }
          }
        })
      })
    }

    const clear = () => {
      const chart = getChart()
      if (!chart) return
      chart.clear() // 清空当前实例,会移除实例中所有的组件和图
    }

    const setOption = (...args) => {
      const chart = getChart()
      if (!chart) return
      return chart.setOption(...args)
    }

    /**
     * 触发图表行为
     * @link https://echarts.apache.org/zh/api.html#echartsInstance.dispatchAction
     *
     * @param {*} args
     */
    const dispatchAction = (...args) => {
      const chart = getChart()
      if (!chart) return
      return chart.dispatchAction(...args)
    }

    const restore = () => {
      dispatchAction({
        type: 'restore', // 重置 option
      })
    }

    /**
     * 导出图表图片,返回一个 base64 的 URL,可以设置为Image的src
     *
     * @param {string} [type='png'] 导出的格式,可选 png, jpeg
     * @param {number} [pixelRatio=2] 导出的图片分辨率比例,默认为 1
     * @param {string} [backgroundColor='#fff'] 导出的图片背景色,默认使用 option 里的 backgroundColor
     */
    const getDataURL = (
      type = 'png',
      pixelRatio = 2,
      backgroundColor = '#fff'
    ) => {
      const chart = getChart()
      if (!chart) return
      return chart.getDataURL({
        type,
        pixelRatio,
        backgroundColor,
      })
    }

    context.expose({
      clear,
      restore,
      setOption,
      dispatchAction,
      getDataURL,
    })

    return {}
  },
  render() {
    const { styles, id, width, height } = this
    Object.assign(styles, { width, height })

    return h('div', { id: `${id}`, class: `v-echarts`, style: styles })
  },
})

封装思想

封装思路

1. 组件初始化方面

  • DOM 准备:在组件的 <template> 部分,定义了一个 <div> 元素作为 Echarts 图表的挂载容器,并且给它赋予相应的样式属性来控制宽度、高度等基础样式,同时设置了 id 等方便后续操作。例如代码中的 return h('div', { id: ${id}, class: v-echarts, style: styles }) 这一渲染逻辑,就是创建出了承载图表的 DOM 节点。
  • Echarts 实例创建:在组件的 setup 函数里,利用 onMounted 生命周期钩子,当组件挂载到 DOM 上之后,先获取到对应的 DOM 元素,再通过 echarts.init 方法去初始化 Echarts 实例,同时传入诸如主题 theme、初始化配置 initCfg 等参数,像 chart = echarts.init($el, props.theme, props.initCfg) 这样的代码实现了实例的初始化。并且会把创建好的实例存放在 chartMap 这个 Map 结构中,以 props.id 作为唯一标识,方便后续根据 id 快速查找和操作对应的图表实例。

2. 数据与配置更新机制

  • 配置项监听:使用 watch 来监听 props.option 的变化,一旦它发生改变,就会调用 refreshOption 方法。在 refreshOption 方法中,获取到对应的图表实例后,依据 props 中传递的 notMerge 和 lazyUpdate 等配置参数,通过调用 chart.setOption 方法来更新图表的配置,确保图表能根据最新传入的配置数据进行相应展示调整。例如 chart.setOption(props.option, props.notMerge, props.lazyUpdate) 这段代码实现了配置更新操作。
  • 主题变更处理:同样通过 watch 监听 props.theme 的变化,一旦主题改变,调用 refreshChart 方法,这个方法会先销毁已有的图表实例(如果存在的话),然后重新初始化一个新的 Echarts 实例并应用新的主题,保证图表的主题样式能及时更新。

3. 事件绑定实现

  • 在 refreshChart 方法里,遍历之前定义好的 Events 数组(里面包含了众多 Echarts 支持的各类事件名称),对于每个事件,通过 chart.on 方法去给图表实例绑定对应的事件处理函数。并且会根据 props.eventQuery 的情况来确定事件的触发范围,当这些事件在图表上被触发时,利用 context.emit 向外发射相应事件,使得父组件能够监听到这些事件并执行对应的业务逻辑。

4. 组件销毁清理

  • 在 onBeforeUnmount 生命周期钩子中,进行资源的清理工作。先获取对应的图表实例,调用 dispose 方法销毁它,防止内存泄漏等问题,同时从 chartMap 中移除该实例的记录,并且停止对图表容器尺寸变化的观察以及移除窗口大小变化的事件监听器,保证组件在销毁时干净利落地释放所有相关资源。

5. 对外暴露操作方法

  • 通过 context.expose 向外暴露了如 clearrestoresetOptiondispatchActiongetDataURL 等方法,让父组件能够方便地调用这些方法来对图表进行各种操作,比如清空图表内容、重置图表配置、触发特定图表行为以及获取图表的图片数据等。

封装的好处

1. 提高代码复用性

  • 在一个项目中,如果多处需要展示不同配置、不同类型的 Echarts 图表,通过封装成这样一个组件,只需要传入不同的 props 参数,比如 option 里配置不同的数据系列、坐标轴信息等,就可以轻松复用该组件来生成各种各样的图表,避免了每次使用 Echarts 都要重新编写初始化实例、配置图表、绑定事件等一长串重复代码,极大地提升了开发效率。

2. 便于维护和扩展

  • 若后续需要对图表的一些通用功能进行优化,比如优化图表的自适应尺寸逻辑、添加新的默认事件处理等,只需要在这个封装的组件内部进行修改即可,而不用在每个使用到 Echarts 图表的地方都去查找和调整代码,降低了维护成本。而且如果要增加新的图表操作方法或者支持更多的 Echarts 特性,也可以很方便地在组件内部进行扩展添加,比如要新增一个导出图表为 SVG 格式的功能,就在组件里添加对应的方法实现逻辑,外部使用组件的地方基本不用变动。

3. 增强组件化和关注点分离

  • 把 Echarts 相关的复杂操作逻辑都封装在一个独立的组件内,对于使用该组件的其他开发者或者其他组件来说,只需要关注通过 props 传递什么配置数据进去、监听哪些发射出来的事件以及调用哪些暴露出来的方法就可以了,不需要深入了解 Echarts 内部的实现细节,实现了关注点的分离,使得整个项目的代码结构更加清晰、易于理解和分工协作。

4. 更好的交互性和响应式体验

  • 通过完善的事件绑定机制,外部组件可以方便地与 Echarts 图表进行交互,比如根据用户点击图表的不同区域执行不同的业务逻辑等。同时,借助对 props 的监听以及窗口、容器尺寸变化的监听处理,图表能够实时根据传入数据和外部环境变化进行自适应更新,给用户提供更好的交互和响应式体验,满足多样化的业务需求。

柱状图分页渲染

<Echarts style="width: 28vw; height: 180px;" :key="chartKey" id="option" :option="option" />

const data = [120, 200, 150, 80, 70, 110, 130, 12, 465, 87, 88];
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', '12', '465', '87', 'sfs'];
const currentPage = ref(1); // 当前页码
const itemsPerPage = 5; // 每页显示 5 个数据项
const totalPages = ref(Math.ceil(data.length / itemsPerPage)); // 总页数

const chartKey = ref(0);  // 用来控制重新渲染的 key

const getPageData = () => {
  const start = (currentPage.value - 1) * itemsPerPage;
  const end = currentPage.value * itemsPerPage;
  return {
    data: data.slice(start, end),
    days: days.slice(start, end)
  };
};

const option = ref({
  grid: {
    left: '5%',
    right: '10%',
    top: '10%',
    bottom: '10%',
  },
  yAxis: {
    type: 'category',
    axisLine: {
      show: true,
      lineStyle: {
        color: '#9B9B9B',
        width: 10,
        type: 'solid',
      },
    },
    axisTick: {
      show: false, // 去除刻度线
    },
    splitLine: {
      show: false, // 去除分隔线
    },
    axisLabel: {
      show: false, // 去除 y 轴的数字标签
    },
  },
  xAxis: {
    type: 'value',
    axisLine: { show: false },
    splitLine: { show: false },
    axisLabel: { show: false },
  },
  series: [
    {
      name: 'Weekly Data',
      type: 'bar',
      data: getPageData().data,  // 动态更新的 data
      barWidth: 27,
      label: {
        show: true,
        position: 'right',
        formatter: (params) => {
          // 在这里使用 params 来获取当前的索引(即 days 和 data 的对应值)
          const day = getPageData().days[params.dataIndex];  // 获取对应的 day
          const value = params.value;  // 获取对应的 data
          return `${day} ${value}`;  // 显示格式为 "Day: Value"
        },
        color: '#52567d',
        fontSize: 12,
        fontWeight: 'bold',
      },
      itemStyle: {
        color: (params) => {
          const colorList = ['#C0BBF6',......];
          return colorList[params.dataIndex % colorList.length];
        },
      },
    },
  ],
});

// 上一页
const lastPage = () => {
  if (currentPage.value > 1) {
    currentPage.value -= 1;
    updateData();
  }
};

// 下一页
const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    currentPage.value += 1;
    updateData();
  }
};

// 更新数据并重新渲染图表
const updateData = () => {
  // 更新 option
  option.value = {
    ...option.value,
    yAxis: { ...option.value.yAxis, data: getPageData().days },
    series: [
      {
        ...option.value.series[0],
        data: getPageData().data,
      },
    ],
  };

  // 更改 chartKey 来强制重新渲染图表
  chartKey.value += 1;
};

watch(currentPage, () => {
  updateData();
});