移动端可视化引擎 F2 架构设计之: 为什么要选用 JSX

591 阅读9分钟

🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师索丘,和大家聊一聊为什么移动端可视化引擎 F2 架构设计要选用 JSX ,这背后的思考是什么?欢迎查阅~

从图形语法说起

《图形语法》是 F2 数据可视化的核心理论,而图形语法的核心是通过一套抽象的语法来描述任意的统计图表,根据书中的描述,统计图的定义依靠以下几个基础语法:

声明描述
DATA从数据集生成视觉编码的数据操作
TRANS变量转换(比如:rank)
SCALE度量转换(比如:log)
COORD定义坐标系(比如:极坐标)
ELEMENT图形(比如:点)和他们的美学属性(比如:color)
GUIDE辅助元素(比如:坐标轴,legend)

如果我们用一份数据结构去表达的话,那么可以抽象成如下的数据结构

{
  data: [
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ],
  trans: {
    value: 'rank',
  },
  scale: {
    value: 'linear',
  },
  coord: 'polar',
  element: {
    "theta": {"field": "category"},
    "radius": {"field": "value"},
    "color": {"field": "category"}
  },
  guide: {
    axis: {
     value: {},
    }
  }   
}

下面来看 F2 3.x API 命令式的描述

chart.data([
  {"category": 1, "value": 4},
  {"category": 2, "value": 6},
  {"category": 3, "value": 10},
  {"category": 4, "value": 3},
  {"category": 5, "value": 7},
  {"category": 6, "value": 8}
]);
chart.scale({
  value: 'linear',
});
chart.coord('polar');
chart.interval('category*value').color('category');
chart.axis({
  value: {},
});
chart.render();

从类型上看我们可以把前面的 JSON 描述归类到「声明式」,API 调用归类到 「命令式」,虽然从最后的结果看没有太大区别,但是从灵活性的角度看,下面的这种 API 命令式要远比 JSON 声明式灵活很多,因为我们可以在调用 API 的过程中加很多逻辑代码,来满足我们的个性化业务诉求,这个也是最早 F2 不直接使用 JSON 的一个原因。

虽然 F2 看起来是命令式的,但是实际上在内部,我们也构造了一个结构对象,用来保存图形语法的描述对象,只是对外提供的是 API 命令式的编程模式,也就是说我们最终的目的都是在构造图形语法的结构描述

那为什么现在要采用 JSX 而不是沿用之前的 API 命令式呢, 在回答这个问题之前,我们先来看看 JSX 和 JSX 的特点。

什么是 JSX

什么是 JSX, 可以看 React 官网的 JSX 简介,但是不要把 JSX 和 React 等同起来,JSX 可以让我们方便地创建数据结构,它可以和 React 完全没有关系,我们只要通过 Babel jsx transform 定义自己构造函数

举个例子: 假设我们定义了如下的构造函数和 JSX 结构

export function jsx(type, config, key?: string) {
  return {
    key,
    type,
    ...config,
  };
}
<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
>
  <interval position="x*y" />
  <axis field="x" />
</chart>

那么转换后的代码就会变成

import { jsx as _jsx } from "./jsx-runtime";

_jsx('chart', {
  data: [],
  coord: 'polar',
  children: [
    _jsx('interval', {
      position: "x*y",
    }),
    _jsx("axis", {
      field: "x",
    }),
  ],
});

执行完后,就会得到如下的结构

{
  type: "chart",
  data: [],
  coord: "polar",
  children: [
    { type: "interval", position: "x*y" },
    { type: "axis",  field: "x" }
  ]
}

通过 JSX 我们也得到了一个类似的数据结构,所以我们也能利用 JSX 来生成我们需要的结构描述,那么相比之下 JSX 有哪些优势呢,我们再来看看 JSX 的优势

JSX 的优势

通过前面我看到,我们最终的目的都是为了得到最后的图形语法结构描述,不管是 JSON, API 命令式,还是 JSX 都是为了生成这份结构,那么 JSX 相比之下又具有哪些优点呢

1. 可编程性

JSX 可以在结构中很方便地嵌入表达式,这个是 JSON 不具备的能力

举个例子

<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
  >
  <interval position="x*y" />
  {
    // 类似这样的表达式在 JSON 中是不好描述的
    showAxis ? <axis field="x" formatter={v => v.toFixed(2)}/> : null
  }
</chart>

从上面的例子我们看到 2 个点

  1. axis 需要通过 showAxis 这个变量来控制是否显示,如果是 JSON 的话,那么就需要 2 份 JSON 描述
  2. formatter 有自定义的格式化的诉求,而 JSON 无法保存方法的

2. 更强的扩展能力

因为 JSON 需要有配套的 Runtime 来处理 JSON 结构,如果需要对 JSON 进行扩展,那么相应的 Runtime 也需要同步升级,这个在多变的业务场景中带来的成本无疑是巨大的,而 JSX 可以通过扩展标签类型来方便地实现

例子:

import { Custom } from './custom';

<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
  >
  <interval position="x*y" />
  <axis field="x" />
  { /* 自定义的标签扩展 */ }
  <Custom ... />
</chart>

JSX 里有个默认规则,小写是内置标签(对应的类型为string),大写的是外部引用(可以是任意类型),所以只要约定 Custom 对应的接口(在 React 里就是 Component),就能实现无限扩展,但是这样也会带来一个问题,那就是生成的结构不再能被序列化传输和存储,这个我们后面的篇幅再讨论。

3. 更稳定的树结构

要理解这一点需要对 JSX 生成的结构有更深的理解,我这里只简单提一下,大家有兴趣可以去研究下 JSX 的编译规则 这句话可以这么理解:「不管外部参数如何变化,JSX 返回的结构树是稳定的」,稳定的结构树,是后续 diff 的基础

例子:

<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
  >
  <interval position="x*y" />
  {
    showAxis ? <axis field="x" /> : null
  }
</chart>

我们还是拿这段代码为例,showAxis 为 true 和 false 时,生成的结构对象分别如下所示,这 2 颗树的结构是一致的,并不会因为参数不同而不同。

{
  type: "chart",
  data: [],
  coord: "polar",
  children: [
    { type: "interval", position: "x*y" },
    { type: "axis",  field: "x" }
  ]
}
{
  type: "chart",
  data: [],
  coord: "polar",
  children: [
    { type: "interval", position: "x*y" },
    null,   // 树的节点里还是会保留null
  ]
}

4. 完整的结构描述

JSX 保留了 JSON 结构化描述的特点,这个是 API 命令式不具备的,命令式需要执行完全部代码之后才能得到完整的结构,而且通过转换函数,可以将 JSX 方便地转成 JSON,在面向未来的 nocode、lowcode、甚至是智能化场景,JSON 无疑是一种最好的形式,而且 JSON 机器友好度比较高。

5. 成熟的配套工具

不管是 Babel 还是 TypeScript 都有成熟的 JSX 编译插件,而且配置简单,详情可看 F2 官网的 jsx-transform

6. 小结

JSX 保留了 JSON 结构化描述的特点,,但相比 JSON 又具备更强的灵活性和可编程性,这些在我们面临复杂的业务场景时是很重要的,但是也因为灵活,所以 JSX 的结构是不可被序列化传输和存储的,这个也是 JSX 的局限,但是这个局限我们可以通过上层更进一步的领域解决方案来封装和解决

JSX 之下的 JSON 化

前面我们也提到 JSON 有非常好的机器友好度,尤其是面向搭建和智能化场景,这些场景都需要跨端,甚至是跨平台来传输和存储,所以在面向机器友好度的角度来看,我们还是要对 JSX 进行 JSON 化,前面我们也提到过,序列化传输和存储的问题要通过上层的解决方案来解决,那么我们要如何解决 JSON 化的问题呢

我们就拿上面这个例子的 formatter举例

<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
  >
  <interval position="x*y" />
  {
    // 类似这样的表达式在 JSON 中是不好描述的
    showAxis ? <axis field="x" formatter={v => v.toFixed(2)} /> : null
  }
</chart>

这个例子中,当我们面向所有业务场景时 formatter的格式是各种各样的,比如保留小数点后 0 - n 位,百分比,保留正负号,日期格式等等,因为我们无法穷举,所以通过函数来处理,但是又因为用了函数,这个结构体就不能被序列化了,这个也是我们前面提到 JSX 不可被序列化的原因

但是,当我们把业务场景局限到某个特定领域时, 这个场景的特点和诉求就能被枚举出来,所以当我们面向某个特定领域时,formatter 的格式也是可能被枚举出来,比如某个场景小数点统一保留 2 位,需要百分比,日期处理等等这些具体的规则,这个时候我们就可以定义 formatter 的类型为 'toFixed' | 'percent' | 'date' 等

一旦可枚举之后,那么我们就可以用如下的方式来表达了

<chart
  data={[
    {"category": 1, "value": 4},
    {"category": 2, "value": 6},
    {"category": 3, "value": 10},
    {"category": 4, "value": 3},
    {"category": 5, "value": 7},
    {"category": 6, "value": 8}
  ]}
  scale={{
    value: 'linear',
  }}
  coord='polar'
  >
  <interval position="x*y" />
  <customAxis visible={ true } field="x" formatter="toFixed" />
</chart>

而这个结构,就完全可以用下面的 JSON 来描述了

{
  type: "chart",
  data: [],
  coord: "polar",
  children: [
    { type: "interval", position: "x*y" },
    { type: "customAxis",  field: "x", visible: true, formatter: 'toFixed' }
  ]
}

这个 JSON 是完全可以被序列化的,而这个转换在代码上仅仅只是对 axis 进行了再次的封装,而这个也是我们提供给业务,让业务可以做领域二次封装的能力,业务的二次封装还能带来业务使用的便利性。

所以,JSX 不仅能有清晰的结构描述,还有良好的编程能力和扩展性,而且通过二次封装,我们还能 JSON 序列化,所以 JSX 的形式无疑是当下最好的选择。

领域解决方案

我们再来聊聊领域解决方案,我们面向的是整个移动端的数据可视化场景,因为需要考虑通用性和灵活性,它的易用性往往是不够的,还是拿我们前面提到的这个formatter举例,在面向特定领域时,格式化的差异会非常大。比如金融类的股票、基金场景:formatter 不仅需要格式化数字,还需要根据数字的 正、负、零来显示不同的颜色(俗称红涨绿跌平盘色),而且美股颜色还需要相反(绿涨红跌),如果业务中这些统一的规则要反复处理,那估计是要发疯的,所以就需要领域解决方案来解决这些问题

这里顺便再剧透一下,在不远的将来,我们还会在 F2, F6 之上,主要从易用性的角度来考虑,并给大家带来更简单易用的移动端可视化解决方案 FCharts,让普通场景使用简单的同时,也让领域解决方案变得更加方便和易用,敬请期待。

最后

总结下来,我们选用 JSX 就是看中 JSX 的可编程性、易扩展性、完整的结构描述和成熟的配套,不仅是当下对编程友好度、业务复杂度的考虑,还有未来面向搭建和智能化友好的 JSON, 都是一种很好的选择。

最后,如果想了解更多细节欢迎 star 我们的 GitHub 和 官网