数据可视化-如何在React项目中使用Echarts插件并封装图表组件?

2,485 阅读11分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战

前言

如果看过我2022年前端技术趋势:你不进来看看,怎么赚它一个小目标?这篇文章的小伙伴,应该知道我们在项目中使用了Echarts插件,做了可视化图表的展示的需求,效果还是挺不错的。今天我们就来聊聊在React中怎么引入Echarts插件,并且封装复用性很强的图表组件提供给组内小伙伴使用,全片都是干货,赶紧码住收藏起来。

需求背景

由于我们公司是建筑设计相关行业的,平时对数据这些内容相当敏感,特别是年底对报表的数据更是重视,传统的表格又不能直观的提供数据该有的表现力,故需要我们使用可视化的图表来展现报表数据,实现数据的自我解释和传递准确的数据信息,从而节约人们的思考时间。

需求就是:分别用折线图柱形图饼图旭日图矩形树图展现根据查询条件查询出的数据,当同一块的折线图需要联动展现所谓联动,即鼠标点击某一个折线图的legend,另一个折线图的对应的线也要显示或隐藏。这里我们主要将这个交互情况,其它变态交互就不展开讲了,毕竟不是所有的公司都是我们公司(交互设计的人迷之自信觉得交互体验很好,其实一地鸡毛)。

来看UI设计图,保持我们的上图说需求传统:

微信截图_20220124171826.png

上面的需求总结就是这张设计图,给我实现这张设计图,加上我要的交互就行了。好了,拿钱干活而已,撸代码吧。

开发

技术栈为:React+TypeScript+Echarts。所以其它技术栈的小伙伴参考使用,若有需要根据需求考虑出Vue相关的使用方法和封装思路。

安装

npm i echarts 或者 yarn add echarts

基本使用

既然安装好了,那我们就用一个小栗子来演示一下使用方法:

引用组件:

import * as echarts from 'echarts';

页面使用:

import React, {useEffect, useRef} from 'react';
import * as echarts from 'echarts';


/**
 * 图表示例
 * @constructor
 */
const EchartsExample = () => {
    const lineRef = useRef<any>(null);
    const myChartRef = useRef<any>(null);

    useEffect(() => {
        initChart();
    }, []);


    /**
     * 初始化
     */
    const initChart = () => {
        myChartRef.current = echarts.init(lineRef.current);
        let option: any = {
            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'
                }
            ]
        };
        myChartRef.current && myChartRef.current.setOption(option, true);
    }
    

    return (
        <div>
            <div ref={lineRef} style={{width: 600, height: 400}}></div>
        </div>
    )
}


export default EchartsExample;

效果:

微信截图_20220123202948.png

这就是最简单的使用方式:xAxis表示的是X轴数据,yAxis表示的是Y轴数据,series.data表示的是折线数据。其它图表的基础用法参考上面的栗子,上面栗子能保证的是只要你把Echarts的示例代码粘贴过来替换option的内容都能看到相应图表效果。

封装思路

封装组件要多考虑一下,不能局限于现有的需求,需求随时都在变化,所以我们封装的时候要注意代码的可扩展性,以至于需求变化时自己不会显得被动,宁愿较小的改动已有代码也不愿去干掉重头再来。好,我们开始一步步来封装。

页面节点:

return (
    <Spin spinning={loading}>
        {
            ObjTest.isNotEmptyArray(dataSource)
                ?
                <div ref={chartRef} style={{width, height, backgroundColor}}></div>
                :
                <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
        }
    </Spin>
)

可以看到我们不只定义了Echarts的容器,我们还考虑到了查询数据时的加载动画Spin和没有数据时的显示Empty,这样就会让我们封装的组件体验感更好,不至于出现一个空白区域和不能感知数据正在加载的场景。

传递参数:

interface ChartProps {
    dataSource: any; // 源数据
    loading: boolean; // 加载标识
    width?: number; // 图表宽度
    height?: number; // 图表高度
    backgroundColor?: string; // 图表背景颜色
    fontSize?: number; // 字号
    beginTimeVo?: any; // 开始时间
    endTimeVo?: any; // 结束时间
    isWeekOrMonth?: number; // X轴显示周还是月
    legendSelected?: any; // 图例选中集合
    isShowDataZoom?: boolean; // 是否显示X轴缩放条
    isShowLegend?: boolean; // 是否显示图例
    gridTop?: string; // 图表内容距离图例高度
    isAllChecked?: boolean; // 是否全选标识
    selectedMode?: boolean; // legend选择模式
    onChange?: (beginDate: string, endBegDate: string, endDate: string) => void; // 缩放条缩放事件
    onLegendSelect?: (name: string, selected: any) => void; // legend图例选中或取消事件
    onClickItem?: (item: any, type: number, index: number) => void; // 点击X轴指示器
    onClickGlobalArea?: (bool: boolean) => void; // 点击全局区域
}

可以看到我们将可能需要改变的字段都作为了参数进行传递进组件使用,只有必须数据源dataSource和加载数据标识loading,像图表字号、宽高、图例相关的显示隐藏等字段都是作为可选参数进行传递,这样我们就可以满足在不同的页面需要不同的样式和功能需求了。其实这里可以把它所有字段都作为参数进行传递,但是那样显得冗余,所以可以看到我们只是把一些需求频率高的字段作为参数进行传递,一些不常用的字段还是维持在组件里面写死。

交互逻辑:

移动X轴缩放滑块事件:

myChartRef.current.on('dataZoom', updatePosition);

/**
 * 移动缩放滑块
 * @param e
 */
const updatePosition = (e: any) => {
    let xAxis: any = myChartRef.current.getModel().option.dataZoom[0];
    let startIndex: number = xAxis.startValue;
    let endIndex: number = xAxis.endValue;
    let beginDate: string = timeVos && timeVos[startIndex] ? timeVos[startIndex]['beginTime'] : '';
    let endBegDate: string = timeVos && timeVos[endIndex] ? timeVos[endIndex]['beginTime'] : '';
    let endDate: string = timeVos && timeVos[endIndex] ? timeVos[endIndex]['endTime'] : '';
    if (timerRef.current) { clearTimeout(timerRef.current); }
    timerRef.current = setTimeout(() => {
        onChange(beginDate, endBegDate, endDate);
    }, 1000)
}

可以看到我们通过移动X轴的缩放滑块,获取到滑块所在的开始序号和结束序号,然后通过序号去获得准确的开始时间和结束时间之后通过onChange方法暴露给父组件,然后去做逻辑处理。代码还体现在我们滑动滑块时不是立刻去执行,而是加了一个防抖去处理,避免了重复处理造成页面假死。

特殊逻辑:

myChartRef.current.off('showTip');
myChartRef.current.on('showTip', function (params) {
    const {dataIndex} = params;
    onClickItem(timeVos[dataIndex], 3, dataIndex);
});
myChartRef.current.getZr().off('click');
myChartRef.current.getZr().on('click', function (params) {
    flag = !flag;
    if (flag) {
        option.tooltip.triggerOn = 'click';
    }else {
        option.tooltip.triggerOn = 'mousemove';
    }
    myChartRef.current.setOption(option);
    onClickGlobalArea(flag);
});

在我们使用堆叠折线图时,我们移动鼠标到折线点时是能获取到部分数据的,如果没准确的移动到折线点时时拿不了数据的,需求又要求不管移入折线点还是没进入折线点都要拿到点所在X轴的开始时间和结束时间去查询数据做其它逻辑。我们在查询了API文档之后选择使用showTip事件搭配tooltip属性,就能实现鼠标移动都能获取准确的数据;至于 全局点击事件getZr().on('click', function() {})的用处则是用于锁定指示器,不再让鼠标继续移动,即锁住数据进行查看。

完整封装

好了,封装Echarts组件有必要说的点就是上述几点,都是我们实际项目中所做的真实需求,这里只拿折线图组件来展示完整代码,其它组件的完整代码等后续建了仓库,上传之后才能查看。折线图组件完整代码如下:

import React, {useEffect, useRef, useState} from 'react';
import {Spin} from 'antd';
import * as echarts from 'echarts';
import {ObjTest} from "../../../util/common";

interface ChartProps {
    dataSource: any; // 源数据
    loading: boolean; // 加载标识
    width?: number; // 图表宽度
    height?: number; // 图表高度
    backgroundColor: string; // 图表背景颜色
    fontSize?: number; // 字号
    beginTimeVo?: any; // 开始时间
    endTimeVo?: any; // 结束时间
    isWeekOrMonth?: number; // X轴显示周还是月
    legendSelected?: any; // 图例选中集合
    isShowDataZoom?: boolean; // 是否显示X轴缩放条
    isShowLegend?: boolean; // 是否显示图例
    gridTop?: string; // 图表内容距离图例高度
    isAllChecked?: boolean; // 是否全选标识
    isLockEchart: boolean; // 是否锁住图表
    selectedMode?: boolean; // legend选择模式
    onChange?: (beginDate: string, endBegDate: string, endDate: string) => void; // 缩放条缩放事件
    onLegendSelect?: (name: string, selected: any) => void; // legend图例选中或取消事件
    onClickItem?: (item: any, type: number, index: number) => void; // 点击X轴指示器
    onClickGlobalArea?: (bool: boolean) => void; // 点击全局区域
}


/**
 * 折线图表组件
 * @props
 * @constructor
 */
const LineChart = (props: ChartProps) => {
    const {
        dataSource,
        loading = true,
        width = 510,
        height = 220,
        backgroundColor = #fff,
        fontSize = 10,
        beginTimeVo = {},
        endTimeVo = {},
        legendSelected = {},
        isShowDataZoom = true,
        isShowLegend = true,
        gridTop = '15%',
        isWeekOrMonth = 1,
        isAllChecked = false,
        isLockEchart = false,
        onChange = (beginDate: string, endBegDate: string, endDate: string) => {},
        onLegendSelect = (name: string, selected: any) => {},
        onClickItem = (item: any, type: number, index: number) => {},
        onClickGlobalArea = (bool: boolean) => {}
    } = props;
    const {
        timeVos = [],
        names = [],
        questionDetails = [],
        beginTimeVo: beginTimeObj = {},
        endTimeVo: endTimeObj = {}
    } = dataSource || {};
    const lineRef = useRef<any>(null);
    const myChartRef = useRef<any>(null);
    const timerRef = useRef<any>(null);

    useEffect(() => {
        initChart();
    }, [dataSource, isLockEchart]);


    /**
     * 初始化Echarts
     */
    const initChart = () => {
        myChartRef.current = echarts.init(lineRef.current);
        let timeVosList: any[] = JSON.parse(JSON.stringify(timeVos)) || [];
        timeVosList.forEach(v => v.week = isWeekOrMonth === 1 ? `${v.week}\n${v.xtime}` : `${v.xtime}`);
        let flag: boolean = isLockEchart;
        
        const xAxisData: string[] = timeVosList && timeVosList.map(v => v.week);
        let seriesData: any[] = [];
        if (questionDetails && questionDetails.length > 0) {
            seriesData = questionDetails.map((v: any, index: number) => {
                let questions: any[] = [...v.questions].map(item => {
                    if (item) {
                        return item.toFixed(0);
                    }
                    return item;
                });
                return {
                    ...v,
                    name: v.name,
                    type: 'bar',
                    stack: v.stack,
                    itemStyle: {
                        borderRadius: [4, 4, 0, 0],
                    },
                    barWidth: 12,
                    data: questions,
                }
            });

        }
        let option: any = {
            tooltip: {
                show: true,
                trigger: 'axis',
                triggerOn: flag ? 'click' : 'mousemove',
                appendToBody: true,
                backgroundColor: '#546E7A',
                borderColor: '#546E7A',
                padding: [2, 5],
                position: function (point, params, dom, rect, size) {
                    return [point[0] - 75, 20];
                },
                textStyle: {
                    color: '#fff',
                    fontSize: 8
                },
                formatter: function () {
                    return '单击锁定数据至左下角';
                }
            },
            legend: {
                data: names,
                itemWidth: 20,
                itemHeight: 12,
                itemGap: 5,
                top: 0,
                left: '3%',
                orient: 'horizontal',
                inactiveColor: '#888',
                textStyle: {
                    color: 'rgba(255, 255, 255, .8)'
                },
                selected: !isAllChecked && !selectedMode ? legendSelected['合计'] ? legendSelected : {
                    ...legendSelected,
                    '合计': false
                } : legendSelected,
            },
            grid: {
                top: gridTop,
                left: '3%',
                right: '8%',
                bottom: 30,
                containLabel: true
            },
            dataZoom: [
                {
                    type: 'slider',
                    show: isShowDataZoom,
                    height: 20,
                    left: 10,
                    right: 20,
                    bottom: 5,
                    startValue: begTime,
                    endValue: endTime,
                    rangeMode: ['value'],
                }
            ],
            xAxis: [
                {
                    type: 'category',
                    data: xAxisData,
                    boundaryGap: false,
                    name: isWeekOrMonth === 1 ? '周' : '月',
                    nameTextStyle: {
                        color: '#607D8B',
                        verticalAlign: 'middle'
                    },
                    axisPointer: {
                        show: true,
                        type: 'shadow',
                        shadowStyle: {
                            color: 'rgba(245, 245, 245, 0.1)'
                        },
                        label: {
                            show: false
                        }
                    },
                    axisLabel: {
                        lineHeight: 16,
                        color: '#607D8B',
                        fontSize: 10
                    }
                }
            ],
            yAxis: [
                {
                    type: 'value',
                    offset: 10,
                    axisLabel: {
                        color: '#607D8B',
                        fontSize: 10
                    },
                    splitLine: {
                        lineStyle: {
                            type: 'dashed',
                            color: 'rgba(255, 255, 255, .06)'
                        }
                    }
                }
            ],
            series: seriesData
        };
        myChartRef.current && myChartRef.current.setOption(option, true);
        myChartRef.current.on('dataZoom', updatePosition);
        myChartRef.current.off('legendselectchanged');
        myChartRef.current.on('legendselectchanged', function (params: any) {
            const {name, selected} = params;
            setLegendSelectedBak(selected);
        });
        myChartRef.current.off('showTip');
        myChartRef.current.on('showTip', function (params) {
            const {dataIndex} = params;
            onClickItem(timeVos[dataIndex], 3, dataIndex);
        });
        myChartRef.current.getZr().off('click');
        myChartRef.current.getZr().on('click', function (params) {
            flag = !flag;
            if (flag) {
                option.tooltip.triggerOn = 'click';
            }else {
                option.tooltip.triggerOn = 'mousemove';
            }
            myChartRef.current.setOption(option);
            onClickGlobalArea(flag);
        });
    }


    /**
     * 移动缩放滑块
     * @param e
     */
    const updatePosition = (e: any) => {
        let xAxis: any = myChartRef.current.getModel().option.dataZoom[0];
        let startIndex: number = xAxis.startValue;
        let endIndex: number = xAxis.endValue;
        let beginDate: string = timeVos && timeVos[startIndex] ? timeVos[startIndex]['beginTime'] : '';
        let endBegDate: string = timeVos && timeVos[endIndex] ? timeVos[endIndex]['beginTime'] : '';
        let endDate: string = timeVos && timeVos[endIndex] ? timeVos[endIndex]['endTime'] : '';
        if (timerRef.current) { clearTimeout(timerRef.current); }
        timerRef.current = setTimeout(() => {
            onChange(beginDate, endBegDate, endDate);
        }, 1000)
    }


    return (
        <Spin spinning={loading}>
            {
                ObjTest.isNotEmptyArray(dataSource)
                    ?
                    <div ref={lineRef} style={{width, height, backgroundColor}}></div>
                    :
                    <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
            }
        </Spin>
    )
}


export default LineChart;

其实上面的封装还是有缺陷的,比如后端传递的字段,不同的后端设置的字段肯定有所不同,所以在这点上面没有做到完美的复用,但是子后面我们封装矩形树图的时候就考虑到了,我们定义了一个字典去转换,父组件将准确的字段传入即可以完美展示;这里折线图、柱形图和旭日图和我对接的后端始终是同一人,所以就没有出现定义不同的字段情况,但是小伙伴们在封装的时候一定要考虑到这点,否则后续有人接手代码可能会免不了被问候全家,到时那就很尴尬了。

这样我在贴一下矩形树图的封装代码,至于柱状图,饼图和旭日图的封装代码小伙伴可以尝试自行封装或者等我把仓库建好之后进行参考。矩形树图封装完整代码:

import React, {useEffect, useRef, useState} from 'react';
import {Empty, Spin} from 'antd';
import * as echarts from 'echarts';
import {ObjTest} from "../../../util/common";

interface TreeMapChartProps {
    dataSource: any[]; // 源数据
    mapping?: any; // 字段映射关系
    loading: boolean; // 加载标识
    type?: number; // 0: 任务组;1:人员
    width?: number; // 图表宽度
    height?: number; // 图表高度
}


/**
 * 任务组和人员工时矩形树图表
 * @props
 * @constructor
 */
const TreeMapChart = (props: TreeMapChartProps) => {
    const {dataSource, loading, type, width = 512, height = 255, mapping = {name: 'userGroupName', value: 'value'}} = props;
    const treeMapRef = useRef<any>(null);
    const myChartRef = useRef<any>(null);

    useEffect(() => {
        if (treeMapRef.current) {
            initChart();
        }
    }, [dataSource]);


    /**
     * 初始化
     */
    const initChart = () => {
        myChartRef.current = echarts.init(treeMapRef.current);
        let seriesData: any[] = (dataSource || []).map((v: any, ind: number) => {
            return {
                ...v,
                value: v[mapping.value],
                name: v[mapping.name],
            }
        });
        let option: any = {
            series: [
                {
                    name: 'All',
                    type: 'treemap',
                    width: '100%',
                    height: '100%',
                    roam: true,
                    breadcrumb: {
                        show: true
                    },
                    data: seriesData,
                    label: {
                        offset: [0, 5],
                        lineHeight: 14,
                        fontSize: 10,
                        formatter: '{b}\n{c}'
                    }
                }
            ]
        };
        myChartRef.current && myChartRef.current.setOption(option, true);
    }


    return (
        <Spin spinning={loading}>
            {
                ObjTest.isNotEmptyArray(dataSource)
                    ?
                    <div ref={treeMapRef} style={{width, height}}></div>
                    :
                    <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
            }
        </Spin>
    )
}


export default TreeMapChart;

使用方法:

组件封装好了,那就必须整出来用用,不然就白忙了;

1)导入组件:
import LineChart from "./component/LineChart";
import TreeMapChart from "./component/TreeMapChart";

2)使用组件:
// 折线图组件
<LineChart
    dataSource={workHoursData}
    loading={loading}
    width={710}
    height={350}
    beginTimeVo={beginTimeVo}
    endTimeVo={endTimeVo}
    isLockEchart={echart1Bool}
    onChange={(beginDate: string, endBegDate: string, endDate: string) => {
        let days = getDaysBetween(beginDate, endBegDate);
        if (days > 60) {
            setFieldsValue({weekOrMonth: 2});
        } else {
            setFieldsValue({weekOrMonth: 1});
        }
        setFieldsValue({periodEnum: '', beginTime: beginDate, endTime: endDate});
        setBeginTimeVo({});
        setEndTimeVo({});
        queryProjectData();
    }}
    onClickItem={(item: any, type: number, index: number) => onClickItemHandle(item, type, index)}
    onClickGlobalArea={(bool: boolean) => setEchart1Bool(bool)}
/>

// 矩形树图
<TreeMapChart
    dataSource={hours}
    type={0}
    width={710}
    height={350}
    mapping={{name: 'projectName',value: 'valueStr'}}
    loading={loading}
/>

效果

本来想给小伙伴们录屏看效果的,由于项目有一些不能展示的内容,所以就不能录屏展示了。看看图片效果吧,尽管有些许遗憾,实在有不能展示的东西,所以抱歉了。

微信截图_20220124171445.png

往期精彩文章

后语

伙伴们,如果觉得本文对你有些许帮助,点个👍或者➕个关注在走呗^_^ 。另外如果本文章有问题或有不理解的部分,欢迎大家在评论区评论指出,我们一起讨论共勉。