echarts封装小技巧

3,642 阅读6分钟

echarts封装小技巧

echarts在没有封装的情况下,一个简单的饼图,可能最后会写上百行代码,在业务层这是毫无必要的,且无形增加了代码审查者的心智(尤其是强迫症患者)。

尝试

从业这么多年,从最开始的没有封装,到后面的简单封装,再后面的过度封装,最后又回到了简单封装。中间经历了很多,类似UI给你加码,前端同事没用或者根本没看到你的封装。接下来就说一下我用过的echarts封装方式。

不针对options的简单封装

这个主要是vue2时期,使用mixin去监听屏幕宽度变化和侧边栏变化,并在组件卸载后及时调用dispose以清除echarts的缓存。而这些是每次使用echarts都要执行方法,减少了大量跟业务无关的代码。

但是随着UI越来越卷,简单又标准的图表已经被抛弃。最后的结果是简简单单一个统计分项数据的业务场景,代码量竟然可以高达几百行。

针对options的过渡封装

很长一段时间,我一直致力于减少业务代码下的非业务代码量的减少。

首先想到的是以function的形式,将每个不同样式的图表分类,提取该类标准图表的options并封装起来,通过简单的传参就可以获得对应图表options就可以将图表渲染出来。后来还在个基础上增加了merge方法,支持外部再对标准options进行覆盖调整。代码大大缩减,简单清晰明了。最后我甚至套了一层vue组件,代码量进一步减小。

实际情况下,小组某个成员每封装一个图表样式,都要跟全组成员说一下。尽管这样也会因为UI多人协同有不同的风格,封装任务越来越重。且总有那么一两个会没按照要求使用。总结是一个人使用还好,多人使用简直就是灾难,这又跟组件封装矛盾,完全没有意义,甚至不如CV大法。

封装

总结上面两个封装思路,针对options封装不具可操作性,不针对的话代码量又太多。

纠结过后,我决定不封装options。而是应该封装options中比较复杂的部分,将options拆解成更小的颗粒度,保证绝对灵活度的前提下,将难点印象加深,将代码量减少。

官方主题构建工具

官方其实有一个主题构建工具的,使用构建工具可以预设很多options的简单项,这可以大大减少代码。

image.png

image.png

image.png

官网上可配置的属性看起来其实比较少,不要慌,下载主题之后,看到json源码后就会发现其实主题文件自己也能动手修改。比如我想设置柱状图最大宽度,对照echarts官方配置,我可以直接找到对应位置这么写:

{
    // ...
    "bar": {
        "itemStyle": {
            "barBorderWidth": 0,
            "barBorderColor": "#ccc"
        },
        "barMaxWidth": 24 // 新增
    },
    // ...
}

写完之后也能生效,这样后续默认所有柱状图柱子最大宽度就是24了。

使用主题方式比较简单:引入、注册、使用。

// 引入
import customedTheme from './theme/customed.json'

// 全局注册
echarts.registerTheme('customed', customedTheme)

// 具体使用
chart = echarts.init(document.getElementById(id), 'customed')

同时使用官方主题构建工具也可以很好的实现多主题切换,这里就先不展开了。

封装options中的复杂项

简单项可以通过预设主题解决,复杂项我这里通过不同属性去做不同处理。

简单项的简单提升

例如简单的配色无法满足UI放飞的心,需要渐变色,那就封装一个color,提供渐变色组,再加上可以传参数来改变渐变色组。

export const defaultColorList = [
  ['#12A3E8', '#63E3FF'],
  ['#5AD8A6', '#93EED2'],
  ['#F2D647', '#F9F4C2'],
  ['#FA9D14', '#FCDF83'],
  ['#FC8A72', '#FC7131']
]
export const getLinearGradientColorList = (colorList = defaultColorList) => {
  const linearGradientcolorList = []

  colorList.forEach((item, index) => {
    let color = item
    if (Array.isArray(item)) {
      color = getLinearGradientcolor(item[0], item[1])
    }
    linearGradientcolorList.push(color)
  })

  return linearGradientcolorList
}

export const getLinearGradientcolor = (startColor, endColor) => {
  return {
    type: 'linear',
    x: 0,
    y: 0,
    x2: 0,
    y2: 1,
    colorStops: [
      {
        offset: 0,
        color: startColor
      },
      {
        offset: 1,
        color: endColor
      }
    ]
  }
}

也可以是Icon图标的管理,这里提供了方形的图标和长条形的图标,分别代码原生提供的柱状图和线图(UI不喜欢线图中间有一个点)。

export const legendIcon = {
  square: 'path://M64 64h896v896h-896z',
  line: 'path://M256 256h3584v512h-3584z'
}

这里除了减少代码量,其实还有对一些属性的收纳管理。

复杂需求的组件化

这里可以参考我之前写的给echart饼图添加光圈

全局方法的封装

这里升级使用vue3的hooks,主要还是为了解决echarts的自适应问题和缓存问题,其次是暴露以上的封装。

// src/hooks/echarts
import echarts from '@/echarts'
import { debounce } from 'lodash-es'
import { getLinearGradientColorList, getLinearGradientcolor } from './color'
import { legendIcon } from './legend'

const useEchartHooks = id => {
  let chart = null
  let options = null

  const renderChart = val => {
    return new Promise((resolve, reject) => {
      options = val
      if (!chart) {
        chart = echarts.init(document.getElementById(id), 'customed')
        resolve(chart)

        // nextTick防止样式未组装完成就开始渲染页面
        nextTick(() => {
          chart.setOption(options, true)
        })
      }

      // nextTick防止样式未组装完成就开始渲染页面
      nextTick(() => {
        chart.setOption(options, true)
      })
    })
  }

  const state = reactive({
    resize: null
  })

  const sidebarResize = e => {
    if (e.propertyName === 'width') {
      state.resize()
    }
  }

  const initListener = () => {
    state.resize = debounce(() => {
      resize()
    }, 100)
    window.addEventListener('resize', state.resize)

    // 监听侧边菜单栏-宽度
    state.sidebarEle = document.querySelector('.g-app-sider')
    state.sidebarEle && state.sidebarEle.addEventListener('transitionend', sidebarResize)
  }
  const destroyListener = () => {
    window.removeEventListener('resize', state.resize)
    state.resize = null

    state.sidebarEle && state.sidebarEle.removeEventListener('transitionend', sidebarResize)
  }

  const resize = () => {
    chart && chart.resize()
  }
  onMounted(() => {
    initListener()
  })

  onActivated(() => {
    if (!state.resize) {
      initListener()
    }
  })

  onBeforeUnmount(() => {
    destroyListener()
    chart && chart.dispose()
    chart = null
  })

  onDeactivated(() => {
    destroyListener()
  })
  return { renderChart, getLinearGradientColorList, getLinearGradientcolor, legendIcon, chart }
}

export default useEchartHooks

最终结果

最终使用就如下图那样简洁了。

<template>
  <ft-card title="分项统计">
    <div id="subItemPie" style="width: 100%; height: 300px"></div>
  </ft-card>
</template>

<script setup>
import { getPieTotal } from '@/api/dashboard'

import useEchartHooks from '@/echarts/hooks'
import { graphicPieCircle } from '@/echarts/conponents'

const echart = reactive({
  data: {},
  props: [
    { name: '分项1', key: 'subItem1' },
    { name: '分项2', key: 'subItem2' },
    { name: '分项3', key: 'subItem3' },
    { name: '分项4', key: 'subItem4' }
  ]
})

const onSearch = () => {
  getPieTotal().then(res => {
    echart.data = res.data
    initChart()
  })
}

onMounted(() => {
  onSearch()
})

const { renderChart, getLinearGradientColorList, legendIcon } = useEchartHooks('subItemPie')

const initChart = () => {
  const options = {
    color: getLinearGradientColorList(),
    legend: {
      orient: 'vertical',
      right: '10%',
      top: 'center',
      data: echart.props.map(item => {
        return {
          name: item.name,
          icon: legendIcon.square
        }
      })
    },
    tooltip: {},
    graphic: graphicPieCircle('subItemPie', ['40%', '50%']),
    series: [
      {
        type: 'pie',
        radius: ['40%', '80%'],
        center: ['40%', '50%'],
        label: {
          position: 'inner',
          formatter: '{d}%',
          color: '#fff'
        },
        data: echart.props.map(item => {
          return {
            name: item.name,
            value: echart.data[item.key]
          }
        })
      }
    ]
  }
  renderChart(options)
}
</script>
<template>
  <ft-card title="趋势分析">
    <template #extra>
      <el-date-picker v-model="search.form.date" type="daterange" @change="onSearch" />
    </template>
    <div id="trendBar" style="width: 100%; height: 300px"></div>
  </ft-card>
</template>

<script setup>
import { getTrendLine } from '@/api/dashboard'
import dayjs from 'dayjs'

import useEchartHooks from '@/echarts/hooks'

const search = reactive({
  form: {
    date: [dayjs().subtract(1, 'month').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')]
  }
})

const echart = reactive({
  data: [],
  props: [
    { name: '分项1', key: 'subItem1', stack: 'subItem' },
    { name: '分项2', key: 'subItem2', stack: 'subItem' },
    { name: '分项3', key: 'subItem3', stack: 'subItem' },
    { name: '分项4', key: 'subItem4', stack: 'subItem' },
    { name: '合计', key: 'total', stack: 'total' }
  ]
})

const onSearch = () => {
  getTrendLine(search.form).then(res => {
    echart.data = res.data
    console.log(echart.data)
    initChart()
  })
}

onMounted(() => {
  onSearch()
})

const { renderChart, getLinearGradientColorList, legendIcon } = useEchartHooks('trendBar')

const initChart = () => {
  const options = {
    color: getLinearGradientColorList(),
    tooltip: {
      trigger: 'axis'
    },
    grid: {
      left: 20
    },
    legend: {
      data: echart.props.map(item => {
        return {
          name: item.name,
          icon: legendIcon.line
        }
      })
    },
    xAxis: {
      type: 'category',
      data: echart.data.map(item => item.date)
    },
    yAxis: {
      type: 'value',
      name: 'unit'
    },
    series: echart.props.map((item, index) => ({
      name: item.name,
      type: 'bar',
      stack: item.stack,
      data: echart.data.map(i => i[item.key])
    }))
  }
  renderChart(options)
}
</script>

在我的vite5+vue3从零开始搭建项目系列文章中列举仓库地址也可以看到相关完整代码。