封装完了才发现有个 vue-echarts 的库。。。难受了。还好我没有封装的很复杂,对于事件这边是完全没做处理(暂时没有那么多互动需求)。

动机

先要明白封装 echarts 的目标是什么,不能上来就无脑封装。我封装它的目的就是因为我们的各类型图表设计类似,没有特别多的变化。以折线图为例,可能我们大多数的折线图都使用相同的设计、颜色,所以我不希望像 echarts 或 vue-echarts 那样,需要给每个图表都传一遍 options。

其实这个需求用其他的代码复用能力也能做到,但是我认为封装成组件更灵活,也更直观一些。

实现

清楚了为什么封装之后,我们就需要明确我们的组件想要隐藏什么细节了。既然我们需要隐藏的是整个图表的样式配置项,所以颜色、表现相关的配置项肯定是想要在组件内写好,而相关的数据配置需要使用者传入。

其实仔细思考一下,相关的表现配置项也不好写死在组件里,这样会导致我们的组件适应性太差。所以我选择将其作为默认值,可以让上层使用者随时可以覆盖。

封装基本的 echarts 组件

按着这个思路来的话,我可能会封装很多种类的图表组件。那么这么多组件,我希望能有一个基本的 echarts 组件能支撑其底层,即 echarts 的初始化、挂载等操作。

  
<!-- components/Echarts/Echarts.vue -->
<template>
    <div ref="echartsRef" class="echarts-container"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
// 这里引入的echarts是按需引入,use了相关插件后的echarts
import { echarts } from './utils/echarts'

const props = defineProps({
  option: {
    type: Object,
    default: () => ({}),
  },
  // 控制loading状态
  loading: {
    type: Boolean,
    default: false,
  },
})

const echartsRef = ref(null)

const chart = ref(null)

// 实现配置项自动更新,让上层无须反复设置setOption
watch(
  () => props.option,
  (newOption) => {
    if (chart.value) {
      chart.value.setOption(newOption)
    }
  }
)

watch(
  () => props.loading,
  (loading) => {
    if (loading) {
      chart.value?.showLoading()
    } else {
      chart.value?.hideLoading()
    }
  }
)

onMounted(() => {
  // 挂载后,初始化echarts实例
  if (!echartsRef.value) return
  chart.value = echarts.init(echartsRef.value)
  if (props.loading) {
    chart.value?.showLoading()
  }
  chart.value.setOption(props.option)
})

onBeforeUnmount(() => {
  // 组件卸载,释放实例
  if (chart.value) {
    chart.value.dispose()
  }
})

// 将echarts实例暴露出去,万一满足不了上层所有需求,上层可以自己操作实例
defineExpose({
  chart,
})
</script>

<script>
export default {
  name: 'BaseChart',
}
</script>

<style lang="less">
.echarts-container {
  width: 100%;
  height: 100%;
}
</style>

封装具体图表

这样我们就可以基于这一层,继续封装。以折线图为例:

  
<!-- components/Echarts/BaseLine.vue -->
<template>
  <Echarts :option="customOption" :loading="loading"></Echarts>
</template>

<script setup>
import { computed } from 'vue'
import { merge, cloneDeep } from 'lodash-es'
import Echarts from './Echarts.vue'

// 将数据相关的配置项抛出去,让使用者确定
const props = defineProps({
  option: {
    type: Object,
    default: () => ({}),
  },
  legend: {
    type: Array,
    default: () => [],
  },
  xAxis: {
    type: Array,
    default: () => [],
  },
  yAxis: {
    type: Array,
    default: () => [],
  },
  data: {
    type: Object,
    default: () => ({}),
  },
  loading: {
    type: Boolean,
    default: false,
  }
})

// 生成series配置项,其实这个函数也可以从上层传进来,这样能更自由,但这里我直接写死了
const generateSeries = (data) => {
  if (!data) return []
  if (Array.isArray(data)) {
    return data.map(({ name, data }) => ({
      name,
      type: 'line',
      symbol: 'circle',
      symbolSize: 6,
      emphasis: {
        scale: 1.5,
      },
      itemStyle: {
        color({ color }) {
          return color
        },
        borderColor: '#fff',
        borderWidth: 1,
      },
      data,
    }))
  } else {
    return Object.entries(data).map(([name, data]) => ({
      name,
      type: 'line',
      symbol: 'circle',
      symbolSize: 6,
      emphasis: {
        scale: 1.5,
      },
      itemStyle: {
        color({ color }) {
          return color
        },
        borderColor: '#fff',
        borderWidth: 1,
      },
      data,
    }))
  }
}

// 默认的折线图配置项
const defaultOption = {
  tooltip: {
    trigger: 'axis',
  },
  legend: {
    data: [],
    left: 'left',
    top: '6px',
  },
  grid: {
    left: '3%',
    right: '4%',
    bottom: '3%',
    containLabel: true,
  },
  xAxis: {
    type: 'category',
    boundaryGap: false,
  },
  yAxis: {
    type: 'value',
  },
}

const customOption = computed(() => {
  // 合并默认配置、自定义的option、传入的数据。得到最终的option
  return merge(merge(cloneDeep(defaultOption), props.option), {
    legend: {
      data: props.legend,
    },
    xAxis: {
      data: props.xAxis,
    },
    yAxis: {
      data: props.yAxis,
    },
    series: generateSeries(props.data),
  })
})
</script>

<script>
export default {
  name: 'BaseLineChart',
}
</script>

这里实现配置项的覆盖,其实是使用了 lodash 的 merge 来进行合并,而且进行了两次合并,这样能确保各配置项的优先级,让他们可以相互覆盖。

使用

这样封装完,我们在使用时就可以直接传入数据了:

  
<!-- 某个业务组件 -->
<template>
    <div style="width: 100%; height: 100%">
    <BaseLineChart
      :legend="legend"
      :xAxis="xAxis"
      :data="data"
      :loading="store.isLoadingChartData"
    ></BaseLineChart>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import BaseLineChart from '../../components/Echarts/baseLine.vue'

import { useVisualizationStore } from '../visualizationStore'

const store = useVisualizationStore()

const legend = ref(['风险数', '得分'])

const xAxis = computed(() => {
  return store.dataSafeList?.map((item) => item.month)
})

const data = computed(() => {
  return {
    '风险数': store.dataSafeList?.map((item) => item.num) || [],
    '得分': store.dataSafeList?.map((item) => item.score) || [],
  }
})
</script>

可以看到 我们就已经实现了在业务层内,非必要不使用样式相关的配置项。如果真的想要覆盖某个样式,也可以直接传入一个 options 来覆盖默认的样式。

个人认为这应该算是一个比较有用的封装,即使使用 vue-echarts,我看也是需要传入具体的配置项。也就意味着,如果想要实现具体业务不参与样式,依旧需要再封装一层图表组件。

唯一的遗憾可能是没有基于 vue-echarts 进行封装吧,如果基于他进行封装,可能有很多功能不需要自己实现,也更完善一些。

哦,可能不少场景下,需要侦听 window 的 resize,来让图表自动重渲染(auto resize)。我这一期没有封装这个功能,但是我觉得也好加,在基础组件内添加即可。