项目中需要在 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 渲染器,很多时候会更清晰。在熟悉了所有组件的基础上,可以按照自己的需求和习惯,重新整理一份按需引入的代码。
下面完整代码引入了柱状图和折线图作为封装支持的图表类型,并改用 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;