0%

G2源码解析之函数式API与规范式API

G2源码解析之函数式API与规范式API

版本:V5.2.8

背景

image-20241024172238716

G2 设计了一套规范(Spec) 去描述可以绘制的可视化,使得用户可以通过调用 chart.options(options) 根据指定的满足规范的选项(options) 去渲染图表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(() => {
const chart = new G2.Chart();

chart.options({
type: 'interval',
data: [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
],
encode: {
x: 'genre',
y: 'sold',
},
});

chart.render();

return chart.getContainer();
})();

基于底层的 Spec,为了提供更多样化和灵活地声明图表的能力,G2 也提供了一系列函数式 API 来声明图表,比如声明上面简单的条形图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(() => {
const chart = new G2.Chart();

chart
.interval()
.data([
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
])
.encode('x', 'genre')
.encode('y', 'sold');

chart.render();

return chart.getContainer();
})();

本文将探究G2是如何实现两套语法用于绘制图表的。

源码追踪

初始化图表

  1. 在构造函数中解构了RuntimeOptions,并对图表基础结构进行了初始化,以Interval举例,在_create中将上文的Interval继承MarkNode创建了Class Mark,并创建了以class markkey 作为名称的方法。
  2. options方法从参数中解构了type并更新了根节点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export class Runtime<Spec extends G2Spec = G2Spec> extends CompositionNode {
constructor(options: RuntimeOptions) {
const { container, canvas, renderer, plugins, lib, createCanvas, ...rest } =
options;
...
this._create();
}
...
private _create() {
...
this._marks = {};
for (const key of marks) {
const name = key.split('.').pop();
class Mark extends MarkNode {
constructor() {
super({}, name);
}
}
this._marks[name] = Mark;
this[name] = function (composite) {
const node = this.append(Mark);
if (name === 'mark') node.type = composite;
return node;
};
}
...
}
...
options(options?: Spec): Runtime<Spec> | Spec {
if (arguments.length === 0) return optionsOf(this) as Spec;
const { type } = options;
if (type) this._previousDefinedType = type;
updateRoot(
this,
options,
this._previousDefinedType,
this._marks,
this._compositions,
);
return this;
}
}

updateRoot中对具体的图表节点树做了增删改。appendNode中又调用了createNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Update node tree from options.
export function updateRoot(
node: Node,
options: G2ViewTree,
definedType: string,
mark: Record<string, new () => Node>,
composition: Record<string, new () => Node>,
) {
...
const discovered: [Node, Node, G2ViewTree][] = [[null, node, rootOptions]];
while (discovered.length) {
if (!oldNode) {
appendNode(parent, newNode, mark, composition);
} else if (!newNode) {
oldNode.remove();
} else {
updateNode(oldNode, newNode);
...
}
}
}

createNode中根据options中的type属性创建了具体的composition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function typeCtor(
type: string | ((...args: any[]) => any),
mark: Record<string, new () => Node>,
composition: Record<string, new () => Node>,
): new () => Node {
if (typeof type === 'function') return mark.mark;
const node = { ...mark, ...composition };
const ctor = node[type];
if (!ctor) throw new Error(`Unknown mark: ${type}.`);
return ctor;
}

// Create node from options.
function createNode(
options: G2ViewTree,
mark: Record<string, new () => Node>,
composition: Record<string, new () => Node>,
): Node {
...
const { type, children, ...value } = options;
const Ctor = typeCtor(type, mark, composition);
const node = new Ctor();
node.value = value;
// @ts-ignore
node.type = type;
return node;
}

函数调用式的参数写入

  1. 通过装饰器的方式将各个props作为方法写入了对应的Mark class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { markProps } from './props';
@defineProps(markProps)
export class MarkNode extends Node<Spec & { [key: string]: any }> {
...
}

export function defineProps(
descriptors: Record<string, NodePropertyDescriptor>,
) {
return (Node: NodeClass) => {
for (const [name, descriptor] of Object.entries(descriptors)) {
const { type } = descriptor;
if (type === 'value') defineValueProp(Node, name, descriptor);
else if (type === 'array') defineArrayProp(Node, name, descriptor);
else if (type === 'object') defineObjectProp(Node, name, descriptor);
else if (type === 'node') defineNodeProp(Node, name, descriptor);
else if (type === 'container')
defineContainerProp(Node, name, descriptor);
else if (type === 'mix') defineMixProp(Node, name, descriptor);
}
return Node as any;
};
}

function defineValueProp(
Node: NodeClass,
name: string,
{ key = name }: NodePropertyDescriptor,
) {
Node.prototype[name] = function (value) {
if (arguments.length === 0) return this.attr(key);
return this.attr(key, value);
};
}

配置式的参数写入

在创建图表类型的节点时将type, children之外的属性作为value写入了图表实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
function createNode(
options: G2ViewTree,
mark: Record<string, new () => Node>,
composition: Record<string, new () => Node>,
): Node {
...
const { type, children, ...value } = options;
const Ctor = typeCtor(type, mark, composition);
const node = new Ctor();
node.value = value;
...
return node;
}

配置式的实现方式

  1. 初始化图表时创建了options方法。并根据options中的type字段创建了对应的mark class

  2. 在创建对应的mark class之后将options中除了typechildren之外的属性作为value写入了mark class的实例。

函数式的实现方式

  1. 初始化图表时创建了与mark class名称对应的方法。
  2. 函数调用式的参数写入中为mark class中所需要的属性,创建了对应的方法。

总结

  1. 两种调用方式都是创建了一个某种类型的图表实例。
  2. 两种调用方式的不同本质上对应的是,创建图表类型实例方式的不同,以及参数写入方式的不同。
  3. 创建图表实例方式的不同本质上是通过 将所有的图表类型均作为function注册到runtime上、以及在options解析type并手动创建对应的实例来实现的。
  4. 不同的参数写入方式是通过 将所有的参数类型通过装饰器创建对应的function进各个不同的图表类型以支持函数式的参数写入、以及在options中解构typechildren之外的参数来实现的。

参考 & 引用

开始使用 | G2 (antgroup.com)