项目中需要在 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. 引入渲染器,有 CanvasRenderer 和 SVGRenderer 两种,相比 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;

绘制得到的图表如下:

echart itemStyle和emphasis的关系 echarts typescript_配置项

不过目前封装的函数组件还比较粗糙,需要对功能进行进一步优化。

三、自适应宽高

首先把写死的图表宽高数据改成可配置参数,这样使用者可以灵活地根据场景决定使用像素值或百分比。通常我们会指定高度为像素值和宽度为百分比,或者全部使用 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;