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的简单项,这可以大大减少代码。
官网上可配置的属性看起来其实比较少,不要慌,下载主题之后,看到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从零开始搭建项目
系列文章中列举仓库地址也可以看到相关完整代码。