数据可视化的春天 g2-render

613 阅读5分钟

推荐一个基于solidjs的webcomponnet 图表组件, g2-render

区别于bizCharts,g2-plot 等传统组件,g2-render 在父组件写渲染逻辑,让你的项目避免成为💩山, 可json 序列化,支持报表配置

在线demo

做了很多bi项目, 说一点自己的经验

一 echart vs g2 怎么选? 选 g2

  1. 封装组件的必要性和难易程度?
  • 如果不封装,在单页应用里,势必要借助ref ,手动控制销毁, 代码重复还是小问题, 假如你的报表是在循环里使用, 那就很恶心了, 你得把每一个ref放到循环项的, 如此一来,项目只能是shi⛰️了,所以封装组件是必要的
  • echart使用一个完整的对象配置,理解起来简单,而且容易封装,直接把option传递下去就行
  • g2 使用链式api, 文档没echarts那么好, 而且v4版本的很多概念太学术了,ds,dv, 还有古怪的position=‘a*b’语法, 劝退了很多人, v5的api设计要简单很多,但是还不够完善
  1. 业务落地如何?
  • echarts 接受数据的地方有两个, 新版的dataset.source,支持对象数据
  • 旧版的series.data,不支持对象数据

 {
    // 按行的 key-value 形式(对象数组),这是个比较常见的格式。
    source: [
      { product: 'Matcha Latte', count: 823, score: 95.8 },
      { product: 'Milk Tea', count: 235, score: 81.4 },
      { product: 'Cheese Cocoa', count: 1042, score: 91.2 },
      { product: 'Walnut Brownie', count: 988, score: 76.9 }
    ]
  },

这个结构看起来没问题, 现在加点需求

场景一: 添加一条参考线,

  1. 假设数据是单独的一个数组:

那么直接新设置一条series.data即可 ? ❌ series.data不支持对象数组, 你比如使用dataset.source 预留一下这个新数组的位置 ,再配置sourcenIndex 来使用

  1. 数据是来自于整体的数据,使用filter过滤出来, 如果数据结构复杂, 处理起来更头疼,我看了vue-eachers的文档, 说什么echart有个开关,控制setOption是复杂还是添加,恐怖, 而且vue-echart 竟然还有自己的配置项, 我的理解不是简单的传递下我的配置项就行了吗, 你搞那么多配置,出了问题,我都不知道怎么排查,毕竟图表库我们普通前端也操作不了代码,全是配置

对于g2, 直接chart.line().data(modelData)即可,

image.png

场景二, 左侧折线图,联动右侧饼图, echart有现成demo

看起来是不是很高兴, 折线图的数据结构

source: [
        ['product', '2012', '2013', '2014', '2015', '2016', '2017'],
        ['Milk Tea', 56.5, 82.1, 88.7, 70.1, 53.4, 85.1],
        ['Matcha Latte', 51.1, 51.4, 55.1, 53.3, 73.8, 68.7],
        ['Cheese Cocoa', 40.1, 62.2, 69.5, 36.4, 45.2, 32.5],
        ['Walnut Brownie', 25.2, 37.1, 41.2, 18, 33.9, 49.1]
      ]

饼图的数据结构

encode: {
         itemName: 'product',
         value: '2012',
         tooltip: '2012'
       }
       
       
翻译成对象数组=> 
[
    {product: 'Milk Tea',2012:56.5 },
     {product: 'Matcha Latte',2012:51.1 },
]

虽然都是对象数组, 却完全不兼容的,假设你的数据不是他这样的,第一行是x轴, 还是使用开头那样的对象数组, 那就gg吧, 谁吊大的来转换一下这两个数组,这个例子很容易误导人的思路,其实自己控制挺简单的

g2的折线图和饼图都是同一种对象结构,没有兼容问题

好了, 推荐g2,

举个例子, 大家一开始使用vue-chart,会自己把type:line以及其他配置封装进去,如果是配置型报表,还会封装数据请求的方式在里面, 或者g2-plot之类的组件库,直接使用line

但是后面发现自己封装的line组件,功能完全不够, 比如之前是一条,现在是多条(g2还好,就是多个字段,echarts的话,彻底gg吧, 一条和多条的数据结构不一样了)

或者接口数据结构变了, 或者是折线图右侧加饼图, 又得修改通用组件,慢慢的,你会发现通用组件越来越大,完全改不动了,

怎么解决呢?

把逻辑抽到外层,由父组件控制, 对于 echarts还好, 直接完整的配置,不做任何封装即可实现,但是配置太特么大了, 一层套一层,不看手册完全不知道自己要的那个属性在哪个层级下, 使用起来太难了

g2的链式api的优势就出来了, 五六行代码就可以出一个图,完全不用记忆属性的位置, 你只需要知道你要改的东西属于哪个通道即可, 很容易记忆

最后的问题就是: 组件是必须封装的, 但是又不想变成shit , 还想在父组件写渲染逻辑 但是,渲染函数中需要绑定一个div ref, 如果ref放父组件,在低代码这种配置系统里, 都是通过循环来生成图表, ref放循环项就很不方便不了,

所以, 既要支持报表配置, 又要在父组件控制渲染逻辑, 那就是把渲染函数系列化,之后作为prop传到子组件 ,子组件只控制创建和销毁, 不处理任何逻辑, 基于此,就可以直接拷贝g2官网里的demo来直接执行, 完全傻瓜式 最后, 能在react和vue项目里都使用就更好了, 这样你的配置就可以跨系统使用, 有web-component就更好了,

// 在vue中使用
<g2-render
      :json="json"
      :data.prop="data"
      :scope.prop="scope"
      :init-config.prop="{
        syncViewPadding: true,
        height: 300,
        width: 200,
      }"
      debug="true"
    ></g2-render>
    
    
    import { parser } from 'g2-render';
    
    const render = ({ DataSet, chart, x, y, data }) => {
        // ...
        chart.render();
    }
    
    const json = parser.stringify({
          render
    });
    

所以 g2-render 诞生了

solid-js 编写的,源码如下

// index.ts 
import { customElement } from "solid-element";
import MyComponent from "./MyComponent";

import parser from "./parser";
customElement(
  "g2-render",
  { data: undefined, scope: {}, json: "", "init-config": {}, debug: false },
  MyComponent
);
export { parser };


// MyComponent.tsx
import { Chart } from "@antv/g2";
import { InitCfg } from "@antv/g2/lib/geometry/base";
import { ChartCfg } from "@antv/g2/lib/interface";
import { Component, createEffect, onCleanup, onMount } from "solid-js";
import parser from "./parser";

export type Props = {
  data?: any[];
  scope: Record<string, any>;
  json: string;
  "init-config"?: Omit<ChartCfg, "container">;
  debug?: boolean;
};

const MyComponent: Component<Props> = (props) => {
  let div: HTMLDivElement;
  let chart: Chart;
  onMount(() => {
    chart = new Chart({
      container: div,
      ...props["init-config"],
    });
    onCleanup(() => chart.destroy());
  });

  /**
   * {
      ...props.scope,
      chart,
      data: props.data ? props.data : [],
    }
   */
  createEffect(() => {
    props.debug && console.log("props", props);

    chart?.clear();
    const scope = {
      ...props.scope,
      chart,
      data: props.data ? props.data : [],
    };
    if (!props.json) {
      return;
    }

    try {
      const parsed = parser.parse(props.json);
      if (!parsed) {
        console.error(
          `[g2-render]: props.json参数格式不对, parse之后是 ${typeof parsed},期待parse成 object`
        );
      }
      parsed.render(scope);
    } catch (err) {
      console.error(err);
    }
  });

  return <div ref={div!}> {/*...*/} </div>;
};

export default MyComponent;


// parser.ts
interface IObject {
  [name: string]: any;
}

const parser = {
  FUNC_PREFIX: "__xfunc__",
  REG_PREFIX: "__xreg__",
  isArrOrObj: function (obj: any) {
    return this.isArr(obj) || this.isObj(obj);
  },
  isArr: function (obj: any) {
    return !!obj && Object.prototype.toString.call(obj) === "[object Array]";
  },
  isObj: function (obj: any) {
    return !!obj && Object.prototype.toString.call(obj) === "[object Object]";
  },
  isRegExp: function (obj: any) {
    return !!obj && Object.prototype.toString.call(obj) === "[object RegExp]";
  },
  isFunc: function (obj: any) {
    return typeof obj === "function";
  },
  funcParse: function (obj: IObject | any[]) {
    let result: any;

    if (this.isArr(obj)) {
      result = [];
      result = obj.map((v: any) => {
        /** 如果是函数类型, 则转换为字符串 */
        if (this.isFunc(v)) {
          return `${this.FUNC_PREFIX}${v}`;
        }
        /** 如果类型为正则 */
        if (this.isRegExp(v)) {
          return `${this.REG_PREFIX}${v}`;
        }
        /** 如果是数组或者对象 */
        if (this.isArrOrObj(v)) {
          return this.funcParse(v);
        }
        /** 基本类型 */
        return v;
      });
    }

    if (this.isObj(obj)) {
      result = {};
      for (let key in obj) {
        const v = (obj as IObject)[key];
        if (this.isFunc(v)) {
          result[key] = `${this.FUNC_PREFIX}${v}`;
        } else if (this.isRegExp(v)) {
          /** 如果类型为正则 */
          result[key] = `${this.REG_PREFIX}${v}`;
        } else if (this.isArrOrObj(v)) {
          result[key] = this.funcParse(v);
        } else result[key] = v;
      }
    }

    return result;
  },
  parse: function (jsonStr: string, error?: (err: Error | unknown) => {}) {
    try {
      return JSON.parse(jsonStr, (key, value) => {
        if (value && typeof value === "string") {
          const _value =
            value.indexOf(this.FUNC_PREFIX) > -1
              ? new Function(`return ${value.replace(this.FUNC_PREFIX, "")}`)()
              : value.indexOf(this.REG_PREFIX) > -1
              ? new Function(`return ${value.replace(this.REG_PREFIX, "")}`)()
              : value;
          return _value;
        }
        return value;
      });
    } catch (err) {
      error && error(err);
    }
  },
  stringify: function (
    obj: any,
    replacer?: (key: string, value: any) => any,
    space?: number | string,
    error?: (err: Error | unknown) => {}
  ) {
    try {
      let _obj = obj;
      if (this.isRegExp(obj)) {
        /** 如果类型为正则 */
        _obj = `${this.REG_PREFIX}${obj}`;
      }
      if (this.isFunc(obj)) {
        _obj = `${this.FUNC_PREFIX}${obj}`;
      }
      if (this.isArrOrObj(obj)) {
        _obj = this.funcParse(obj);
      }
      return JSON.stringify(_obj, replacer, space);
    } catch (err) {
      error && error(err);
      return "";
    }
  },
  fastStringify: function (
    obj: any,
    space?: number | string,
    error?: (err: Error | unknown) => {}
  ) {
    try {
      return JSON.stringify(
        obj,
        (k, v) => {
          /** 如果类型为函数 */
          if (this.isFunc(v)) {
            return `${this.FUNC_PREFIX}${v}`;
          }
          /** 如果类型为正则 */
          if (this.isRegExp(v)) {
            return `${this.REG_PREFIX}${v}`;
          }
          return v;
        },
        space
      );
    } catch (err) {
      error && error(err);
      return "";
    }
  },
  nativeStringify: JSON.stringify,
  nativeParse: JSON.parse,
};

export default parser;