ECharts 封装
一、相关知识
1.1 什么是 ECharts
ECharts 是一个基于 JavaScript 的开源可视化库,以数据驱动、直观、交互丰富、可高度个性化定制为特点。它提供了丰富多样的图表类型和交互功能,可以帮助开发人员快速创建各种数据可视化图表,如折线图、柱状图、饼图、地图等。同时,ECharts 还提供了多种数据交互和动画效果,使得数据可视化更加生动和有趣。
ECharts 具有良好的兼容性和扩展性,兼容当前绝大部分浏览器(IE8/9/10/11,Chrome,Firefox,Safari 等),支持移动端和 PC 端展示,同时提供了丰富的配置项和 API,使用户能够灵活地定制和调整图表样式和行为。
由于其功能强大、易于上手和社区支持良好,ECharts 已成为前端开发中常用的数据可视化工具之一。
1.2 什么是组件
组件是前端开发中一种模块化的设计方式,用于将特定功能、结构和样式封装成独立的单元。通过组件化的设计,开发人员可以将复杂的界面拆分为多个独立、可复用的部分,使代码更加清晰、可维护性更强;通过组合不同的组件,可以构建出丰富多样的用户界面。
在现代前端开发中,组件化已成为一种重要的开发模式,通过前端组件化能够提高团队协作效率,加快项目开发速度,便于后期进行功能的扩展和修改。
二、为什么要封装 ECharts 组件
数据可视化图表是前端开发中非常常见的功能需求,尤其在大屏和数据管理系统的开发中占有很高的比例,因此 ECharts 成了我们前端工程师经常使用的一个工具库。
在实际开发中,当项目中需要使用 ECharts 进行可视化图表的开发时,通常我们会直接参照官网提供的样例配置来生成所需的图表,类似这样:
myChart.setOption({
title: {
text: "ECharts 入门示例",
},
tooltip: {},
xAxis: {
data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"],
},
yAxis: {},
series: [
{
name: "销量",
type: "bar",
data: [5, 20, 36, 10, 10, 20],
},
],
});
以上只是一个柱状图的配置内容,而每个图表都得有一份自己的配置,这就导致了一个问题:随着项目中需要绘制的图表数量增加,配置变得越来越复杂,当页面中需要显示十几个 ECharts 图时,我们需要写十几份独立的配置。
这些配置不仅冗长还复杂,严重降低了代码的可读性和可维护性,在接入后端接口时也会带来一些麻烦;而且在同一个项目中我们用到的大部分 ECharts 配置都是相似的,每个图表的配置都有很多重复的内容,当需要统一修改某个配置项的内容时重复的工作量很大。在这种情况下,封装通用的 ECharts 组件成了一个更好的选择。
封装 ECharts 组件有以下几个好处:
- 简化使用:封装后的组件可以提供更简洁、更易用的 API,我们能够更快速地完成页面中 ECharts 图表的绘制。
- 隐藏实现细节:封装可以隐藏 ECharts 的具体实现细节,让我们不必过多关心图表配置项和底层实现,只需关注如何处理数据来进行展示。
- 提高复用性:封装后的组件可以被多个页面或项目共享使用,提高了代码的复用性和可维护性。
- 增强扩展性:封装可以在原有基础上进行功能扩展,比如添加自定义交互、动画效果等,从而满足更多定制化的需求。
- 提高可维护性:封装可以将相关的代码逻辑集中在一个组件中,便于维护和管理,减少代码冗余和维护成本。
通过封装 ECharts 组件,我们可以提高开发效率,降低代码维护成本,并使项目变得更加模块化和可扩展化。
三、如何在 Vue3 项目中封装 ECharts 组件
3.1 ECharts 的全量引入和按需引入
我们以 ECharts 5.3.3 版本为例,要在 Vue3 项目中使用 ECharts,得安装 echarts
依赖包,如果要使用 3D 图表功能,还得额外安装 echarts-gl
依赖:
npm install echarts echarts-gl --save
在使用 ECharts 时,我们可以选择全量引入或按需引入 ECharts 资源。
全量引入会导入 ECharts 中的所有图表和组件,使用起来比较便捷:
<template>
<div ref="chartDom"></div>
</template>
<script setup lang="ts">
import * as echarts from "echarts";
import { onMounted, ref, type Ref } from "vue";
const chartDom: Ref<HTMLDivElement | null> = ref(null);
onMounted(() => {
const chart = echarts.init(chartDom.value);
chart.setOption({
// 配置项
});
});
</script>
然而在我们通常开发的中小型系统中,对于可视化图表的复杂性需求通常较低,一般只需要使用 ECharts 中的柱状图、折线图和饼图等基本图表类型,因此全量引入可能会显得有些资源浪费。在适当的情况下,可以考虑使用按需引入的方式,仅导入我们用到的图表组件:
<template>
<div ref="chartDom"></div>
</template>
<script setup lang="ts">
import * as echarts from "echarts/core";
import { BarChart } from "echarts/charts";
import {
// 引入标题组件
TitleComponent,
// 引入图例组件
LegendComponent,
// 引入提示框组件
TooltipComponent,
// 引入网格组件
GridComponent,
} from "echarts/components";
import { onMounted, ref, type Ref } from "vue";
echarts.use([TitleComponent, LegendComponent, TooltipComponent, GridComponent, BarChart]);
const chartDom: Ref<HTMLDivElement | null> = ref(null);
onMounted(() => {
const chart = echarts.init(chartDom.value);
chart.setOption({
// 配置项
});
});
</script>
但是这样的按需引入在使用时也并不方便,每次使用都要引入很多组件,复用性比较差。因此我们可以将 ECharts 的按需引入封装成一个精简版的 ECharts 放在项目的 utils
文件夹下:
import * as Echarts from "echarts/core";
import { BarChart, PieChart, LineChart } from "echarts/charts";
import {
// 引入标题组件
TitleComponent,
// 引入图例组件
LegendComponent,
// 引入提示框组件
TooltipComponent,
// 引入网格组件
GridComponent,
// 引入数据集组件
DatasetComponent,
// 引入数据转换组件
TransformComponent,
// 引入工具箱组件
ToolboxComponent,
// 引入数据缩放组件
DataZoomComponent,
// 引入图形组件
GraphicComponent,
} from "echarts/components";
import { LabelLayout, UniversalTransition } from "echarts/features";
import { CanvasRenderer } from "echarts/renderers";
import type {
// 柱状图系列配置
BarSeriesOption,
PieSeriesOption,
LineSeriesOption,
} from "echarts/charts";
import type {
// 标题组件配置
TitleComponentOption,
TooltipComponentOption,
GridComponentOption,
DatasetComponentOption,
ToolboxComponentOption,
DataZoomComponentOption,
GraphicComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
// 注册必须的组件
Echarts.use([
TitleComponent,
LegendComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
ToolboxComponent,
DataZoomComponent,
GraphicComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
BarChart,
PieChart,
LineChart,
]);
// 导出配置类型
export type ECOption = ComposeOption<
| BarSeriesOption
| PieSeriesOption
| LineSeriesOption
| TitleComponentOption
| TooltipComponentOption
| GridComponentOption
| DatasetComponentOption
| ToolboxComponentOption
| DataZoomComponentOption
| GraphicComponentOption
>;
export const echarts = Echarts;
这样在 vue 文件中需要使用 ECharts 时可以直接引入封装好的 echarts.ts
,当增加了新的图表类型(如雷达图、热力图、桑基图等)时直接修改 echarts.ts
文件就可以,提高使用按需引入的便捷性:
<template>
<div ref="chartDom"></div>
</template>
<script setup lang="ts">
import { echarts, type ECOption } from "@/utils/echarts";
import { onMounted, ref, type Ref } from "vue";
const chartDom: Ref<HTMLDivElement | null> = ref(null);
onMounted(() => {
const chart = echarts.init(chartDom.value);
const options: ECOption = {
// 配置项
};
chart.setOption(options);
});
</script>
3.2 支持数据响应式更新
搞定了 ECharts 的资源引入后,我们就可以正式开始封装 ECharts 组件了。
我们以封装一个柱状图 BarChart 组件为例,首先,我们要让这个组件做到最基本的功能——以数据为驱动,支持响应式更新,即这个组件需要做到能够接收父组件传递的数据绘制成柱状图,当父组件数据变化时也要能重新渲染刷新图表。因此我们可以使用 defineProps
定义一个 data 属性(y 轴数据)和 xAxisData 属性(x 轴数据),并结合监听器 watch
对这些属性进行监听,当监听到变化时用新的数据重新绘制柱状图:
<template>
<div ref="chartDom" style="height: 300px;"></div>
</template>
<script setup lang="ts">
import { echarts, type ECOption } from "@/utils/echarts";
import { ref, shallowRef, watch, onMounted, type ShallowRef, type Ref } from "vue";
import type { EChartsType } from "echarts/types/dist/core";
// 定义组件属性
const props = withDefaults(
defineProps<{
// 数据
data: Array<string | number>;
// x轴数据
xAxisData: Array<string>;
}>(),
{
data: () => [],
xAxisData: () => [],
}
);
// 要渲染的Dom元素
const chartDom: Ref<HTMLDivElement | null> = ref(null);
// 渲染的chart对象要用shallowRef
const chart: ShallowRef<EChartsType | null | undefined> = shallowRef(null);
// 监听数据变化,重新绘制
watch(
() => props,
() => {
drawChart();
},
{
deep: true,
}
);
// 绘制
function drawChart() {
// 图表配置项
const options: ECOption = {
title: {
text: "ECharts柱状图",
},
tooltip: {},
xAxis: {
data: props.xAxisData,
},
yAxis: {},
series: [
{
name: "数量",
type: "bar",
data: props.data,
},
],
};
// 开启notMerge保证数据不会叠加
chart.value?.setOption(options, { notMerge: true });
}
onMounted(() => {
chart.value = echarts.init(chartDom.value);
drawChart();
});
</script>
📌 注意:
echarts.init
初始化得到的 chart 对象要定义成响应式数据时,得使用 shallowRef 来代替 ref,不然会出现像 tooltips 不显示这样的问题
此时我们就得到了一个最简单的柱状图组件 BarChart v1,可以完成最基本的数据展示:
<template>
<el-card>
<BarChart :data="data" :xAxisData="xData" />
</el-card>
</template>
<script setup lang="ts">
import BarChart from "@/components/charts/BarChart.vue";
import { ref } from "vue";
const data = ref<Array<number>>([]);
const xData = ref<Array<string>>([]);
data.value = [5, 20, 36, 10, 10, 20];
xData.value = ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"];
</script>
3.3 简化 ECharts 的配置工作
目前我们的 BarChart v1 组件只能支持单类柱状图的显示,当需要显示多类柱状图时它就无能为力了,因此我们需要扩展这个组件的属性,使它能够接收 ECharts 的配置数据:
import type {
XAXisOption,
YAXisOption,
LegendComponentOption,
BarSeriesOption,
DataZoomComponentOption,
} from "echarts/types/dist/shared";
const props = withDefaults(
defineProps<{
// 数据
data: Array<string | number>;
// x轴数据
xAxisData: Array<string>;
// 图表标题
title?: string;
// 系列配置
series?: Array<BarSeriesOption>;
// x轴配置
xAxis?: Array<XAXisOption>;
// y轴配置
yAxis?: Array<YAXisOption>;
// 图例配置
legend?: LegendComponentOption;
// 区域缩放配置
dataZoom?: Array<DataZoomComponentOption>;
}>(),
{
data: () => [],
xAxisData: () => [],
}
);
要使封装的 ECharts 组件更加易用,必须解决使用 ECharts 时存在的一个痛点——配置项繁多、配置工作繁琐。因此,我们进一步对 BarChart 组件进行改进,内置一些默认的 ECharts 配置,以简化组件的配置流程,从而提高开发效率:
<template>
<div ref="chartDom" :style="{ height: getHeight }"></div>
</template>
<script setup lang="ts">
import { echarts, type ECOption } from "@/utils/echarts";
import { ref, shallowRef, watch, computed, onMounted, type ShallowRef, type Ref } from "vue";
import type { EChartsType } from "echarts/types/dist/core";
import type {
XAXisOption,
YAXisOption,
LegendComponentOption,
BarSeriesOption,
DataZoomComponentOption,
} from "echarts/types/dist/shared";
// 定义组件属性
const props = withDefaults(
defineProps<{
// 数据
data?: Array<string | number>;
// x轴数据
xAxisData: Array<string>;
// 图表标题
title?: string;
// 系列配置
series?: Array<BarSeriesOption>;
// x轴配置
xAxis?: Array<XAXisOption>;
// y轴配置
yAxis?: Array<YAXisOption>;
// 图例配置
legend?: LegendComponentOption;
// 区域缩放配置
dataZoom?: Array<DataZoomComponentOption>;
// 图形高度
height?: number | string;
}>(),
{
data: () => [],
xAxisData: () => [],
title: "ECharts柱状图",
}
);
// 要渲染的Dom元素
const chartDom: Ref<HTMLDivElement | null> = ref(null);
// 渲染的chart对象要用shallowRef
const chart: ShallowRef<EChartsType | null | undefined> = shallowRef(null);
// 高度同时支持string和number
const getHeight = computed(() => {
return typeof props.height === "number" ? props.height + "px" : props.height;
});
// 监听数据变化,重新绘制
watch(
() => props,
() => {
drawChart();
},
{
deep: true,
}
);
// 绘制
function drawChart() {
let series: Array<BarSeriesOption> = props.series
? props.series
: [
{
name: "数量",
type: "bar",
barMaxWidth: 30,
emphasis: { focus: "self" },
label: { show: true, position: "inside", color: "#fff" },
data: props.data,
},
];
let xAxis: Array<XAXisOption> = props.xAxis
? props.xAxis
: [
{
type: "category",
axisTick: { show: false },
data: props.xAxisData,
},
];
let yAxis: Array<YAXisOption> = props.yAxis ? props.yAxis : [{ type: "value", minInterval: 1 }];
let legend: LegendComponentOption = props.legend
? props.legend
: {
show: true,
type: "scroll",
orient: "horizontal",
top: 25,
left: "center",
};
let dataZoom: Array<DataZoomComponentOption> = props.dataZoom ? props.dataZoom : [];
const options: ECOption = {
backgroundColor: "",
title: {
text: props.title,
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: legend,
grid: {
left: 10,
right: 10,
bottom: props.dataZoom ? 40 : 10,
containLabel: true,
},
toolbox: {
show: true,
feature: {
saveAsImage: { show: true },
},
},
xAxis: xAxis,
yAxis: yAxis,
dataZoom: dataZoom,
series: series,
};
chart.value?.setOption(options, { notMerge: true });
}
onMounted(() => {
chart.value = echarts.init(chartDom.value);
drawChart();
});
</script>
现在我们得到了更加灵活的柱状图组件 BarChart v2,支持传入 ECharts 配置数据,并且内置了默认配置,可以方便地实现多类型的柱状图显示:
<template>
<el-card>
<BarChart :data="data" :xAxisData="xData" :series="barSeries" title="销售数据统计" />
</el-card>
</template>
<script setup lang="ts">
import BarChart from "@/components/charts/BarChart.vue";
import { ref } from "vue";
import type { BarSeriesOption } from "echarts/types/dist/shared";
const data = ref<Array<number>>([]);
const xData = ref<Array<string>>([]);
const barSeries = ref<Array<BarSeriesOption>>([]);
data.value = [5, 20, 36, 10, 10, 20];
xData.value = ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"];
barSeries.value = [
{
name: "销量",
type: "bar",
data: [5, 20, 36, 10, 10, 20],
},
{
name: "库存",
type: "bar",
data: [15, 30, 46, 20, 20, 30],
},
];
</script>
3.4 支持自适应窗口大小
此时的 BarChart v2 看起来似乎已经能满足使用了,但是当我们调整了浏览器窗口大小就会发现,我们的组件渲染出来的柱状图仍保持着初始的大小,会因为窗口大小的改变而出现留白或显示不全的问题。因此,我们还需要给 BarChart 组件加上 resize
事件的监听,当监听到窗口大小变化时重新渲染 ECharts 图表。
查看 ECharts 提供的 API 会发现,它提供了一个 resize
方法来重新渲染图表,我们可以结合 window.addEventListener
,在项目的 utils 文件夹下再封装一个 resize.ts
工具来实现 ECharts 自适应窗口大小的功能:
import type { EChartsType } from "echarts/types/dist/core";
// 防抖函数
function debounce(func: Function, delay: number) {
let timeoutId: NodeJS.Timeout;
return function (...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
// 图表自适应窗口大小
function useResize(chart: EChartsType) {
const resizeHandler = debounce(() => {
chart.resize();
}, 100);
const addResizeListener = () => {
window.addEventListener("resize", resizeHandler);
};
const removeResizeListener = () => {
window.removeEventListener("resize", resizeHandler);
};
return {
addResizeListener,
removeResizeListener,
};
}
export default useResize;
有了这个工具类,再让 BarChart 组件实现窗口自适应就很方便了:
// ...
import useResize from "@/utils/resize";
// ...
let resizeHandler: any = null;
onMounted(() => {
chart.value = echarts.init(chartDom.value);
drawChart();
// 添加窗口大小变化监听
if (chart.value) {
resizeHandler = useResize(chart.value);
resizeHandler.addResizeListener();
}
});
onUnmounted(() => {
// 移除监听器
resizeHandler?.removeResizeListener();
chart.value?.dispose();
});
四、进一步提升组件的实用性和便捷性
通过上述步骤的封装,我们得到了一个基本的 ECharts 柱状图组件 BarChart v3,已经可以满足常规的显示需求了,但是这个组件在数据接入后端接口时会存在一个问题:因为业务和开发人员的不同,后端接口返回的数据的属性名是不固定的,我们每次都要先把后端数据处理成纯数据数组传给组件才能显示。
在日常开发中,通常后端接口返回的数据形式是这样的:
[
{ name: "衬衫", saleNum: 17, stockNum: 5 },
{ name: "羊毛衫", saleNum: 20, stockNum: 8 },
{ name: "雪纺衫", saleNum: 36, stockNum: 12 },
{ name: "裤子", saleNum: 10, stockNum: 15 },
{ name: "高跟鞋", saleNum: 10, stockNum: 20 },
{ name: "袜子", saleNum: 20, stockNum: 25 },
];
对于这样的数据,我们的 BarChart v3 在使用时必须遍历重组成两个数组才能实现正常使用:
const xData = ref < Array < string >> [];
const barSeries = ref < Array < BarSeriesOption >> [];
function initChartData(data: Array<any>) {
xData.value = data.map(item => item.name);
barSeries.value = [
{
name: "销量",
type: "bar",
data: data.map(item => item.saleNum),
},
{
name: "库存",
type: "bar",
data: data.map(item => item.stockNum),
},
];
}
// 模拟接口请求
function getData() {
const mockData = [
{ name: "衬衫", saleNum: 17, stockNum: 5 },
{ name: "羊毛衫", saleNum: 20, stockNum: 8 },
{ name: "雪纺衫", saleNum: 36, stockNum: 12 },
{ name: "裤子", saleNum: 10, stockNum: 15 },
{ name: "高跟鞋", saleNum: 10, stockNum: 20 },
{ name: "袜子", saleNum: 20, stockNum: 25 },
];
initChartData(mockData);
}
getData();
而对于不同的图形和不同的接口,我们都得进行不同的遍历,这样一来就增加了使用组件的额外重复工作,说明目前这个组件还是不太通用,这时候就需要考虑使用 ECharts 的数据集(dataset)配置了。
4.1 巧用 dataset
在 ECharts 中,数据集(dataset)是专门用来管理数据的组件。虽然每个系列都可以在 series.data
中设置数据,但是从 ECharts4 支持数据集开始,更推荐使用数据集来管理数据。因为这样数据可以被多个组件复用,也方便进行 "数据和其他配置" 分离的配置风格。毕竟在运行时,数据是最常改变的,而其他配置大多并不会改变。通过巧妙地使用 ECharts 的 dataset 组件,可以更方便地处理数据,实现更灵活的图表展示。
对于上述示例的接口数据,使用 dataset 来构建有两种比较简便的方式。
一种方式是配置 dimensions 让数据自动按顺序映射到坐标轴中:
option = {
legend: {},
tooltip: {},
dataset: {
dimensions: ["name", "saleNum", "stockNum"],
source: [
{ name: "衬衫", saleNum: 17, stockNum: 5 },
{ name: "羊毛衫", saleNum: 20, stockNum: 8 },
{ name: "雪纺衫", saleNum: 36, stockNum: 12 },
{ name: "裤子", saleNum: 10, stockNum: 15 },
{ name: "高跟鞋", saleNum: 10, stockNum: 20 },
{ name: "袜子", saleNum: 20, stockNum: 25 },
],
},
xAxis: { type: "category" },
yAxis: {},
series: [
{
name: "销量",
type: "bar",
},
{
name: "库存",
type: "bar",
},
],
};
另一种方式是配置 series.encode
,让每个图例按配置映射:
option = {
legend: {},
tooltip: {},
dataset: {
source: [
{ name: "衬衫", saleNum: 17, stockNum: 5 },
{ name: "羊毛衫", saleNum: 20, stockNum: 8 },
{ name: "雪纺衫", saleNum: 36, stockNum: 12 },
{ name: "裤子", saleNum: 10, stockNum: 15 },
{ name: "高跟鞋", saleNum: 10, stockNum: 20 },
{ name: "袜子", saleNum: 20, stockNum: 25 },
],
},
xAxis: { type: "category" },
yAxis: {},
series: [
{
name: "销量",
type: "bar",
encode: {
x: "name",
y: "saleNum",
},
},
{
name: "库存",
type: "bar",
encode: {
x: "name",
y: "stockNum",
},
},
],
};
📌 关于 dataset 的更多使用方式,可以参考 ECharts 文档:echarts.apache.org/handbook/zh…
考虑到数据的兼容性和使用的复杂性,在这里我们可以使用第二种配置 series.encode
的方式来改造 BarChart 组件,使其支持使用 dataset:
const props = withDefaults(
defineProps<{
// 数据
data?: Array<string | number>;
// x轴数据
xAxisData?: Array<string>;
// 数据集源数据
datasetSource?: Array<any>;
// x轴属性名
xProp?: string;
// 系列配置
seriesOption?: Array<BarSeriesOption>;
// 图表标题
title?: string;
// x轴配置
xAxis?: Array<XAXisOption>;
// y轴配置
yAxis?: Array<YAXisOption>;
// 图例配置
legend?: LegendComponentOption;
// 区域缩放配置
dataZoom?: Array<DataZoomComponentOption>;
// 图形高度
height?: number | string;
}>(),
{
data: () => [],
xAxisData: () => [],
datasetSource: () => [],
title: "ECharts柱状图",
}
);
// 绘制
function drawChart() {
let series: Array<BarSeriesOption> = [];
let dataset: any = {};
// 如果有数据集源数据,使用dataset方式
if (props.datasetSource && props.datasetSource.length > 0) {
dataset = {
source: props.datasetSource,
};
series = props.seriesOption || [];
} else {
// 否则使用传统方式
series = props.seriesOption || [
{
name: "数量",
type: "bar",
barMaxWidth: 30,
emphasis: { focus: "self" },
label: { show: true, position: "inside", color: "#fff" },
data: props.data,
},
];
}
// ... 其他配置
const options: ECOption = {
// ... 其他配置
dataset: dataset,
series: series,
};
chart.value?.setOption(options, { notMerge: true });
}
此时我们再接入后端接口时,就不需要再重组数据了:
<template>
<el-card>
<BarChart :datasetSource="chartData" :seriesOption="barSeries" :xProp="'name'" title="销售数据统计" />
</el-card>
</template>
<script setup lang="ts">
import BarChart from "@/components/charts/BarChart.vue";
import { ref } from "vue";
import type { BarSeriesOption } from "echarts/types/dist/shared";
const chartData = ref<Array<any>>([]);
const barSeries = ref<Array<BarSeriesOption>>([]);
barSeries.value = [
{
name: "销量",
type: "bar",
encode: {
x: "name",
y: "saleNum",
},
},
{
name: "库存",
type: "bar",
encode: {
x: "name",
y: "stockNum",
},
},
];
// 模拟接口请求
function getData() {
chartData.value = [
{ name: "衬衫", saleNum: 17, stockNum: 5 },
{ name: "羊毛衫", saleNum: 20, stockNum: 8 },
{ name: "雪纺衫", saleNum: 36, stockNum: 12 },
{ name: "裤子", saleNum: 10, stockNum: 15 },
{ name: "高跟鞋", saleNum: 10, stockNum: 20 },
{ name: "袜子", saleNum: 20, stockNum: 25 },
];
}
getData();
</script>
4.2 结合 axios 请求进一步封装
在 BarChart v4 中,虽然我们支持了适配不同属性名的后端数据,但是组件的配置内容还可以再精简,比如像用于构建图表数据的 initChartData
函数和柱状图系列配置 barSeries
,我们在使用时完全不关心它的生成过程,似乎可以完全集成到 BarChart 组件内部。
同时在一般项目的开发过程中,我们获取图表的后端数据时,往往都是一个图表对应一个接口,那么在后端接口规范统一且数据可直接使用的情况下,我们也许可以让 BarChart 绑定一个 axios 方法,直接从该方法中获取数据集。
根据这个思路,我们可以进一步再改造一下 BarChart 组件,增加一个 options 属性:
import type { ChartSetting } from "@/types/ChartData";
const props = withDefaults(
defineProps<{
// 数据
data?: Array<string | number>;
// x轴数据
xAxisData?: Array<string>;
// 数据集源数据
datasetSource?: Array<any>;
// 图表配置
options?: ChartSetting;
// 图表标题
title?: string;
// x轴配置
xAxis?: Array<XAXisOption>;
// y轴配置
yAxis?: Array<YAXisOption>;
// 图例配置
legend?: LegendComponentOption;
// 区域缩放配置
dataZoom?: Array<DataZoomComponentOption>;
// 图形高度
height?: number | string;
}>(),
{
data: () => [],
xAxisData: () => [],
datasetSource: () => [],
title: "ECharts柱状图",
}
);
// 获取数据
async function fetchData() {
if (props.options?.api) {
try {
const response = await props.options.api();
if (response && response.data) {
// 处理数据
processData(response.data);
}
} catch (error) {
console.error("获取图表数据失败:", error);
}
}
}
// 处理数据
function processData(data: Array<any>) {
if (props.options?.xProp && props.options?.seriesOption) {
// 使用dataset方式
chartDataset.value = data;
chartSeries.value = props.options.seriesOption;
} else {
// 使用传统方式
chartXData.value = data.map(item => item[props.options?.xProp || "name"]);
chartData.value = data.map(item => item[props.options?.yProp || "value"]);
}
drawChart();
}
其中 ChartSetting
是一个自定义类型,放在了项目 types 目录下的 ChartData.ts
中:
import type { BarSeriesOption } from "echarts/types/dist/shared";
export interface ChartSetting {
// API方法
api?: () => Promise<any>;
// x轴属性名
xProp?: string;
// y轴属性名
yProp?: string;
// 系列配置
seriesOption?: Array<BarSeriesOption>;
// 其他配置
[key: string]: any;
}
现在再调用柱状图组件就更进一步简化了配置过程:
<template>
<el-card>
<BarChart :options="chartOptions" title="销售数据统计" />
</el-card>
</template>
<script setup lang="ts">
import BarChart from "@/components/charts/BarChart.vue";
import { ref } from "vue";
import type { ChartSetting } from "@/types/ChartData";
import { getSalesData } from "@/api/chart";
const chartOptions = ref<ChartSetting>({
api: getSalesData,
xProp: "name",
seriesOption: [
{
name: "销量",
type: "bar",
encode: {
x: "name",
y: "saleNum",
},
},
{
name: "库存",
type: "bar",
encode: {
x: "name",
y: "stockNum",
},
},
],
});
</script>
将 api 封装进柱状图组件的好处是,当我们要实现类似这样的页面时:
只需要进行简单的配置就可以完成:
<template>
<el-row :gutter="16">
<el-col :span="12">
<el-card>
<BarChart :options="salesOptions" title="销售数据" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<BarChart :options="stockOptions" title="库存数据" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px;">
<el-col :span="12">
<el-card>
<BarChart :options="profitOptions" title="利润数据" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<BarChart :options="costOptions" title="成本数据" />
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import BarChart from "@/components/charts/BarChart.vue";
import { ref } from "vue";
import type { ChartSetting } from "@/types/ChartData";
import { getSalesData, getStockData, getProfitData, getCostData } from "@/api/chart";
const salesOptions = ref<ChartSetting>({
api: getSalesData,
xProp: "name",
seriesOption: [
{
name: "销量",
type: "bar",
encode: { x: "name", y: "value" },
},
],
});
const stockOptions = ref<ChartSetting>({
api: getStockData,
xProp: "name",
seriesOption: [
{
name: "库存",
type: "bar",
encode: { x: "name", y: "value" },
},
],
});
const profitOptions = ref<ChartSetting>({
api: getProfitData,
xProp: "name",
seriesOption: [
{
name: "利润",
type: "bar",
encode: { x: "name", y: "value" },
},
],
});
const costOptions = ref<ChartSetting>({
api: getCostData,
xProp: "name",
seriesOption: [
{
name: "成本",
type: "bar",
encode: { x: "name", y: "value" },
},
],
});
</script>
五、总结与后期改进
在进行了一系列的完善后,最终我们得到了这样一个柱状图组件:
<template>
<div ref="chartDom" :style="{ height: getHeight }"></div>
</template>
<script setup lang="ts">
import { echarts, type ECOption } from "@/utils/echarts";
import { ref, shallowRef, watch, computed, onMounted, onUnmounted, type ShallowRef, type Ref } from "vue";
import type { EChartsType } from "echarts/types/dist/core";
import type {
XAXisOption,
YAXisOption,
LegendComponentOption,
BarSeriesOption,
DataZoomComponentOption,
} from "echarts/types/dist/shared";
import type { ChartSetting } from "@/types/ChartData";
import useResize from "@/utils/resize";
// 定义组件属性
const props = withDefaults(
defineProps<{
// 数据
data?: Array<string | number>;
// x轴数据
xAxisData?: Array<string>;
// 数据集源数据
datasetSource?: Array<any>;
// 图表配置
options?: ChartSetting;
// 图表标题
title?: string;
// 系列配置
series?: Array<BarSeriesOption>;
// x轴配置
xAxis?: Array<XAXisOption>;
// y轴配置
yAxis?: Array<YAXisOption>;
// 图例配置
legend?: LegendComponentOption;
// 区域缩放配置
dataZoom?: Array<DataZoomComponentOption>;
// 图形高度
height?: number | string;
}>(),
{
data: () => [],
xAxisData: () => [],
datasetSource: () => [],
title: "ECharts柱状图",
}
);
// 要渲染的Dom元素
const chartDom: Ref<HTMLDivElement | null> = ref(null);
// 渲染的chart对象要用shallowRef
const chart: ShallowRef<EChartsType | null | undefined> = shallowRef(null);
// 内部数据状态
const chartData = ref<Array<string | number>>([]);
const chartXData = ref<Array<string>>([]);
const chartDataset = ref<Array<any>>([]);
const chartSeries = ref<Array<BarSeriesOption>>([]);
// 高度同时支持string和number
const getHeight = computed(() => {
return typeof props.height === "number" ? props.height + "px" : props.height;
});
// 监听数据变化,重新绘制
watch(
() => props,
() => {
if (props.options?.api) {
fetchData();
} else {
drawChart();
}
},
{
deep: true,
immediate: true,
}
);
// 获取数据
async function fetchData() {
if (props.options?.api) {
try {
const response = await props.options.api();
if (response && response.data) {
processData(response.data);
}
} catch (error) {
console.error("获取图表数据失败:", error);
}
}
}
// 处理数据
function processData(data: Array<any>) {
if (props.options?.xProp && props.options?.seriesOption) {
// 使用dataset方式
chartDataset.value = data;
chartSeries.value = props.options.seriesOption;
} else {
// 使用传统方式
chartXData.value = data.map(item => item[props.options?.xProp || "name"]);
chartData.value = data.map(item => item[props.options?.yProp || "value"]);
}
drawChart();
}
// 绘制
function drawChart() {
let series: Array<BarSeriesOption> = [];
let dataset: any = {};
let xAxisData: Array<string> = [];
let data: Array<string | number> = [];
// 确定数据来源
if (chartDataset.value.length > 0) {
// 使用dataset方式
dataset = { source: chartDataset.value };
series = chartSeries.value;
} else if (props.datasetSource && props.datasetSource.length > 0) {
// 使用props传入的dataset
dataset = { source: props.datasetSource };
series = props.series || [];
} else {
// 使用传统方式
xAxisData = chartXData.value.length > 0 ? chartXData.value : props.xAxisData || [];
data = chartData.value.length > 0 ? chartData.value : props.data || [];
series = props.series || [
{
name: "数量",
type: "bar",
barMaxWidth: 30,
emphasis: { focus: "self" },
label: { show: true, position: "inside", color: "#fff" },
data: data,
},
];
}
let xAxis: Array<XAXisOption> = props.xAxis || [
{
type: "category",
axisTick: { show: false },
data: xAxisData,
},
];
let yAxis: Array<YAXisOption> = props.yAxis || [{ type: "value", minInterval: 1 }];
let legend: LegendComponentOption = props.legend || {
show: true,
type: "scroll",
orient: "horizontal",
top: 25,
left: "center",
};
let dataZoom: Array<DataZoomComponentOption> = props.dataZoom || [];
const options: ECOption = {
backgroundColor: "",
title: {
text: props.title,
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: legend,
grid: {
left: 10,
right: 10,
bottom: props.dataZoom ? 40 : 10,
containLabel: true,
},
toolbox: {
show: true,
feature: {
saveAsImage: { show: true },
},
},
dataset: dataset,
xAxis: xAxis,
yAxis: yAxis,
dataZoom: dataZoom,
series: series,
};
chart.value?.setOption(options, { notMerge: true });
}
let resizeHandler: any = null;
onMounted(() => {
chart.value = echarts.init(chartDom.value);
// 添加窗口大小变化监听
if (chart.value) {
resizeHandler = useResize(chart.value);
resizeHandler.addResizeListener();
}
// 初始化数据
if (props.options?.api) {
fetchData();
} else {
drawChart();
}
});
onUnmounted(() => {
// 移除监听器
resizeHandler?.removeResizeListener();
chart.value?.dispose();
});
</script>
这个组件支持仅传入横、纵坐标数据来显示基础的柱状图,也支持传入 JSON 配置来显示多系列的复杂柱状图,足以应对日常基本需求。同样地,利用类似的设计逻辑,我们也能轻松地封装出饼图、折线图和热力图等组件。
不过在实际项目应用中,特别是面对大屏展示等复杂多变的可视化需求时,现有的封装形式可能还略显不足,因此仍有待进一步拓展和优化。在后续的改进中,我们可以进一步优化组件的功能和性能,以满足不同项目的需求:比如可以为这个组件扩展更多的动态配置项属性,如 tooltip、grid 等,使其可以更灵活地使用;也可以对 series 属性做更好的适配,将所有的图表组件整合为一个,仅通过配置不同的 series.type
,就可以让这个组件展示柱状图、饼图或折线图等。
在 Vue 3 中封装 ECharts 组件无疑为前端开发人员提供了一种高效、便捷的方式来构建可视化图表,极大地提升了开发效率和代码复用性。但值得注意的是,封装组件更适合用于处理基础图表的构建,而在面对高度定制化的 ECharts 图表时,过度依赖封装可能会增加代码的复杂性和维护成本。因此,是否选择封装 ECharts 组件应根据具体项目需求进行权衡。
本文是在借鉴现有 ECharts 组件封装经验的基础上,提供了一种可行的封装的技巧和思路,也许并不是最优的解决方案。希望通过这篇文章能够启发大家的思考,帮助大家更便捷地在 Vue 3 项目中使用 ECharts。