流程图JS实现方案对比
实现流程图和类流程图的工具主要需要解决数据 -> 图形和交互两方面问题。在实现图形元素时也有canvas,SVG,canvas with DOM,SVG with DOM,DOM with canvas一些实现方式。
canvas和SVG的实现方式区别比较明显:
- 大规模元素、频繁重绘上,canvas完胜
- 强调光影效果上,canvas小胜
- 强调导出图片上,canvas小胜
- 强调元素可交互上,SVG完胜
- 强调画图元素可缩放上,SVG完胜
使用SVG实现时,元素规模大以及频繁重绘时会出现卡顿现象,在大规模元素场景下交互也会有卡顿。使用canvas实现时,保证流程图元素的可交互性将成为头疼的难题,开发者需要自己模拟浏览器的一部分行为。
下面是一些流程图实现基础的对比。
d3
d3着眼在数据可视化,重点在使用不同layout组织数据,完成可视化。
d3最初是天然支持SVG的,这点从类jQuery的API也能看出来。d3和canvas的结合上,绘制需要额外的data binding操作,周期性地将虚拟的DOM节点映射到canvas上,重绘得到下一帧画面。要实现canvas可交互的话也需要一些hack的手段。基于d3实现流程图并不划算。
zrender
zrender是一个canvas画图的基础库。它并不负责数据的组织和渲染,需要自己完成这一部分工作。但是zrender提供了让canvas可交互的重要功能。
zrender下,mixin了Eventful特性的元素上可以监听交互事件。Eventful只是为元素提供了类似EventEmitter的功能。真正实现元素可交互的handler。
handler内会拦截发生在canvas内的click
/mousedown
/mouseup
/mousewheel
/dblclick
/contextmenu
事件,交予prototype内对应的处理方法处理,handler内有下面几个关键方法:
mousemove
,监听canvas内mousemove事件,调用findHover
得到当前位置对应的元素,根据情况调用dispatchToElement
方法,分发mouseout
,mouseover
,mousemove
给刚才得到的元素实例dispatchToElement
,分发事件到对应实例,将事件对象封装,trigger实例的对应事件handler,并通过el.parent
向上冒泡findHover
,指定x, y坐标寻找该坐标位置的元素。从storage中拿到所有的displayable的list。挨个调用isHover
判断displayable和[x, y]坐标的关系isHover
函数,根据displayable的rectHover属性,即是否使用包围盒检测鼠标进入。调用displayable的rectContain
或contain
检测是否在其中。
1 | function isHover(displayable, x, y) { |
先简单看下storage,因为zrender里绘制的元素之间没有逻辑关联,因此需要有一个全局存储storage去统一管理加入的Group或Shape。storage的getDisplayList
方法返回所有图形的绘制队列。
1 | getDisplayList: function (update, includeIgnore) { |
注:方法中提到的updateDisplayList
用于更新图形的绘制队列,在每次绘制前调用,它会深度优先遍历整个树,更新所有的变换后,根据优先级排序得到新的绘制队列。
在displayable的基类中,contain
方法只是单纯调用了rectContain
(子类都有区别于rectContain
的自己的实现)。在rectContain
中,获取到坐标相对于图形的坐标(transformCoordToLocal
)和图形的包围盒(getBoundingRect
)。这里先说简单的RectContain
。
1 | rectContain: function (x, y) { |
其中getBoundingRect
是各自类自己实现的。除了个别情况,如Text,形状都基于Path类。在Path的getBoundingRect
中可以看到,path的绘制又额外包装了一层PathProxy
,getBoundingRect
也是使用的PathProxy
的方法。在实现上,PathProxy把绘制路径的操作命令拆分成了命令数组。通过记录每一段子路径上x、y的最大最小值,再将所有这些极值比较得到最后的最值。在PathProxy返回结果后,根据描边粗细得到最终结果。
1 | getBoundingRect: function () { |
Displayable
继承自Element
,Element
通过mixin得到来自Transformable
中的transformCoordToLocal
方法。这里要说到,zrender中元素和Group都有一个构造时的初始位置,而后的所有变化都是作为transform叠加在元素上的。例如拖拽元素对应的是“原始位置 + transform”而不是一个“新位置”。
在每次变换后,Transformable
中的updateTransform
方法都会调用,设置自身invTransform
属性为这次变化的逆矩阵。在transformCoordToLocal
中对向量[x, y]应用这个逆矩阵即可得到点相对于当前形状的位置(可以理解成将点逆变换到形状变换前的位置)。
1 | transformableProto.updateTransform = function () { |
综合这两个方法即可判断点是否在某元素的包围盒中。
判断contain
时,首先需要满足rectContain
的关系。之后根据描边和填充情况,执行contain/path
下对应的contain
或containStroke
方法。前者实际上是后者stroke为0时的特殊情况。除了path外,可以判断点是否在元素图形内的所有元素在contain
下都有对应文件。基本所有的包含都可以转化为指定闭合路径是否包含指定点的问题。
zrender利用PIP(point-in-polygon)问题winding number的解法判断点是否在path中;canvas提供的API中也有isPointInPath
和isPointInStroke
,不过只能针对当前的path。
综上,zrender可以实现canvas内的元素和交互。
g6
g6是antv的一部分,是一个canvas实现的展示关系型数据的JS可视化库。使用canvas的原因应该也在展示大量数据和重绘上更流畅。
使用canvas实现时,g6一样会遇到zrender遇到的实现元素可交互的难题。从处理event的event.js中能看到,关联事件和元素的实现在_getEventObj
处完成,剩下的步骤只是额外的封装操作:
1 | Util.each(MouseEventTypes, item => { |
p.s. 另说一点,frontCanvas的作用是绘制拖拽状态中的元素和辅助线等信息。
最关键的方法getPointByClient
和getShape
来自Graph的canvas
属性,这个属性通过‘@antv/g’(G2)的canvas构造得来。
1 | const G = require('@antv/g'); |
在G2中,Canvas继承自Group,可以认为Canvas本身已经扮演了根节点的角色。Canvas判断坐标对应元素的方法getShape(x,y)
也来自Group。此方法遍历Group下所有元素(包括单个元素或Group),判断点[x, y]是否在范围内:
1 | function find(children, x, y) { |
关键的child.isHit
方法类似zrender里的contain
方法。区别使用包围盒还是自身范围判断。
1 | isHit(x, y) { |
使用包围盒时用getBBox()
判断,类似zrender;否则使用isPointInPath
。这点上g2不同,它只对特殊的闭合曲线如圆、矩形、贝塞尔曲线等等进行自己的实现。对一般性的path,直接使用上面提到的canvas的API来判断。
processOn
processOn严格意义上是一个产品,类似于在线的visio,编辑很流畅。使用DOM + canvas实现。具体来说:
- DOM绘制每个元素占位,响应交互
- canvas绘制每个DOM内的图形本身
这么做的好处在有二:1. 天然解决了元素交互的问题;2. 更平滑的元素拖拽效果。
类似的还有jsPlumb这样的使用SVG的方案,使用SVG的优势体现在交互更容易实现。