手摸手教学如何使用Vue3.x封装ECharts组件

425 阅读8分钟

前言

日常开发中,echarts 使用是很频繁的一个可视化库。今天手摸手教学如何基于 vue3.x 自己封装一个简单、可拓展的可视化组件。

准备工作

  • node 版本 >=v14.18.1
  • typescript 版本 >=v4.9.0
  • vue 版本 v3.2.37
  • echarts 版本 >=5.0.0
  • vite 版本 v4.1.4

预览

点击预览 RayChart

思路

graph TD
DOM容器 --> Vue处理配置项 --> 注册并生成ECharts --> 保存ECharts实例 --> 根据实例进行拓展

开始

这里就不做如何配置、启动项目工程了,默认大家都配置好了。并且这里演示如何使用 tsx 进行组件开发。

import './index.scss'

import * as echarts from 'echarts/core' // `echarts` 核心模块
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent,
  LegendComponent,
  ToolboxComponent,
  AriaComponent,
} from 'echarts/components' // 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等组件(组件后缀都为 `Component`)
import {
  BarChart,
  LineChart,
  PieChart,
  CandlestickChart,
  ScatterChart,
  PictorialBarChart,
} from 'echarts/charts' // 系列类型(后缀都为 `SeriesOption`)
import { LabelLayout, UniversalTransition } from 'echarts/features' // 标签自动布局, 全局过渡动画等特性
import { CanvasRenderer } from 'echarts/renderers' // `echarts` 渲染器

import { useSetting } from '@/store'
import { cloneDeep, debounce } from 'lodash-es'
import { on, off, addStyle } from '@/utils/element'

import type { PropType } from 'vue'

export type AutoResize =
  | boolean
  | {
      width: number
      height: number
    }

export interface LoadingOptions {
  text: string // 文本内容
  color: string // 颜色
  textColor: string // 字体颜色
  maskColor: string // 遮罩颜色
  zlevel: number // 水平
  fontSize: number // 字体大小
  showSpinner: boolean // 是否显示旋转动画(`spinner`)
  spinnerRadius: number // 旋转动画(`spinner`)的半径
  lineWidth: number // 旋转动画(`spinner`)的线宽
  fontWeight: string // 字体粗细
  fontStyle: string // 字体风格
  fontFamily: string // 字体系列
}

export type ChartTheme = 'dark' | '' | object

/**
 *
 * @returns LoadingOptions
 *
 * 为了方便使用加载动画, 写了此方法, 虽然没啥用
 */
export const loadingOptions = (options?: LoadingOptions) =>
  Object.assign(
    {},
    {
      text: 'loading',
      color: '#c23531',
      textColor: '#000',
      maskColor: 'rgba(255, 255, 255, 0.9)',
      zlevel: 0,
      fontSize: 12,
      showSpinner: true,
      spinnerRadius: 10,
      lineWidth: 5,
      fontWeight: 'normal',
      fontStyle: 'normal',
      fontFamily: 'sans-serif',
    },
    options,
  )

const RayChart = defineComponent({
  name: 'RayChart',
  props: {
    width: {
      /**
       *
       * chart 容器初始化宽度
       *
       * 如果未能继承宽度, 则会以 200px 宽度填充
       */
      type: String,
      default: '100%',
    },
    height: {
      /**
       *
       * chart 容器初始化高度
       *
       * 如果未能继承高度, 则会以 200px 宽度填充
       */
      type: String,
      default: '100%',
    },
    autoResize: {
      /**
       *
       * `chart` 是否跟随窗口尺寸变化自动变化
       *
       * 如果为对象, 则可以指定其变化尺寸, 实现图表大小不等于容器大小的效果
       */
      type: [Boolean, Object] as PropType<AutoResize>,
      default: true,
    },
    canvasRender: {
      /**
       *
       * `chart` 渲染器, 默认使用 `canvas`
       *
       * 考虑到打包体积与大多数业务场景缘故, 暂时移除 `SVGRenderer` 渲染器的默认导入
       */
      type: Boolean,
      default: true,
    },
    showAria: {
      /**
       *
       * 是否开启 `chart` 无障碍访问
       *
       * 此选项会覆盖 `options` 中的 `aria` 配置
       */
      type: Boolean,
      default: false,
    },
    options: {
      type: Object as PropType<echarts.EChartsCoreOption>,
      default: () => ({}),
    },
    success: {
      /**
       *
       * 返回 chart 实例
       *
       * 渲染成功回调函数
       *
       * () => EChartsInstance
       */
      type: Function,
      default: () => ({}),
    },
    error: {
      /**
       *
       * 渲染失败回调函数
       *
       * () => void
       */
      type: Function,
      default: () => ({}),
    },
    theme: {
      type: [String, Object] as PropType<ChartTheme>,
      default: '',
    },
    autoChangeTheme: {
      /**
       *
       * 是否自动跟随模板主题切换
       *
       * 如果开启此属性, 则会覆盖 `theme` 属性
       *
       * 注意: 这个属性重度依赖此模板, 所以默认不开启. 并且动态切换主题有一定的性能问题
       */
      type: Boolean,
      default: false,
    },
    use: {
      /**
       *
       * 拓展 `echarts` 图表
       *
       * 由于官方并没有提供该类型, 手动去复刻成本过高, 故而采用 `any`
       */
      type: Array,
      default: () => [],
    },
    watchOptions: {
      /** 主动监听 options 变化 */
      type: Boolean,
      default: true,
    },
    loading: {
      /** 加载动画 */
      type: Boolean,
      default: false,
    },
    loadingOptions: {
      /** 配置加载动画样式 */
      type: Object as PropType<LoadingOptions>,
      default: () => loadingOptions(),
    },
  },
  setup(props) {
    const settingStore = useSetting()
    const { themeValue } = storeToRefs(settingStore)
    const rayChartRef = ref<HTMLElement>() // `echart` 容器实例
    const echartInstanceRef = ref<EChartsInstance>() // `echart` 拷贝实例, 解决直接使用响应式实例带来的问题
    let echartInstance: EChartsInstance // `echart` 实例
    let resizeDebounce: AnyFunc // resize 防抖方法实例

    const cssVarsRef = computed(() => {
      const cssVars = {
        '--ray-chart-width': props.width,
        '--ray-chart-height': props.height,
      }

      return cssVars
    })
    const modelLoadingOptions = computed(() =>
      loadingOptions(props.loadingOptions),
    )

    /**
     *
     * 注册 `echart` 组件, 图利, 渲染器等
     *
     * 会自动合并拓展 `echart` 组件
     * 该方法必须在注册图表之前调用
     */
    const registerChartCore = async () => {
      echarts.use([
        TitleComponent,
        TooltipComponent,
        GridComponent,
        DatasetComponent,
        TransformComponent,
        LegendComponent,
        ToolboxComponent,
        AriaComponent,
      ]) // 注册组件

      echarts.use([
        BarChart,
        LineChart,
        PieChart,
        CandlestickChart,
        ScatterChart,
        PictorialBarChart,
      ]) // 注册类型

      echarts.use([LabelLayout, UniversalTransition]) // 注册布局, 过度效果

      // 如果业务场景中需要 `svg` 渲染器, 手动导入渲染器后使用该行代码即可(不过为了体积考虑, 移除了 SVG 渲染器)
      // echarts.use([props.canvasRender ? CanvasRenderer : SVGRenderer])
      echarts.use([CanvasRenderer]) // 注册渲染器

      try {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        echarts.use(props.use as any[])
      } catch (e) {
        console.error(
          'Error: wrong property and method passed in extend attribute',
        )
      }
    }

    /**
     *
     * @returns `chart options`
     *
     * 合并配置项
     *
     * 如果有需要特殊全局配置的可以在此继续写...
     */
    const useMergeOptions = () => {
      let options = cloneDeep(props.options)

      const merge = (opts: object) => Object.assign({}, options, opts)

      if (props.showAria) {
        options = merge({
          aria: {
            enabled: true,
            decal: {
              show: true,
            },
          },
        })
      }

      return options
    }

    /**
     *
     * 渲染 `echart`
     *
     * 缓存两个实例
     *
     * 直接使用响应式代理实例会出现诡异的问题, 例如 `legend` 点击时报错
     */
    const renderChart = (theme: ChartTheme) => {
      /** 获取 dom 容器 */
      const element = rayChartRef.value as HTMLElement
      /** 获取配置项 */
      const options = useMergeOptions()
      /** 获取 dom 容器实际宽高 */
      const { height, width } = element.getBoundingClientRect()

      /** 如果高度为 0, 则以 200px 填充 */
      if (height === 0) {
        addStyle(element, {
          height: '200px',
        })
      }

      /** 如果款度为 0, 则以 200px 填充 */
      if (width === 0) {
        addStyle(element, {
          width: '200px',
        })
      }

      try {
        /** 注册 chart */
        echartInstance = echarts.init(element, theme)
        echartInstanceRef.value = echartInstance

        /** 设置 options 配置项 */
        options && echartInstance.setOption(options)

        /** 渲染成功回调 */
        props.success?.(echartInstance)
      } catch (e) {
        /** 渲染失败回调 */
        props.error?.()

        console.error(e)
      }
    }

    /**
     *
     * @param bool 渲染带有主题色的可视化图
     *
     * 区别自动跟随模板主题切换与指定主题切换
     */
    const renderThemeChart = (bool?: boolean) => {
      if (props.autoChangeTheme) {
        bool ? renderChart('dark') : renderChart('')

        return void 0
      }

      if (!props.theme) {
        renderChart('')
      }
    }

    /**
     *
     * 销毁 `chart` 实例, 释放资源
     */
    const destroyChart = () => {
      if (echartInstance) {
        echartInstance.clear()
        echartInstance.dispose()
      }
    }

    /** 重置 echarts 尺寸 */
    const resizeChart = () => {
      if (echartInstance) {
        echartInstance.resize()
      }
    }

    /** 监听全局主题变化, 然后重新渲染对应主题 echarts */
    watch(
      () => [themeValue.value],
      ([theme]) => {
        /**
         *
         * Q: 为什么需要重新卸载再渲染
         * A: 因为 echarts 官方文档并未提供动态渲染方法
         * A: 虽然原型上有 setTheme 方法, 但是官方标记是仅限于在类 ECharts 中访问
         */
        if (props.autoChangeTheme) {
          destroyChart()

          renderThemeChart(theme)
        }
      },
    )

    watch(
      () => props.showAria,
      () => {
        destroyChart()

        /**
         *
         * 贴花跟随主题渲染
         *
         * 自动跟随模板主题或者指定主题皆可
         */
        if (props.autoChangeTheme || props.theme) {
          themeValue.value ? renderChart('dark') : renderChart('')
        } else {
          renderChart('')
        }
      },
    )

    /** 显示/隐藏加载动画 */
    watch(
      () => props.loading,
      (newData) => {
        newData
          ? echartInstance?.showLoading(modelLoadingOptions.value)
          : echartInstance?.hideLoading()
      },
    )

    /** 监听 options 变化 */
    if (props.watchOptions) {
      watch(
        () => props.watchOptions,
        () => {
          /** 重新组合 options */
          const options = useMergeOptions()

          /** 如果 options 发生变动更新 echarts */
          echartInstance?.setOption(options)
        },
      )
    }

    onBeforeMount(async () => {
      /** 注册 echarts 组件与渲染器 */
      await registerChartCore()
    })

    onMounted(() => {
      nextTick(() => {
        /** 注册 echarts */
        if (props.autoChangeTheme) {
          renderThemeChart(themeValue.value)
        } else {
          props.theme ? renderChart('dark') : renderChart('')
        }

        /** 注册事件 */
        if (props.autoResize) {
          resizeDebounce = debounce(resizeChart, 500)

          on(window, 'resize', resizeDebounce)
        }
      })
    })

    onBeforeUnmount(() => {
      /** 卸载 echarts */
      destroyChart()
      /** 卸载事件柄 */
      off(window, 'resize', resizeDebounce)
    })

    return {
      rayChartRef,
      cssVarsRef,
      echartInstance: echartInstanceRef,
    }
  },
  render() {
    return (
      <div class="ray-chart" style={[this.cssVarsRef]} ref="rayChartRef"></div>
    )
  },
})

export default RayChart

/**
 *
 * 基于 `echarts` 的组件. 意在便捷的使用 `chart` 图
 *
 * 暂时不支持自动解析导入 `chart` 组件, 如果使用未注册的组件, 需要在顶部手动导入并且再使用 `use` 注册
 *
 * 预引入: 柱状图, 折线图, 饼图, k线图, 散点图等
 * 预引入: 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等
 *
 * 如果需要大批量数据渲染, 可以通过获取实例后阶段性调用 `setOption` 方法注入数据
 *
 * 该组件会在卸载组件时, 自动释放资源
 *
 * 注意: 尽量别一次性倒入全部 `chart` 会造成打包体积异常大
 */
.ray-chart {
  width: var(--ray-chart-width);
  height: var(--ray-chart-height);
  border: none;
  outline: none;
  box-sizing: border-box;
}

解析

代码都进行了逐行解析,该组件代码来自于 Ray Template,具体可以去该处查看。

使用

import './index.scss'

import { NCard, NSwitch, NSpace, NP, NH6, NH2, NH3 } from 'naive-ui'
import RayChart from '@/components/RayChart/index'

const Echart = defineComponent({
  name: 'Echart',
  setup() {
    const baseChartRef = ref()
    const chartLoading = ref(false)
    const chartAria = ref(false)
    const state = reactive({
      loading: false,
    })

    const baseOptions = {
      legend: {},
      tooltip: {},
      xAxis: {
        type: 'category',
        data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
      },
      yAxis: {
        type: 'value',
      },
      series: [
        {
          name: '日期',
          data: [120, 200, 150, 80, 70, 110, 130],
          type: 'bar',
          showBackground: true,
          backgroundStyle: {
            color: 'rgba(180, 180, 180, 0.2)',
          },
        },
      ],
    }
    const basePieOptions = {
      title: {
        text: 'Referer of a Website',
        subtext: 'Fake Data',
        left: 'center',
      },
      tooltip: {
        trigger: 'item',
      },
      legend: {
        orient: 'vertical',
        left: 'left',
      },
      series: [
        {
          name: 'Access From',
          type: 'pie',
          radius: '50%',
          data: [
            { value: 1048, name: 'Search Engine' },
            { value: 735, name: 'Direct' },
            { value: 580, name: 'Email' },
            { value: 484, name: 'Union Ads' },
            { value: 300, name: 'Video Ads' },
          ],
          emphasis: {
            itemStyle: {
              shadowBlur: 10,
              shadowOffsetX: 0,
              shadowColor: 'rgba(0, 0, 0, 0.5)',
            },
          },
        },
      ],
    }
    const baseLineOptions = {
      title: {
        text: 'Stacked Area Chart',
      },
      tooltip: {
        trigger: 'axis',
        axisPointer: {
          type: 'cross',
          label: {
            backgroundColor: '#6a7985',
          },
        },
      },
      legend: {
        data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine'],
      },
      toolbox: {
        feature: {
          saveAsImage: {},
        },
      },
      grid: {
        left: '3%',
        right: '4%',
        bottom: '3%',
        containLabel: true,
      },
      xAxis: [
        {
          type: 'category',
          boundaryGap: false,
          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
        },
      ],
      yAxis: [
        {
          type: 'value',
        },
      ],
      series: [
        {
          name: 'Email',
          type: 'line',
          stack: 'Total',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [120, 132, 101, 134, 90, 230, 210],
        },
        {
          name: 'Union Ads',
          type: 'line',
          stack: 'Total',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [220, 182, 191, 234, 290, 330, 310],
        },
        {
          name: 'Video Ads',
          type: 'line',
          stack: 'Total',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [150, 232, 201, 154, 190, 330, 410],
        },
        {
          name: 'Direct',
          type: 'line',
          stack: 'Total',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [320, 332, 301, 334, 390, 330, 320],
        },
        {
          name: 'Search Engine',
          type: 'line',
          stack: 'Total',
          label: {
            show: true,
            position: 'top',
          },
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [820, 932, 901, 934, 1290, 1330, 1320],
        },
      ],
    }

    const handleLoadingShow = (bool: boolean) => {
      state.loading = bool
    }

    const handleAriaShow = (bool: boolean) => {
      chartAria.value = bool
    }

    const handleChartRenderSuccess = (chart: EChartsInstance) => {
      window.$notification.info({
        title: '可视化图渲染成功回调函数',
        content: '可视化图渲染成功, 并且返回了当前可视化图实例',
        duration: 5 * 1000,
      })

      console.log(chart)
    }

    return {
      baseOptions,
      baseChartRef,
      chartLoading,
      handleLoadingShow,
      chartAria,
      handleAriaShow,
      handleChartRenderSuccess,
      basePieOptions,
      baseLineOptions,
      ...toRefs(state),
    }
  },
  render() {
    return (
      <div class="echart">
        <NH2>RayChart 组件使用</NH2>
        <NP>
          该组件会默认以 200*200
          宽高进行填充。预设了常用的图、方法组件,如果不满足需求,需要用 use
          方法进行手动拓展。该组件实现了自动跟随模板主题切换功能,但是动态切换损耗较大,所以默认不启用
        </NP>
        <NH2>基础使用</NH2>
        <div class="chart--container">
          <RayChart options={this.baseOptions} />
        </div>
        <NH2>渲染成功后运行回调函数</NH2>
        <div class="chart--container">
          <RayChart
            options={this.basePieOptions}
            success={this.handleChartRenderSuccess.bind(this)}
          />
        </div>
        <NH2>能跟随主题切换的可视化图</NH2>
        <div class="chart--container">
          <RayChart
            autoChangeTheme
            options={this.baseLineOptions}
            showAria={this.chartAria}
          />
        </div>
        <NH2>不跟随主题切换的暗色主题可视化图</NH2>
        <div class="chart--container">
          <RayChart theme="dark" options={this.baseOptions} />
        </div>
        <NH2>加载动画</NH2>
        <NSwitch
          v-model:value={this.chartLoading}
          onUpdateValue={this.handleLoadingShow.bind(this)}
          style={['padding: 18px 0']}
        >
          {{
            checked: () => '隐藏加载动画',
            unchecked: () => '显示加载动画',
          }}
        </NSwitch>
        <div class="chart--container">
          <RayChart loading={this.loading} options={this.baseOptions} />
        </div>
        <NH2>贴画可视化图</NH2>
        <NSwitch
          v-model:value={this.chartAria}
          onUpdateValue={this.handleAriaShow.bind(this)}
          style={['padding: 18px 0']}
        >
          {{
            checked: () => '隐藏贴花',
            unchecked: () => '显示贴花',
          }}
        </NSwitch>
        <div class="chart--container">
          <RayChart options={this.baseOptions} showAria={this.chartAria} />
        </div>
      </div>
    )
  },
})

export default Echart

感谢阅读,欢迎多多指教