vue3 echart元件封裝

Shapeying發表於2024-02-29

專案中用到了很多echart圖表,進行了簡單的元件封裝,主要包含以下功能:

  • 建立圖表例項,渲染圖表
  • 支援傳入自定義函式,可拿到圖表例項,實現個性化功能
  • 支援配置更新後圖表自動重新整理,可配置是清空後再重新整理
  • loading狀態控制
  • resize時圖表更新
  • 支援餅圖預設高亮功能

實現

資源引入

  • echart資源按需引入
  • 第三方元件引入(echarts-liquidfill,水波紋圖表)
/* 即下文中的 @/modules/echartPlugin */

// https://echarts.apache.org/handbook/zh/basics/import#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
import * as echarts from "echarts/core";
import {
  BarChart,
  // 系列型別的定義字尾都為 SeriesOption
  BarSeriesOption,
  PieChart,
  PieSeriesOption,
  LineChart,
  LineSeriesOption,
  LinesChart,
  LinesSeriesOption,
  EffectScatterChart,
  EffectScatterSeriesOption,
} from "echarts/charts";
import {
  TitleComponent,
  // 元件型別的定義字尾都為 ComponentOption
  TitleComponentOption,
  TooltipComponent,
  TooltipComponentOption,
  DatasetComponent,
  DatasetComponentOption,
  GridComponent,
  GridComponentOption,
  DataZoomComponent,
  DataZoomComponentOption,
  LegendComponent,
  LegendComponentOption,
  GeoComponent,
  GeoComponentOption,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import "echarts-liquidfill";

// 透過 ComposeOption 來組合出一個只有必須元件和圖表的 Option 型別
export type ECOption = echarts.ComposeOption<
  | BarSeriesOption
  | TitleComponentOption
  | TooltipComponentOption
  | GridComponentOption
  | DatasetComponentOption
  | DataZoomComponentOption
  | PieSeriesOption
  | LegendComponentOption
  | GeoComponentOption
  | LinesSeriesOption
  | LineSeriesOption
  | EffectScatterSeriesOption
>;

// https://www.npmjs.com/package/echarts-liquidfill
export interface LiquidFillOption {
  series: {
    type: "liquidFill";
    data: number[];
    color?: string[];
    radius?: string;
    center?: [string, string];
    label?: {
      color?: string;
      insideColor?: string;
      fontSize?: number;
      formatter?: (param: {
        borderColor: string;
        color: string;
        data: number;
        dataIndex: number;
        dataType: undefined;
        name: string;
        value: number;
      }) => string | number;
    };
    shape?:
      | "circle"
      | "rect"
      | "roundRect"
      | "triangle"
      | "diamond"
      | "pin"
      | "arrow";
    [name: string]: unknown;
  }[];
  [name: string]: unknown;
}

// 註冊必須的元件
echarts.use([
  TitleComponent,
  TooltipComponent,
  GridComponent,
  BarChart,
  LinesChart,
  CanvasRenderer,
  DatasetComponent,
  DataZoomComponent,
  PieChart,
  LegendComponent,
  GeoComponent,
  LineChart,
  EffectScatterChart,
]);

export default echarts;

元件封裝

<template>
  <div class="h-echart-wrapper" ref="chartWrapperDom">
    <div class="h-echart" ref="chartDom">loading</div>
  </div>
</template>
<script lang="ts" src="./index.ts"></script>
<style lang="less" scoped>
.h-echart-wrapper {
  height: 100%;
}
.h-echart {
  height: 100%;
  width: 100%;
  text-align: center;
}
</style>
import {
  defineComponent,
  onMounted,
  onUnmounted,
  PropType,
  ref,
  watch,
  toRaw,
} from "vue";
import echarts, { ECOption, LiquidFillOption } from "@/modules/echartPlugin";
import ResizeObserver from "resize-observer-polyfill";

export default defineComponent({
  name: "h-echart",
  props: {
    // echart配置
    options: {
      type: Object as PropType<ECOption | LiquidFillOption>,
      required: true,
    },
    // 餅圖是否需要預設高亮
    needDefaultHighLight: {
      type: Boolean,
      default: false,
    },
    loading: Boolean,
    // 自定義函式,會暴露echart例項出去,可以實現個性化操作
    customFn: Function as PropType<
      (echartInstance: null | echarts.ECharts) => void
    >,
    // 更新圖表之前是否先清空
    clearBeforeUpdate: Boolean,
  },
  setup(props) {
    const chartWrapperDom = ref<null | HTMLElement>(null);
    const chartDom = ref<null | HTMLElement>(null);
    // WARN: echarts5 例項用響應式物件存放時會導致功能tooltip功能異常
    let echartInstance: null | echarts.ECharts = null;
    let chartWrapperResize: null | ResizeObserver = null;
    let highlightName: string | null = null;
    let firstRender = true;

    const setOptions = (options?: ECOption | LiquidFillOption) => {
      echartInstance &&
        options &&
        echartInstance.setOption(toRaw(options), {
          notMerge: true,
        });

      if (props.needDefaultHighLight && firstRender) {
        firstRender = false;
        const _options = props.options as ECOption;

        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        if (_options.series && _options.series[0] && _options.series[0].data) {
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
          // @ts-ignore
          const name = _options.series[0].data[0].name as string;

          setTimeout(() => {
            // 預設高亮
            echartInstance &&
              echartInstance.dispatchAction({
                type: "highlight",
                seriesIndex: 0,
                name,
              });

            highlightName = name;
          }, 600);
        }
      }
    };

    watch(
      () => props.loading,
      (newLoading) => {
        if (newLoading !== undefined && echartInstance) {
          newLoading
            ? echartInstance.showLoading({
                textColor: "rgb(255 255 255 / 0%)",
                showSpinner: false,
                zlevel: 0,
              })
            : echartInstance.hideLoading();
        }
      }
    );

    const init = () => {
      chartDom.value && (echartInstance = echarts.init(chartDom.value));

      props.customFn && props.customFn(echartInstance);

      if (props.needDefaultHighLight && echartInstance) {
        echartInstance.on("mouseover", function (e) {
          if (e.name !== highlightName) {
            echartInstance!.dispatchAction({
              type: "downplay",
              seriesIndex: 0,
              name: highlightName,
            });
          }
        });

        echartInstance.on("mouseout", function (e) {
          highlightName = e.name;
          echartInstance!.dispatchAction({
            type: "highlight",
            seriesIndex: 0,
            name: e.name,
          });
        });
      }
      setOptions(props.options);
    };

    onMounted(() => {
      // 初始化圖表例項
      setTimeout(init, 300);

      // 觀察包裹層變化,進行圖表resize
      if (chartWrapperDom.value) {
        chartWrapperResize = new ResizeObserver(() => {
          echartInstance && echartInstance.resize();
        });

        chartWrapperResize.observe(chartWrapperDom.value);
      }
    });

    // 觀察者清理
    onUnmounted(() => {
      chartWrapperResize?.disconnect();
    });
    watch(
      () => props,
      // 配置變化,重新設定
      (newVal) => {
        if (newVal.clearBeforeUpdate) {
          echartInstance && echartInstance.clear();
        }
        setOptions(toRaw(newVal.options));
      },
      { immediate: true, deep: true }
    );

    return {
      chartDom,
      chartWrapperDom,
    };
  },
});

元件註冊及全域性型別宣告

/* ./components/index.ts */
import { App } from "vue";
import HEchart from "./h-echart";
import HIframeKeepAlive from "./h-iframe-keep-alive/index.vue";

export default function useCompoments(app: App<Element>) {
  app &&
    app.component &&
    [
      HEchart,
      HIframeKeepAlive,
    ].forEach((_component) => {
      app.component(_component.name, _component);
    });
}

// 宣告全域性元件型別
// https://github.com/johnsoncodehk/volar/blob/master/extensions/vscode-vue-language-features/README.md
declare module "@vue/runtime-core" {
  export interface GlobalComponents {
    HEchart: typeof HEchart;
    HIframeKeepAlive: typeof HIframeKeepAlive;
  }
}
import useCompoments from "./components";

const app = createApp(App).use(router);
tempApp = app;

// 註冊所自定義元件
useCompoments(app);

使用

    <div class="chart-wrapper">
      <h-echart :options="boardPieOptions" needDefaultHighLight />
    </div>
  const boardPieOptions = computed(() => {
    return getBoardPieOptions(props.arrivalNodeStats.types);
  });

相關文章