今天我们开始从零学习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设计

  1. const Global = require('./global');
  2. const G = require('@antv/g/lib');
  3. const Shape = require('./shape');
  4. const Behaviors = require('./behavior');
  5. const G6 = {
  6. Graph: require('./graph/graph'),
  7. TreeGraph: require('./graph/tree-graph'),
  8. Util: require('./util/'),
  9. G,
  10. Global,
  11. Shape,
  12. registerNode: Shape.registerNode,
  13. registerEdge: Shape.registerEdge,
  14. registerBehavior: Behaviors.registerBehavior,
  15. version: Global.version,
  16. //对比2.0,少了如下几个API
  17. Layouts: require('./layouts/'),
  18. Plugins: {},
  19. Components: {},
  20. registerGroup: Shape.registerGroup,
  21. registerGuide: Shape.registerGuide,
  22. track(track) {
  23. Global.track = track;
  24. }
  25. };
  26. 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中。而多出来的配置,就是给我们新的输入,这些输入可能是我们通过简单的关系图绘制没考虑到的,比如缩放事件,动画等。

  1. Class Graph extends EventEmitter {
  2. getDefaultCfg() {
  3. return {
  4. nodes: [],
  5. edges: [],
  6. itemMap: {},
  7. minZoom0.2
  8. ...
  9. }
  10. constructor(inputCfg) {
  11. super();
  12. //在G6中数据结构和用户配置 存放在_this._cfg中
  13. this._cfg = Util.deepMix(this.getDefaultCfg(), inputCfg);
  14. this._init();
  15. }
  16. }

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模式

    1. npm run dev
  • 我们打开 image-node 这个demo,看到G6官方写的demo

    我们可以依次在Graph .js中的constructor,data,render 这三个方法处打断点。了解其启动的关键执行处

  1. const graph = new G6.Graph({
  2. container: 'mountNode',
  3. width: 1000,
  4. height: 600,
  5. modes: {
  6. default: ['drag-node','click-select']
  7. }
  8. });
  9. graph.data(data);
  10. graph.render();
  11. graph.on('node:click', e=> { console.log(e) })

emmm~这个时候,你需要泡一杯绿茶,边喝边调试,享受这个过程。
记住__随时在你的console中打印你需要验证的结果,源码分析最重要的就是带着问题去调试,带着猜想去验证

image.png

  • 比如上图,我们知道Graph中的唯一属性是_cfg,下划线的命名方式大概是内部的属性,不想暴露给用户。
  • 通过构造函数的执行,Graph实例this,有了_cfg的变化
  • renderer:”canvas”,看来默认的绘图方式是canvas
  • 最大缩放是10,最小缩放是0.2
  • ….还有很多猜想,不能急躁,喝口茶慢慢去验证
  • 这个时候,断点来到了this._init()方法处,要不要进入就看你自己的选择,我们时间还很充裕,那就step into
    1. _init() {
    2. this._initCanvas();
    3. const eventController = new Controller.Event(this);
    4. const viewController = new Controller.View(this);
    5. const modeController = new Controller.Mode(this);
    6. const itemController = new Controller.Item(this);
    7. const stateController = new Controller.State(this);
    8. this.set({
    9. eventController,
    10. viewController,
    11. modeController,
    12. itemController,
    13. stateController
    14. });
    15. this._initPlugins();
    16. }
    在这里,我们一眼就可以看出来,这个controller是用来做代码切分的 code splitting。把graph实例对象传入不同的控制器中,然后得到的不同控制器实例再设置到_cfg中。这样Graph内部想用到和事件相关的处理逻辑,就在
    this._cfg.eventController中处理。既做到了代码分割,也满足了功能分割。很棒的操作

    俗话说,一图胜千文,对于一个框架而言,梳理其执行过程,最佳的方法就是整理思维导图

G6源码阅读-part1-运行主流程 - 图2

5.运行全流程-初始化阶段Init

接下来我就按照顺序来看下整个初始化过程

initCanvas

  • 本质是调用G的Canvas绘图方法,最终在将canvas实例挂载在_cfg属性上(可以通过console验证)

    1. const canvas = new G.Canvas({
    2. containerDOM: container,
    3. width: this.get('width'),
    4. height: this.get('height'),
    5. renderer: this.get('renderer'),
    6. pixelRatio: this.get('pixelRatio')
    7. });
    8. this.set('canvas', canvas);

    InitGroup

  • 伪代码:canvas.addGroup(“root”).addGroup(“Node/Edges”) 这个后续我们解释,用于递归绘制Group

    InitControllers

    无论是事件/模式/视图/状态/元素 ,都是在构造函数中将graph实例绑定到this上,便于后续的处理。对于EventController,多一步初始化events事件

  1. const EVENTS = [
  2. 'click',
  3. 'mousedown',
  4. 'mouseup',
  5. 'dblclick',
  6. 'contextmenu',
  7. 'mouseenter',
  8. 'mouseout',
  9. 'mouseover',
  10. 'mousemove',
  11. 'mouseleave',
  12. 'dragstart',
  13. 'dragend',
  14. 'drag',
  15. 'dragenter',
  16. 'dragleave',
  17. 'drop'
  18. ];
  19. _initEvents() {
  20. const self = this;
  21. const graph = self.graph;
  22. const canvas = graph.get('canvas');
  23. const canvasHandler = Util.wrapBehavior(self, '_onCanvasEvents');
  24. Util.each(EVENTS, event => {
  25. canvas.on(event, canvasHandler);
  26. });
  27. }

也就是说,所有的事件都绑定到canvas元素上。这个handler是onCanvasEvents。必然要在这个函数内部做事件代理,因为canvas上需要通过点击区域来判断是哪个元素触发了事件。这块可以参考MDN canvas Hit_regions

InitPlugins

通过代码,我们可以在知道plugin的使用方式是写在配置项里_cfg,因为self.get('plugins'),然后执行initPlugin方法,构造函数必然传入graph实例。

总结下,插件 就是在外部 提供一个核心运行实例 供用户调用 的机制

  1. _initPlugins() {
  2. const self = this;
  3. Util.each(self.get('plugins'), plugin => {
  4. if (!plugin.destroyed && plugin.initPlugin) {
  5. plugin.initPlugin(self);
  6. }
  7. });
  8. }

6.运行全流程—data与render

Graph.data()

将用户的数据,通过set方法,设置到this._cfg.data中

  1. /**
  2. * 设置视图初始化数据
  3. * @param {object} data 初始化数据
  4. */
  5. data(data) {
  6. debugger;
  7. this.set('data', data);
  8. }

Graph.render()

  1. render() {
  2. const self = this;
  3. const data = this.get('data');
  4. this.clear();
  5. Util.each(data.nodes, node => {self.add(NODE, node)});
  6. Util.each(data.edges, edge => {self.add(EDGE, edge)});
  7. self.paint();
  8. self.emit('afterrender');
  9. }
  10. clear() {
  11. const canvas = this.get('canvas');
  12. canvas.clear();
  13. this._initGroups();
  14. this.set({ itemMap: {}, nodes: [], edges: [] });
  15. return this;
  16. }

  • Clear:1.先调用canvas的clear,清空画布。原因是Canvas画布每次绘制前都需要清空,你可以理解它是新叠加一层画布,如果不想要之前底下的那层,就需要清除掉,详见MDN 2.初始化group 3.清除清空目前 Graph 上的节点和边实例
  • 遍历所有的点和边,分别执行,通过这里我们可以认识到,原始的数据data对于graph来讲就是model

    1. this.get('itemController').addItem(type, model)
  • 调用paint方法绘制

    1. paint() {
    2. this.emit('beforepaint');
    3. this.get('canvas').draw();
    4. this.emit('afterpaint');
    5. }

    至此,我们就梳理出了Graph从启动到挂载数据,再到渲染的全流程。不过这里面有几个点我们或许很感兴趣

  • 原始的数据nodes,edges 如何转化成Grpah的Node和Edge实例

  • 绘图调用G.Canvas.draw,底层绘图库G究竟是怎么运行的
  • 事件代理onCanvasEvents到底是如何处理的?
  • 自定义节点的机制是什么?

不急,我们慢慢梳理,以上运行过程是我们的第一部分,总结的思维导图如下,大家可以参考理解 G6源码阅读-part1-运行主流程 - 图3