项目中需要在 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;
复制