今天我们开始从零学习g6的源码,为什么学习源码呢,因为框架源码相比于业务代码,会有让我们体会到更多的架构设计方案。在关系可视化这一领域,理解并能实现G6,也是我们关系可视化工程能力的一部分。
在看源码之前,我们一定要带着问题来看。还记得我们《从零绘制关系图-1 Simple Graph》吗?我们用最直观最原始的方式实现了一个简单关系图。那么如果由小见大,把它做成一个框架,应该是什么样子的呢?
- 1.点和边的数据类型定义 ===> G6.Config
- 2.定义节点和边的位置,xy坐标 ===> G6.Layout
- 3.通过canvasAPI批量绘制节点和边 ===> G6.Render
以上是我们的猜想,那我们看着G6的源码,看它是如何在框架层面解决这些问题
1.熟悉版本演变历史与顶层API设计
默认你已经了解了[G6的基本用法](https://yuque.antfin-inc.com/antv/g6-3.0/uigdaf)。通过git的提交记录,你可以看到这一文件的演变路径。
TomHuangCN authored 2018/12/11 @ 20:41 Release 2.2.3
Elaine1234 authored 2018/12/17 @ 14:35 chore: sort out 3.0.0 dirs
也就是说G63.0版本是在2018年12月17号正式开始了代码工作,而它延续的版本是G6@2.2.3。它的一些API设计还是沿用了2.0版本,但是少了几个API。下图是3.0版本的顶层API设计
const Global = require('./global');
const G = require('@antv/g/lib');
const Shape = require('./shape');
const Behaviors = require('./behavior');
const G6 = {
Graph: require('./graph/graph'),
TreeGraph: require('./graph/tree-graph'),
Util: require('./util/'),
G,
Global,
Shape,
registerNode: Shape.registerNode,
registerEdge: Shape.registerEdge,
registerBehavior: Behaviors.registerBehavior,
version: Global.version,
//对比2.0,少了如下几个API
Layouts: require('./layouts/'),
Plugins: {},
Components: {},
registerGroup: Shape.registerGroup,
registerGuide: Shape.registerGuide,
track(track) {
Global.track = track;
}
};
module.exports = G6;
之前 2.x 的问题是因为没空开发,一直在服务业务,业务就是,他们需要一个这个,我们就吭哧吭哧去帮忙实现了,最后一旦要迭代,要改版,我们不支持,这个业务就死了,我们不想重蹈覆辙,所以这次G6很简单 G6 3.0 重构的目的:> - 提供更简单、易用的接口
- 更好的性能
- 满足图分析需求
通过顶层的API设计,我们可以猜想出 G6的 实例化方法应该是 Graph,未来随着业务的不同,可以扩展出
- Graph:通用图可视化
- TreeGraph:树图
- AnalyzerGraph:分析图
- …
G6的扩展性设计都在Register方法里
- registerNode:自定义节点
- registerEdge:自定义边
- registerBehavior:自定义行为/事件
2.验证之前的想法,得到新的输入
通过顶层API,我们直接看
src/graph/graph.js
,看这个文件中能否得到我们之前的猜想验证答案
很快,我们找到了第一个答案 **1.点和边的数据类型定义 ===> G6.Config**
,G6中不仅仅是点和边的数据类型存储,它还包含了很多很多用户配置,这些都在getDefaultCfg中。而多出来的配置,就是给我们新的输入,这些输入可能是我们通过简单的关系图绘制没考虑到的,比如缩放事件,动画等。
Class Graph extends EventEmitter {
getDefaultCfg() {
return {
nodes: [],
edges: [],
itemMap: {},
minZoom:0.2
...
}
constructor(inputCfg) {
super();
//在G6中数据结构和用户配置 存放在_this._cfg中
this._cfg = Util.deepMix(this.getDefaultCfg(), inputCfg);
this._init();
}
}
3.粗略梳理,看清脉络—-分析Graph类
graph.js 文件很长,大概有992行,一行行看下去肯定不明智。快速的梳理能让我们抓住核心,了解其设计想法。我在这里归纳总结表格下:
功能点 | 方法名 | |
---|---|---|
图的生命周期 | 创建 | init |
加载 | data | |
绘制 | render | |
更新 | changeData | |
清除 | clear | |
销毁 | destroy | |
图的交互操作 |
通用事件 | 集成EventEmitter |
改变大小 | changeSize | |
调整视窗 | fitView/focusItem | |
缩放 | zoom/zoomTo | |
保存 | save | |
刷新 | refreshPositions | |
图的元素操作 | 添加 | add/addItem |
删除 | remove | |
更新 | update/updateItem | |
显示 | showItem | |
隐藏 | hideItem | |
图的通用方法 | 查找节点/状态 | findById/findAllByState |
查找元素 | find//findAll | |
导出图片 | downloadImage | |
查找内部配置 | get/getZoom/getNodes getEdges/getCurrentMode |
|
注册 | addBehaviors/removeBehaviors |
4.通过debug,了解运行全过程
了解Graph.js后,我们就不能再肉眼看代码了(除非你经验很丰富)我们需要启动G6的DEMO,通过再源码中打断点,分析运行全过程
打开package.json文件,找到项目的编译入口,执行下面的命令,可以开启本地web-demo服务和 源码编译的watch模式
npm run dev
我们打开 image-node 这个demo,看到G6官方写的demo
我们可以依次在Graph .js中的constructor,data,render 这三个方法处打断点。了解其启动的关键执行处
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
modes: {
default: ['drag-node','click-select']
}
});
graph.data(data);
graph.render();
graph.on('node:click', e=> { console.log(e) })
emmm~这个时候,你需要泡一杯绿茶,边喝边调试,享受这个过程。
记住__随时在你的console中打印你需要验证的结果,源码分析最重要的就是带着问题去调试,带着猜想去验证。
- 比如上图,我们知道Graph中的唯一属性是_cfg,下划线的命名方式大概是内部的属性,不想暴露给用户。
- 通过构造函数的执行,Graph实例this,有了_cfg的变化
- renderer:”canvas”,看来默认的绘图方式是canvas
- 最大缩放是10,最小缩放是0.2
- ….还有很多猜想,不能急躁,喝口茶慢慢去验证
- 这个时候,断点来到了this._init()方法处,要不要进入就看你自己的选择,我们时间还很充裕,那就step into
在这里,我们一眼就可以看出来,这个controller是用来做代码切分的 code splitting。把graph实例对象传入不同的控制器中,然后得到的不同控制器实例再设置到_cfg中。这样Graph内部想用到和事件相关的处理逻辑,就在_init() {
this._initCanvas();
const eventController = new Controller.Event(this);
const viewController = new Controller.View(this);
const modeController = new Controller.Mode(this);
const itemController = new Controller.Item(this);
const stateController = new Controller.State(this);
this.set({
eventController,
viewController,
modeController,
itemController,
stateController
});
this._initPlugins();
}
this._cfg.eventController中处理。既做到了代码分割,也满足了功能分割。很棒的操作俗话说,一图胜千文,对于一个框架而言,梳理其执行过程,最佳的方法就是整理思维导图
5.运行全流程-初始化阶段Init
initCanvas
本质是调用G的Canvas绘图方法,最终在将canvas实例挂载在_cfg属性上(可以通过console验证)
const canvas = new G.Canvas({
containerDOM: container,
width: this.get('width'),
height: this.get('height'),
renderer: this.get('renderer'),
pixelRatio: this.get('pixelRatio')
});
this.set('canvas', canvas);
InitGroup
伪代码:canvas.addGroup(“root”).addGroup(“Node/Edges”) 这个后续我们解释,用于递归绘制Group
InitControllers
无论是事件/模式/视图/状态/元素 ,都是在构造函数中将graph实例绑定到this上,便于后续的处理。对于EventController,多一步初始化events事件
const EVENTS = [
'click',
'mousedown',
'mouseup',
'dblclick',
'contextmenu',
'mouseenter',
'mouseout',
'mouseover',
'mousemove',
'mouseleave',
'dragstart',
'dragend',
'drag',
'dragenter',
'dragleave',
'drop'
];
_initEvents() {
const self = this;
const graph = self.graph;
const canvas = graph.get('canvas');
const canvasHandler = Util.wrapBehavior(self, '_onCanvasEvents');
Util.each(EVENTS, event => {
canvas.on(event, canvasHandler);
});
}
也就是说,所有的事件都绑定到canvas元素上。这个handler是onCanvasEvents。必然要在这个函数内部做事件代理,因为canvas上需要通过点击区域来判断是哪个元素触发了事件。这块可以参考MDN canvas Hit_regions
InitPlugins
通过代码,我们可以在知道plugin的使用方式是写在配置项里_cfg,因为self.get('plugins')
,然后执行initPlugin方法,构造函数必然传入graph实例。
总结下,插件 就是在外部 提供一个核心运行实例 供用户调用 的机制
_initPlugins() {
const self = this;
Util.each(self.get('plugins'), plugin => {
if (!plugin.destroyed && plugin.initPlugin) {
plugin.initPlugin(self);
}
});
}
6.运行全流程—data与render
Graph.data()
将用户的数据,通过set方法,设置到this._cfg.data中
/**
* 设置视图初始化数据
* @param {object} data 初始化数据
*/
data(data) {
debugger;
this.set('data', data);
}
Graph.render()
render() {
const self = this;
const data = this.get('data');
this.clear();
Util.each(data.nodes, node => {self.add(NODE, node)});
Util.each(data.edges, edge => {self.add(EDGE, edge)});
self.paint();
self.emit('afterrender');
}
clear() {
const canvas = this.get('canvas');
canvas.clear();
this._initGroups();
this.set({ itemMap: {}, nodes: [], edges: [] });
return this;
}
- Clear:1.先调用canvas的clear,清空画布。原因是Canvas画布每次绘制前都需要清空,你可以理解它是新叠加一层画布,如果不想要之前底下的那层,就需要清除掉,详见MDN 2.初始化group 3.清除清空目前 Graph 上的节点和边实例
遍历所有的点和边,分别执行,通过这里我们可以认识到,原始的数据data对于graph来讲就是model
this.get('itemController').addItem(type, model)
调用paint方法绘制
paint() {
this.emit('beforepaint');
this.get('canvas').draw();
this.emit('afterpaint');
}
至此,我们就梳理出了Graph从启动到挂载数据,再到渲染的全流程。不过这里面有几个点我们或许很感兴趣
原始的数据nodes,edges 如何转化成Grpah的Node和Edge实例
- 绘图调用G.Canvas.draw,底层绘图库G究竟是怎么运行的
- 事件代理onCanvasEvents到底是如何处理的?
- 自定义节点的机制是什么?
不急,我们慢慢梳理,以上运行过程是我们的第一部分,总结的思维导图如下,大家可以参考理解