项目中需要在 React + TypeScript 技术栈下的前端绘制 ECharts,没有找到比较完整的封装,所以自己来写一个。
在 Github 上有看到开源的方案例如 echarts-for-react,也可以作为参考。不使用开源方案还是希望可以自己理解和维护代码。
版本信息 React: 17.x/18.x Typescript: 4.7.x ECharts: 5.3.x
一、按需引入
封装 ECharts 还是要从官方指南出发,可以在官网使用手册中看到对 TS 按需引入的指导。
我们新建一个 MyCharts.tsx 文件,把相关代码复制进来。这里面主要涉及以下几个部分:
- 引入核心模块。
- 引入需要的图表类型,比如柱状图 BarChart、折线图 LineChart、散点图 ScatterChart、饼图 PieChart 等。同时也需要引入关联的系列配置,官方说明了它们的后缀由 Chart 换成 SeriesOption 即可。 在封装的时候需要把自己常用的图表类型都引入进来,否则没有办法在封装模块的基础上使用没有引入的图表。具体有哪些图表和系列,可以参考配置项手册。
- 引入需要的组件类型,比如标题组件 TitleComponent、图例组件 LegendComponent、提示框组件 TooltipComponent 等。同时也需要引入关联的组件配置,官方说明了它们的后缀由 Component 换成 ComponentOption 即可。 具体有哪些组件,也可以参考配置项手册。
- 引入一些特性,可用的只有两个,标签自动布局特性 LabelLayout 和全局过渡动画特性 UniversalTransition。
- 引入渲染器,有 CanvasRenderer 和 SVGRenderer 两种,相比 Canvas 画的是位图而 SVG 画的是矢量图,Canvas 性能更好一点而 SVG 节点过多时渲染慢。个人比较喜欢用 SVG 渲染器,很多时候会更清晰。
- 通过组合所有引入的 SeriesOption 和 ComponentOption,构造一个合法的 option 配置项类型,它决定了当前封装模块可以使用配置项手册中的哪些。
- 把引入的必要的组件注册给 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;
绘制得到的图表如下:
不过目前封装的函数组件还比较粗糙,需要对功能进行进一步优化。
三、自适应宽高
首先把写死的图表宽高数据改成可配置参数,这样使用者可以灵活地根据场景决定使用像素值或百分比。通常我们会指定高度为像素值和宽度为百分比,或者全部使用 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;