React+TypeScript封装ECharts

项目中需要在 React + TypeScript 技术栈下的前端绘制 ECharts,没有找到比较完整的封装,所以自己来写一个。

在 Github 上有看到开源的方案例如 echarts-for-react,也可以作为参考。不使用开源方案还是希望可以自己理解和维护代码。

版本信息
React: 17.x/18.x
Typescript: 4.7.x
ECharts: 5.3.x


一、按需引入

封装 ECharts 还是要从官方指南出发,可以在官网使用手册中看到对 TS 按需引入的指导。

我们新建一个 MyCharts.tsx 文件,把相关代码复制进来。这里面主要涉及以下几个部分:

  1. 引入核心模块。
  2. 引入需要的图表类型,比如柱状图 BarChart、折线图 LineChart、散点图 ScatterChart、饼图 PieChart 等。同时也需要引入关联的系列配置,官方说明了它们的后缀由 Chart 换成 SeriesOption 即可。
    在封装的时候需要把自己常用的图表类型都引入进来,否则没有办法在封装模块的基础上使用没有引入的图表。具体有哪些图表和系列,可以参考配置项手册。
  3. 引入需要的组件类型,比如标题组件 TitleComponent、图例组件 LegendComponent、提示框组件 TooltipComponent 等。同时也需要引入关联的组件配置,官方说明了它们的后缀由 Component 换成 ComponentOption 即可。
    具体有哪些组件,也可以参考配置项手册。
  4. 引入一些特性,可用的只有两个,标签自动布局特性 LabelLayout 和全局过渡动画特性 UniversalTransition
  5. 引入渲染器,有 CanvasRendererSVGRenderer 两种,相比 Canvas 画的是位图而 SVG 画的是矢量图,Canvas 性能更好一点而 SVG 节点过多时渲染慢。个人比较喜欢用 SVG 渲染器,很多时候会更清晰。
  6. 通过组合所有引入的 SeriesOption 和 ComponentOption,构造一个合法的 option 配置项类型,它决定了当前封装模块可以使用配置项手册中的哪些。
  7. 把引入的必要的组件注册给 ECharts。

在熟悉了所有组件的基础上,可以按照自己的需求和习惯,重新整理一份按需引入的代码。

下面完整代码引入了柱状图和折线图作为封装支持的图表类型,并改用 SVG 渲染器。

import * as echarts from 'echarts/core';
import {
  DatasetComponent,
  DatasetComponentOption,
  DataZoomComponent,
  DataZoomComponentOption,
  GridComponent,
  GridComponentOption,
  LegendComponent,
  LegendComponentOption,
  TitleComponent,
  TitleComponentOption,
  ToolboxComponent,
  ToolboxComponentOption,
  TooltipComponent,
  TooltipComponentOption
} from 'echarts/components';
import {BarChart, BarSeriesOption, LineChart, LineSeriesOption} from 'echarts/charts';
import {UniversalTransition} from 'echarts/features';
import {SVGRenderer} from 'echarts/renderers';

echarts.use([
  DatasetComponent,
  DataZoomComponent,
  GridComponent,
  LegendComponent,
  TitleComponent,
  ToolboxComponent,
  TooltipComponent,
  LineChart,
  BarChart,
  UniversalTransition,
  SVGRenderer,
]);

export type MyChartOption = echarts.ComposeOption<
  | DatasetComponentOption
  | DataZoomComponentOption
  | GridComponentOption
  | LegendComponentOption
  | TitleComponentOption
  | ToolboxComponentOption
  | TooltipComponentOption
  | LineSeriesOption
  | BarSeriesOption
>;

二、函数组件

接下来需要初始化一个函数组件,封装一些基础的功能。

组件至少需要一个满足 MyChartOption 类型的 option 配置项作为参数,我们先写一个接口。

export interface MyChartProps {
  option: MyChartOption;
}

然后编写函数组件,目的是根据传入的配置项,使用 charts.init() 函数初始化一个 ECharts 实例,并挂载在一个 div 元素上。

为了避免使用 document.getElementById('main') 这种写法,为 div 元素维护成一个 Ref 对象 cRef,同时将我们即将创建的图表实例也维护成一个 Ref 对象 cInstance

const MyChart: React.FC<MyChartProps> = ({option}) => {
  const cRef = useRef<HTMLDivElement>(null);
  const cInstance = useRef<EChartsType>();

  // 初始化注册组件,监听 cRef 和 option 变化
  useEffect(() => {
    if (cRef.current) {
      // 校验 Dom 节点上是否已经挂载了 ECharts 实例,只有未挂载时才初始化
      cInstance.current = echarts.getInstanceByDom(cRef.current);
      if (!cInstance.current) {
        cInstance.current = echarts.init(cRef.current, undefined, {
          renderer: 'svg',
        });
      }
      // 设置配置项
      if (option) cInstance.current?.setOption(option);
    }
  }, [cRef, option]);

  return (
    <div ref={cRef} style={{width: 500, height: 300}}/>
  );
};

export default MyChart;

此时简单的封装已经完成了,我们任意找一个页面并绘制一下官方示例中最简单的折线图。

import React from 'react';
import MyChart, { MyChartOption } from '@/components/MyChart';

const MyPage: React.FC = () => {
  const option = {
    xAxis: {
      type: 'category',
      data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    },
    yAxis: {
      type: 'value'
    },
    series: [
      {
        data: [150, 230, 224, 218, 135, 147, 260],
        type: 'line'
      }
    ]
  } as MyChartOption;

  return (
    <MyChart option={option}/>
  );
}

export default MyPage;

绘制得到的图表如下:
React+TypeScript封装ECharts_第1张图片
不过目前封装的函数组件还比较粗糙,需要对功能进行进一步优化。

三、自适应宽高

首先把写死的图表宽高数据改成可配置参数,这样使用者可以灵活地根据场景决定使用像素值或百分比。通常我们会指定高度为像素值和宽度为百分比,或者全部使用 100% 靠父容器控制大小。

同时还有一个非常常用的设置,如果我们使用百分比来控制图表大小,我们希望当页面窗口发生变化的时候,图表可以自动调整大小,因此还需要添加一个 resize 监听事件。

如果你有可能会手动修改宽度和高度,还可以额外监听它们。

export interface MyChartProps {
  option: MyChartOption;
  width: number | string;
  height: number | string;
}

const MyChart: React.FC<MyChartProps> = ({option, width, height}) => {
  ...

  // 监听窗口大小变化重绘
  useEffect(() => {
    window.addEventListener('resize', resize);
    return () => {
      window.removeEventListener('resize', resize);
    };
  }, [option]);

  // 监听高度变化
  useLayoutEffect(() => {
    resize();
  }, [width, height]);

  // 重新适配大小并开启过渡动画
  const resize = () => {
    cInstance.current?.resize({
      animation: {duration: 300}
    });
  }
  
  return (
    <div ref={cRef} style={{width: width, height: height}}/>
  );
};

四、异步加载

多数情况下图表的数据需要从后端异步加载,这时候需要在前端展示一个加载中的指示,可以使用官方提供的 loading 动画来实现。

我们在接口处添加一个可选的配置参数 loading,其默认值是 false

export interface MyChartProps {
  option: MyChartOption;
  width: number | string;
  height: number | string;
  loading?: boolean;
}

const MyChart: React.FC<MyChartProps> = ({option, width, height, loading = false}) => {
  ...

  // 展示加载中
  useEffect(() => {
    if (loading) cInstance.current?.showLoading();
    else cInstance.current?.hideLoading();
  }, [loading]);

  ...
}

如果希望 loading 动画和前端使用的 UI 框架保持一致,也可以不使用官方动画,直接用 UI 框架提供的组件包裹 div 元素。这种情况下建议将宽度设置为 100%,依赖父容器来控制实际宽度。以 Antd 为例:

import {Spin} from 'antd';

const MyChart: React.FC<MyChartProps> = ({option, width, height, loading = false}) => {
  ...

  return (
    <Spin spinning={loading}>
      <div ref={cRef} style={{width: width, height: height}}/>
    </Spin>
  );
}

五、点击事件

ECharts 有许多图表提供了事件与行为,其中鼠标点击事件是比较常见的,我们将它绑定到 onClick 函数上并提供在接口中。

import {ECElementEvent} from 'echarts/types/src/util/types';

export interface MyChartProps {
  option: MyChartOption;
  width: number | string;
  height: number | string;
  loading?: boolean;

  onClick?(event: ECElementEvent): any;
}

const MyChart: React.FC<MyChartProps> = ({option, width, height, loading = false, onClick}) => {
  ...

  useEffect(() => {
    if (cRef.current) {
      cInstance.current = echarts.getInstanceByDom(cRef.current);
      if (!cInstance.current) {
        cInstance.current = echarts.init(cRef.current, undefined, {
          renderer: 'svg',
        });

        // 绑定鼠标点击事件
        cInstance.current.on('click', (event) => {
          const ec = event as ECElementEvent;
          if (ec && onClick) onClick(ec);
        });
      }

      if (option) cInstance.current?.setOption(option);
    }
  }, [cRef, option]);

  ...
}

六、实例露出

最后一步,为了让封装的组件更灵活,需要把实例暴露出去,方便父组件在使用时直接操作 ECharts 实例。

我们将 MyChart 从普通的 React.FC 组件改写成带转发的 React.ForwardRefRenderFunction 组件,并改名为 MyChartInner。然后使用 React.forwardRef 重新构造 MyChart 组件。

export interface MyChartRef {
}

const MyChartInner: React.ForwardRefRenderFunction<MyChartRef, MyChartProps> = (
  {option, width, height, loading = false, onClick},
  ref: ForwardedRef<MyChartRef>
) => {
}

const MyChart = React.forwardRef(MyChartInner);

export default MyChart;

这里我们把获取 ECharts 实例 instance() 函数暴露出来,这样当出现封装组件不能满足的需求时,可以直接通过实例来调用原生函数,例如 resize()setOption() 等等。

export interface MyChartRef {
  instance(): EChartsType | undefined;
}

const MyChartInner: React.ForwardRefRenderFunction<MyChartRef, MyChartProps> = (
  {option, width, height, loading = false, onClick},
  ref: ForwardedRef<MyChartRef>
) => {
  ...

  // 获取实例
  const instance = () => {
    return cInstance.current;
  }

  // 对父组件暴露的方法
  useImperativeHandle(ref, () => ({
    instance
  }));

  ...
}

OK,React + TypeScript 对 ECharts 的组件封装就基本完成了,根据需要在此基础上可以自行定制。

以下是全部源码:

import React, {ForwardedRef, useEffect, useImperativeHandle, useLayoutEffect, useRef,} from 'react';

import * as echarts from 'echarts/core';
import {EChartsType} from 'echarts/core';
import {
  DatasetComponent,
  DatasetComponentOption,
  DataZoomComponent,
  DataZoomComponentOption,
  GridComponent,
  GridComponentOption,
  LegendComponent,
  LegendComponentOption,
  TitleComponent,
  TitleComponentOption,
  ToolboxComponent,
  ToolboxComponentOption,
  TooltipComponent,
  TooltipComponentOption
} from 'echarts/components';
import {BarChart, BarSeriesOption, LineChart, LineSeriesOption,} from 'echarts/charts';
import {UniversalTransition} from 'echarts/features';
import {SVGRenderer} from 'echarts/renderers';
import {ECElementEvent} from 'echarts/types/src/util/types';
import {Spin} from 'antd';

echarts.use([
  DatasetComponent,
  DataZoomComponent,
  GridComponent,
  LegendComponent,
  TitleComponent,
  ToolboxComponent,
  TooltipComponent,
  LineChart,
  BarChart,
  UniversalTransition,
  SVGRenderer,
]);

export type MyChartOption = echarts.ComposeOption<| DatasetComponentOption
  | DataZoomComponentOption
  | GridComponentOption
  | LegendComponentOption
  | TitleComponentOption
  | ToolboxComponentOption
  | TooltipComponentOption
  | LineSeriesOption
  | BarSeriesOption>;

export interface MyChartProps {
  option: MyChartOption | null | undefined;
  width: number | string;
  height: number | string;
  merge?: boolean;
  loading?: boolean;
  empty?: React.ReactElement;

  onClick?(event: ECElementEvent): any;
}

export interface MyChartRef {
  instance(): EChartsType | undefined;
}

const MyChartInner: React.ForwardRefRenderFunction<MyChartRef, MyChartProps> = (
  {option, width, height, loading = false, onClick},
  ref: ForwardedRef<MyChartRef>
) => {
  const cRef = useRef<HTMLDivElement>(null);
  const cInstance = useRef<EChartsType>();

  // 初始化注册组件,监听 cRef 和 option 变化
  useEffect(() => {
    if (cRef.current) {
      // 校验 Dom 节点上是否已经挂载了 ECharts 实例,只有未挂载时才初始化
      cInstance.current = echarts.getInstanceByDom(cRef.current);
      if (!cInstance.current) {
        cInstance.current = echarts.init(cRef.current, undefined, {
          renderer: 'svg',
        });

        cInstance.current.on('click', (event) => {
          const ec = event as ECElementEvent;
          if (ec && onClick) onClick(ec);
        });
      }

      // 设置配置项
      if (option) cInstance.current?.setOption(option);
    }
  }, [cRef, option]);

  // 监听窗口大小变化重绘
  useEffect(() => {
    window.addEventListener('resize', resize);
    return () => {
      window.removeEventListener('resize', resize);
    };
  }, [option]);

  // 监听高度变化
  useLayoutEffect(() => {
    resize();
  }, [width, height]);

  // 重新适配大小并开启过渡动画
  const resize = () => {
    cInstance.current?.resize({
      animation: {duration: 300}
    });
  }

  // 获取实例
  const instance = () => {
    return cInstance.current;
  }

  // 对父组件暴露的方法
  useImperativeHandle(ref, () => ({
    instance
  }));

  return (
    <Spin spinning={loading}>
      <div ref={cRef} style={{width: width, height: height}}/>
    </Spin>
  );
};

const MyChart = React.forwardRef(MyChartInner);

export default MyChart;

你可能感兴趣的:(React,echarts,react.js,typescript)