Skip to content

告别繁琐!Vue3 组合式函数解锁 Echarts 封装新姿势 #6

@bryqiu

Description

@bryqiu

前言

本篇文章主要讲解如何使用组合式函数(Composables)来封装 Echarts,提供一套可复用、易维护的图表解决方案

在这里你能够学到 Echarts 封装的思路与最佳实践,理解 Echarts 的特性与使用技巧

本文也是《通俗易懂的中后台系统建设指南》系列的第六篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

什么是 Echarts

Echarts 是一个基于 JavaScript 的开源可视化图表库,具有丰富的图表类型和强大的交互能力,凭借其优秀的性能和灵活的配置,成为前端可视化主流方案

Echarts 的使用场景很多,比如:

  • 数据监控和实时数据展示,比如物联网、实时数据监控、金融
  • 业务数据可视化,比如电商销售、商品价格等
  • 地理信息可视化,比如物流跟踪、商圈分析、气象预报
  • 报表数据直观可视化,比如财务报表

功能列表

在本文中,我们会实现这些功能:

  • 自适应不同屏幕尺寸变化,结合防抖机制提升性能,确保流畅体验
  • 灵活配置图表样式与行为,满足多样化的使用场景
  • 支持明暗主题动态切换,提供良好的视觉体验
  • Loading 状态管理,优化加载体验
  • 支持图表交互事件
  • 支持图表导出与截图功能

本文的基础开发环境

技术栈:Vue3 + TS + Vite
包管理器:pnpm
组合式函数工具库:VueUse
可视化图表库:Echarts
原子化 CSS:tailwindcss

在项目中引入 Echarts

pnpm add echarts --save

引入 Echarts 分为全量引入和按需引入,这里选择按需引入,参阅 NPM 安装 ECharts

我这里放在 plugins 文件夹下,且新建一个 echarts.ts 文件,写入以下配置:

// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
import * as echarts from 'echarts/core';
// 引入柱状图图表,图表后缀都为 Chart
import { BarChart } from 'echarts/charts';
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent
} from 'echarts/components';
// 标签自动布局、全局过渡动画等特性
import { LabelLayout, UniversalTransition } from 'echarts/features';
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer } from 'echarts/renderers';

// 注册必须的组件
echarts.use([
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent,
  BarChart,
  LabelLayout,
  UniversalTransition,
  CanvasRenderer
]);

export { echarts }

这是 Echarts 的起步配置,下面我们将通过组合式函数来封装 Echarts

开始

src/hooks 目录下新建一个 use-echarts.ts 文件,这个文件表示使用组合式函数来封装图表逻辑(在 React 里被称为 Hooks)

import { Ref } from 'vue';

export const useEcharts = (dom: Ref<HTMLDivElement | HTMLCanvasElement | null>, config = {}) => {};
  • dom:要绑定的 DOM 元素
  • config:接收一些外部传入的配置项(在下文实现中用到)

图表初始化

创建一个 initChart 函数,用于初始化图表

import { Ref, onMounted } from 'vue';
import { echarts } from '@/plugins/echarts';
import type { EChartsInitOpts } from 'echarts';

interface ConfigProps {
  /**
   * init函数基本配置
   * @see https://echarts.apache.org/zh/api.html#echarts.init
   */
  echartsInitOpts?: EChartsInitOpts;
}

export const useEcharts = (
  dom: Ref<HTMLDivElement | HTMLCanvasElement | null>,
  config: ConfigProps = {},
) => {
  const { echartsInitOpts } = config;
  
  /** 图表实例 */
  let chartInstance: NullType<echarts.ECharts> = null;

  /** 图表初始化 */
  const initChart = () => {
    if (!dom.value || echarts.getInstanceByDom(dom.value)) return;
    chartInstance = echarts.init(dom.value, null, echartsInitOpts);
  };

  /** 获取图表实例 */
  const getChartInstance = () => chartInstance;

  onMounted(() => {
    initChart();
  });
  
  return {
    getChartInstance,
  };
};

注意,这里用到了 NullType,这是一个简单的泛型,type NullType<T> = T | null;

在上面的代码逻辑里,核心代码是  echarts.init

echarts.init 函数接受三个参数,第一个必填参数是要绑定的 dom 节点,第二个可选参数是主题 theme,这里先设置为null,第三个可选参数是一些配置项,比如指定宽、高度,指定语言 locale等,然后会返回一个 ECharts 实例,参阅 echarts.init

最后在组件挂载完成后(onMounted)执行 initChart 函数

图表的销毁

上述我们写了一个初始化函数,这里我们来写一下相对的销毁逻辑

chartInstance 实例上挂载了一个 dispose 函数,这个方法可用于销毁实例,参阅 echarts.dispose

import { onUnmounted } from 'vue';

  /** 图表销毁 */
  const destroyChart = () => {
    if (!chartInstance) return;
    chartInstance.dispose();
    chartInstance = null;
  };
  
  // 组件实例被卸载之后
  onUnmounted(() => {
    destroyChart();
  });

上面这段代码的作用主要是在容器被销毁后,通过执行 destroyChart 函数来释放资源,避免内存泄漏

渲染图表与配置options

chartInstance 实例上挂载了一个 setOption 函数,用于设置图表实例的配置项以及数据

我们的总体思路是:导出一个函数,由外部传入 options 数据进行图表渲染

所以,创建一个 renderChart 函数,表示这个函数用于将数据渲染成可视化图表

import type { EChartsCoreOption, EChartsInitOpts, SetOptionOpts } from 'echarts';

  /**
   * 图表渲染
   * @param options 图表数据集
   * @param opts 图表配置项
   */
  const renderChart = (options: EChartsCoreOption, opts: SetOptionOpts = { notMerge: true }) => {
    const finalOptions = { ...options, backgroundColor: 'transparent' };
    chartInstance.setOption(finalOptions, opts);
  };

在上述代码中,renderChart 函数接收两个参数,第一个参数是图表的数据集,第二个可选参数是针对数据项的设置,有两种配置,参阅 这里,在此处代码示例中,我们约定接收一个对象,它的类型配置如下:

(option: Object, opts?: {
    notMerge?: boolean;// 是否不跟之前设置的 `option` 进行合并
    replaceMerge?: string | string[];// 用户可以在这里指定一个或多个组件
    lazyUpdate?: boolean;// 在设置完 `option` 后是否不立即更新图表
})

响应式图表尺寸与防抖

响应式尺寸

Echarts 官网-响应式容器大小变化章节的介绍中,我们可以看到主要是通过监听页面的 resize 事件获取浏览器大小改变的事件,然后调用 echartsInstance.resize 改变图表的大小,文章中也提到一种更细粒度的方法是通过 ResizeObserver API 来监听尺寸变化

所以,这里我们可以借助 Vue 生态中的工具型组合式函数集合 VueUse 中的 useResizeObserver 来实现

注意,在这一步需要确保项目中安装了依赖 VueUse,如果没有,使用命令 pnpm add @vueuse/core 安装

创建一个 resize 函数,写入以下内容:

import { Ref, onMounted, onUnmounted } from 'vue';
import { echarts } from '@/plugins/echarts';
import { useResizeObserver } from '@vueuse/core';
import type { UseResizeObserverReturn } from '@vueuse/core';
import type { EChartsCoreOption, EChartsInitOpts, SetOptionOpts } from 'echarts';

interface ConfigProps {
  /**
   * 是否开启过渡动画
   * @default true
   */
  animation?: boolean;
  /**
   * 过渡动画持续时间(ms)
   * @default 300
   */
  animationDuration?: number;
  /**
   * 是否自动调整大小
   * @default true
   */
  autoResize?: boolean;
}

const DEFAULT_CONFIG: ConfigProps = {
  animation: true,
  animationDuration: 300,
  autoResize: true,
};

export const useEcharts = (
  dom: Ref<HTMLDivElement | HTMLCanvasElement | null>,
  config: ConfigProps,
) => {
  const { animation, animationDuration, autoResize } = { ...DEFAULT_CONFIG, ...config };
  
  /** 图表实例 */
  let chartInstance: NullType<echarts.ECharts> = null;

  /** 图表尺寸变化监听 */
  let resizeObserver: NullType<UseResizeObserverReturn> = null;

  //...
  
  /** 图表销毁 */
  const destroyChart = () => {
    if (autoResize && resizeObserver) {
      resizeObserver.stop();
      resizeObserver = null;
    }
    //...
  };
  
  /** 调整图表尺寸 */
  const resize = () => {
    if (!chartInstance) return;
    chartInstance.resize({
      animation: {
        duration: animation ? animationDuration : 0,
      },
    });
  };
  
  onMounted(() => {
	//...
    if (autoResize) {
      resizeObserver = useResizeObserver(dom, resize);
    }
  });

  // 组件实例被卸载之后
  onUnmounted(() => {
    destroyChart();
  });

  return {
    renderChart,
  };
};

在上面的代码中,着重关注这几点:

  • resizeObserver:一个可为空的 UseResizeObserverReturn 类型对象,用于监听指定 DOM 元素的尺寸变化并在变化时执行回调函数
  • configDEFAULT_CONFIG:参数配置项及默认配置
  • chartInstance.resize:核心方法,调整图表尺寸
  • destroyChart 函数上使用 resizeObserver 方法 stop 停止监听尺寸

防抖

在上述代码中,我们实现了响应式的尺寸功能。然而,当尺寸频繁变化时,可能会对性能造成较大压力。为提升性能,我们可以进行进一步优化,加入防抖,这里借助 VueUseuseDebounceFn来实现

防抖:在事件持续触发时,不会立即执行,而是会等待一段时间后执行。若在等待时间内事件再次触发,则重新计时。只有在事件停止触发后的指定时间内,没有新的事件触发时,才会执行事件

import { Ref, onMounted, onUnmounted } from 'vue';
import { echarts } from '@/plugins/echarts';
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
import type { UseResizeObserverReturn } from '@vueuse/core';
import type { EChartsCoreOption, EChartsInitOpts, SetOptionOpts } from 'echarts';

interface ConfigProps {
  //...
  
  /**
   * 防抖时间(ms)
   * @default 300
   */
  resizeDebounceWait: number;
  /**
   * 最大防抖时间(ms)
   * @default 500
   */
  maxResizeDebounceWait: number;
}

const DEFAULT_CONFIG: ConfigProps = {
  animation: true,
  animationDuration: 300,
  autoResize: true,
  resizeDebounceWait: 300,
  maxResizeDebounceWait: 500,
};

export const useEcharts = (
  dom: Ref<HTMLDivElement | HTMLCanvasElement | null>,
  config: ConfigProps,
) => {

  const {
    echartsInitOpts,
    animation,
    animationDuration,
    autoResize,
    resizeDebounceWait,
    maxResizeDebounceWait,
  } = { ...DEFAULT_CONFIG, ...config };

  //...

  /** 调整图表尺寸 */
  const resize = () => {
  //...
  };
  
  /** 防抖处理的resize */
  const resizeDebounce = useDebounceFn(resize, resizeDebounceWait, {
    maxWait: maxResizeDebounceWait,
  });

 onMounted(() => {
    //...
    if (autoResize) {
      resizeObserver = useResizeObserver(dom, resizeDebounce);
    }
  });
  
};

这里你需要关注这几点:

  • useDebounceFn:防抖函数,由 VueUse 提供
  • config 参数,DEFAULT_CONFIG 默认配置中新加入了 resizeDebounceWait 防抖时间、maxResizeDebounceWait 防抖最大时间

到这一步,其实基础的图表已经能够实现,我们来测试一下效果

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useEcharts } from '@/hooks/use-echarts';
import { userVisitOption } from './data';

const userInstance = ref<NullType<HTMLDivElement>>(null);

const { renderChart } = useEcharts(userInstance);

onMounted(() => {
  renderChart(userVisitOption);
});
</script>

<template>
<!--其他内容略...-->
<div ref="userInstance" class="w-full h-72" />
</template>

userVisitOption 表示你的图表数据集

Kapture 2024-11-09 at 14 50 13

图表的主题模式切换

一般来说,主题模式分为明亮模式和暗黑模式,Echarts 除了一贯的默认主题外,还支持暗黑模式

图表的主题切换需要通过 echarts.init 的第二个参数进行配置,我们在上述的[图表初始化](# 图表初始化)章节中简单提了一句,现在来完善它

在类型定义中加入 themeMode,它的联合类型中:

  • dark 代表 Echarts 内置的暗黑模式
  • string 类型表示你可以传入注册的主题名称
  • null 表示为默认的明亮模式
interface ConfigProps {
  //...略
  
  /**
   * 主题模式
   */
  themeMode?: 'dark' | string | null;
}
import { computed, watch } from 'vue';
import { useDark } from '@vueuse/core';

export const useEcharts = (
  dom: Ref<HTMLDivElement | HTMLCanvasElement | null>,
  config: ConfigProps,
) => {

  const {
    //...
    themeMode,
  } = { ...DEFAULT_CONFIG, ...config };

  const isDark = useDark();

  /** 当前主题 */
  const currentTheme = computed(() => {
    // 如果设置了自定义主题模式,优先使用
    if (themeMode || isNull(themeMode)) {
      return themeMode;
    }

    // 否则根据系统主题自动切换
    return isDark.value ? 'dark' : null;
  });
  
  /** 图表初始化 */
  const initChart = async () => {
    //...
    chartInstance = echarts.init(dom.value, currentTheme.value, echartsInitOpts);
  };

  // 监听主题变化,自动重新初始化图表
  watch(currentTheme, async () => {
    if (!chartInstance) return;
    destroyChart();
    await initChart();

    if (chartOptions.value) {
      renderChart(chartOptions.value);
    }
  });
  
};

你需要关注的几个点:

  • useDark:由 VueUse 提供的组合式函数,响应式暗黑模式状态,参阅 useDark
  • 监听 currentTheme 变量:当变量改变时,先销毁图表实例,再重新初始化和渲染数据集

最终效果是这样的:

Kapture 2024-11-13 at 17 54 36

阶段总结

上面几个章节中,我们实现了一个图表基本的功能,到这一步,图表已经是可以拿来即用的状态了

在全面阅读 Echarts 的文档时,发现其 Api 极其丰富,可实现的功能也很多,下面会介绍一些丰富性的内容

loading状态

chartInstance 实例上挂载了2个方法,用于显示/隐藏 Loading 效果,分别是 showLo adinghideLoading

正如官网所说的:可以在加载数据前手动调用该接口显示加载动画,在数据加载完成后调用 hideLoading 隐藏加载动画

  /** 图表实例 */
  let chartInstance: NullType<echarts.ECharts> = null;
  
  /** Loading 状态控制 */
  const toggleLoading = (show: boolean) => {
    if (!chartInstance) return;
    show ? chartInstance.showLoading('default') : chartInstance.hideLoading();
  };
  
  /** 图表初始化 */
  const initChart = async () => {
    //...
    toggleLoading(true);
  };
  
  /**
   * 图表渲染
   * @param options 图表数据
   * @param opts 图表配置项
   */
  const renderChart = (options: EChartsCoreOption, opts: SetOptionOpts = { notMerge: true }) => {
    //...
    toggleLoading(false);
  };

  return {
    toggleLoading,
  };

事件支持

Echarts 的事件与行为文章中向我们介绍了事件相关的内容,可以通过用户操作触发事件,监听事件以触发回调函数进行自定义需求处理

同时,在上述的初始化篇章里,我们有导出一个 getChartInstance 函数,这个函数用于返回一个图表实例,所以,我们可以这样做

此部分不涉及到 echarts 封装代码,只是使用示例

import { onMounted } from 'vue';

import { userVisitOption } from './data';
const { renderChart, getChartInstance } = useEchartss(userInstance);

onMounted(() => {
  renderChart(userVisitOption);
  
  getChartInstance()?.on('click', (params: any) => {
    console.log('点我查看信息:', params);
  });
});

导出图片

chartInstance 实例上挂载了一个 getDataURL 方法,该方法返回一个 base64 的 URL

首先先来定义配置项的类型与默认配置

interface DataURLOptions {
  /**
   * 导出的格式,可选 png, jpg, svg
   * @default png
   */
  type?: 'png' | 'jpeg' | 'svg';
  /**
   * 导出的图片分辨率比例
   * @default 1
   */
  pixelRatio?: number;
  /**
   * 导出的图片背景色
   * @default #fff
   */
  backgroundColor?: Color;
  /**
   * 导出的图片排除的列表
   */
  excludeComponents?: string[];
}

/** 导出文件默认配置 */
const DEFAULT_EXPORT_OPTIONS: DataURLOptions = {
  type: 'png',
  pixelRatio: 1,
  backgroundColor: '#fff',
  excludeComponents: [],
};

然后写一个 downloadImage 方法。方法很简单,base64 数据通过 downloadFile 方法内 JS 创建了 a 标签进行点击下载

  /** 下载图表文件 */
  const downloadImage = (fileName: string, options?: DataURLOptions) => {
    if (!chartInstance) return;
    const baseOptions: DataURLOptions = {
      ...DEFAULT_EXPORT_OPTIONS,
      ...options,
    };
    const dataURL = chartInstance.getDataURL(baseOptions);

    const finalFileName = /^[a-z0-9]+$/i.test(fileName.trim())
      ? fileName
      : `${fileName.trim()}.${baseOptions.type}`;

    downloadFile(dataURL, finalFileName);
  };

  return {
    //...略
    downloadImage,
  };

文中的 downloadFile 是一个工具函数,这里讲它超出了本文的内容,如果你需要的话,可以在这里找到源码

完整代码

本文中完整的示例代码可以在这里找到:use-echarts

参考资料

不要再编写冗余的ECharts代码了,带你封装一个EChatrs Hook

了解更多

系列专栏地址:GitHub | 掘金专栏 | 思否专栏

此系列实战项目:vue-clean-admin

专栏往期回顾:

  1. 收下这份 Vue + TS + Vite 中后台系统搭建指南,从此不再害怕建项目
  2. 中后台开发必修课:Vue 项目中 Pinia 与 Router 完全攻略
  3. 用了这些 Vite 配置技巧,同事都以为我开挂了
  4. 受够了团队代码风格不统一?7千字教你从零搭建代码规范体系
  5. 开发者必看!在团队中我是这样实现 Git 提交规范化的

交流讨论

文章如有错误或需要改进之处,欢迎指正

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions