Skip to content

ECharts 封装

一、相关知识

1.1 什么是 ECharts

ECharts 是一个基于 JavaScript 的开源可视化库,以数据驱动、直观、交互丰富、可高度个性化定制为特点。它提供了丰富多样的图表类型和交互功能,可以帮助开发人员快速创建各种数据可视化图表,如折线图、柱状图、饼图、地图等。同时,ECharts 还提供了多种数据交互和动画效果,使得数据可视化更加生动和有趣。

ECharts 具有良好的兼容性和扩展性,兼容当前绝大部分浏览器(IE8/9/10/11,Chrome,Firefox,Safari 等),支持移动端和 PC 端展示,同时提供了丰富的配置项和 API,使用户能够灵活地定制和调整图表样式和行为。

由于其功能强大、易于上手和社区支持良好,ECharts 已成为前端开发中常用的数据可视化工具之一。

1.2 什么是组件

组件是前端开发中一种模块化的设计方式,用于将特定功能、结构和样式封装成独立的单元。通过组件化的设计,开发人员可以将复杂的界面拆分为多个独立、可复用的部分,使代码更加清晰、可维护性更强;通过组合不同的组件,可以构建出丰富多样的用户界面。

在现代前端开发中,组件化已成为一种重要的开发模式,通过前端组件化能够提高团队协作效率,加快项目开发速度,便于后期进行功能的扩展和修改。

二、为什么要封装 ECharts 组件

数据可视化图表是前端开发中非常常见的功能需求,尤其在大屏和数据管理系统的开发中占有很高的比例,因此 ECharts 成了我们前端工程师经常使用的一个工具库。

在实际开发中,当项目中需要使用 ECharts 进行可视化图表的开发时,通常我们会直接参照官网提供的样例配置来生成所需的图表,类似这样:

javascript
myChart.setOption({
  title: {
    text: "ECharts 入门示例",
  },
  tooltip: {},
  xAxis: {
    data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"],
  },
  yAxis: {},
  series: [
    {
      name: "销量",
      type: "bar",
      data: [5, 20, 36, 10, 10, 20],
    },
  ],
});

以上只是一个柱状图的配置内容,而每个图表都得有一份自己的配置,这就导致了一个问题:随着项目中需要绘制的图表数量增加,配置变得越来越复杂,当页面中需要显示十几个 ECharts 图时,我们需要写十几份独立的配置。

这些配置不仅冗长还复杂,严重降低了代码的可读性和可维护性,在接入后端接口时也会带来一些麻烦;而且在同一个项目中我们用到的大部分 ECharts 配置都是相似的,每个图表的配置都有很多重复的内容,当需要统一修改某个配置项的内容时重复的工作量很大。在这种情况下,封装通用的 ECharts 组件成了一个更好的选择。

封装 ECharts 组件有以下几个好处:

  1. 简化使用:封装后的组件可以提供更简洁、更易用的 API,我们能够更快速地完成页面中 ECharts 图表的绘制。
  2. 隐藏实现细节:封装可以隐藏 ECharts 的具体实现细节,让我们不必过多关心图表配置项和底层实现,只需关注如何处理数据来进行展示。
  3. 提高复用性:封装后的组件可以被多个页面或项目共享使用,提高了代码的复用性和可维护性。
  4. 增强扩展性:封装可以在原有基础上进行功能扩展,比如添加自定义交互、动画效果等,从而满足更多定制化的需求。
  5. 提高可维护性:封装可以将相关的代码逻辑集中在一个组件中,便于维护和管理,减少代码冗余和维护成本。

通过封装 ECharts 组件,我们可以提高开发效率,降低代码维护成本,并使项目变得更加模块化和可扩展化。

三、如何在 Vue3 项目中封装 ECharts 组件

3.1 ECharts 的全量引入和按需引入

我们以 ECharts 5.3.3 版本为例,要在 Vue3 项目中使用 ECharts,得安装 echarts 依赖包,如果要使用 3D 图表功能,还得额外安装 echarts-gl 依赖:

bash
npm install echarts echarts-gl --save

在使用 ECharts 时,我们可以选择全量引入或按需引入 ECharts 资源。

全量引入会导入 ECharts 中的所有图表和组件,使用起来比较便捷:

vue
<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 中的柱状图、折线图和饼图等基本图表类型,因此全量引入可能会显得有些资源浪费。在适当的情况下,可以考虑使用按需引入的方式,仅导入我们用到的图表组件:

vue
<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 文件夹下:

typescript
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 文件就可以,提高使用按需引入的便捷性:

vue
<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 对这些属性进行监听,当监听到变化时用新的数据重新绘制柱状图:

vue
<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,可以完成最基本的数据展示:

vue
<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 的配置数据:

typescript
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 配置,以简化组件的配置流程,从而提高开发效率:

vue
<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 配置数据,并且内置了默认配置,可以方便地实现多类型的柱状图显示:

vue
<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 自适应窗口大小的功能:

typescript
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 组件实现窗口自适应就很方便了:

typescript
// ...
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,已经可以满足常规的显示需求了,但是这个组件在数据接入后端接口时会存在一个问题:因为业务和开发人员的不同,后端接口返回的数据的属性名是不固定的,我们每次都要先把后端数据处理成纯数据数组传给组件才能显示。

在日常开发中,通常后端接口返回的数据形式是这样的:

javascript
[
  { 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 在使用时必须遍历重组成两个数组才能实现正常使用:

javascript
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 让数据自动按顺序映射到坐标轴中:

javascript
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,让每个图例按配置映射:

javascript
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:

typescript
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 });
}

此时我们再接入后端接口时,就不需要再重组数据了:

vue
<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 属性:

typescript
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 中:

typescript
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;
}

现在再调用柱状图组件就更进一步简化了配置过程:

vue
<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 封装进柱状图组件的好处是,当我们要实现类似这样的页面时:

只需要进行简单的配置就可以完成:

vue
<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>

五、总结与后期改进

在进行了一系列的完善后,最终我们得到了这样一个柱状图组件:

vue
<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。

Released under the MIT License.