节点关系可视化

airglass.js最佳实践第二弹。我特别为airglass.js新增了Module和BezierLine两个类,因为模块这种抽象概念的实际应用场景众多。我所理解的模块就像一个个小黑匣子,黑匣子一面从输入端口接收外界信息,一面向外界环境中通过输出端口发送信息,每一个黑匣子都能相互连接。可视化模块利用黑匣子这一特点,无需模块内部如何运作,只关心模块的输入和输出信息以及模块之间的关联关系。

Airglass.js模块连接表示家族关系

创建节点类

如我前面所说,模块一个重要的特征是,暴露输入和输出端口,隐藏内部逻辑。我为每一个Module的实例赋予一个imports属性和一个exports属性,imports作为输入端口存储,exports存储所有输出端口。

airglass.js可视化类继承关系

Module类在应用于实践中也在不断优化。每一个模块都id属性标志模块的唯一性,并且每个模块的全部I/O端口也都是唯一的。目前每一个模块的所有I/O端口并不能可视化除了端口id的其他信息。在实际场景中,端口会拥有名称或其他描述端口的信息。

比如将一个函数比作模块,函数的形参数量就是这个模块的输入端口数量,而参数的名称就是端口的名称,函数的返回值就好比模块的输出端口。同理,模块化编程思维时将每个文件作为一个模块的开发方式,在用可视化手段描述这些编程之间的关系时,I/O端口应该注明模块导入了哪些模块,以及当前模块的输出信息。

编辑黑匣子

虽然前面我将模块比作黑匣子,但是从界面上操作模块内部并不会真正触碰到黑匣子内部的逻辑,所以描述模块的不一定是寥寥几个字,而是可以在模块上做交互的,从而影响模块本身的特性。

实际应用场景中能代表模块的形式可以不拘泥于极简的框框,虽然这是简单明了的形式,这都是后话了。在模块上以及模块之间做交互;考虑模块多样化的表现形式。继续在实践中优化扩展模块的功能吧。

编辑模块间的关联关系。模块的输出端口是连线的起始位置,模块的输入端口是连线的终止位置。从任意输出端口拖拽出一条曲线去连接另一个模块的输入端口,从而建立起模块与模块之间的连接关系。除了创建关联,如何解除关联关系,以及如何给模块创建新端口或删除模块的多余端口。

Airglass.js通过模块建立连接

在订阅了touchstart事件的逻辑中,应该检测触发touchstart事件时端口的类型。我在创建端口时,给端口对象分配了标志端口类型的属性,端口的类型有source和target两种。

到目前为止,我创建了四个Glass渲染器:

  • host渲染器,渲染全部已创建的模块;
  • port渲染器,渲染全部已创建的端口;
  • link渲染器,渲染模块的连接线;
  • controller渲染器,该Glass用于捕获和委托外部事件。

在创建和拖拽模块连线的过程中,抬手或释放鼠标变可以中断建立模块间的连接关系。正在创建的模块连线可能并不会成为最终的连线,所以我创建了新的用于渲染临时连线的渲染器:tempLink。

目前我给airglass.js定义的能订阅到的渲染器派发事件名称包括:

  • mousemove,针对使用鼠标操作的场景,鼠标在目标元素中滑动时触发;
  • touchstart,手指触摸屏幕或鼠标按下时触发;
  • touchmove,手指按在屏幕上并移动或鼠标按下并滑动时触发该事件;
  • touchend,手指抬起或释放鼠标时触发该事件。

通过前一个Airglass.js最佳实践,我总结了一些开发习惯。比如关于订阅渲染器的事件,最佳实践是最好只绑定一个事件订阅处理器:

renderer.subscribe(renderer, actor => {
  // 渲染器触发事件后执行这里的逻辑
})

我尝尝在订阅事件处理器中分别处理mousemove、touchstart、touchmove、touchend四个事件类型,经常会遇到停止处理当前事件的逻辑,这时用到label语法以及break语法。还有在处理touchstart时,往往在这一事件类型的逻辑中存储激活中的元素。例如activeHost和activePort这种命名方式。

对于在触发touchstart事件后,当需要检测和存储激活中的元素时,这时存在优先级策略。比如,对于这Airglass.js的第二个最佳实践来说,总是要先处理端口,再处理模块。

到目前为止,实现了通过已有数据渲染节点连接,还实现了从节点的输出端口拖出一条临时连线。在节点的输入端释放鼠标完成节点间端口对端口的关联关系。并解决如何断连端口之间的连线。

Airglass.js节点输出端口与输入端口相连通

连通

节点之间通过端口互相连接,我在写这篇想法时,最初的设想是规定每个节点只能有一个输出端口,节点的输入端口则至少有一个。虽然我在后来打破了这种限制,现在每个节点可以有任意多的输入端口和任意多的输出端口。

从输出端口拖出的连接线需要找一个停靠的地方,这个地方必须是某个节点的输入端口,输入配输出。并且我规定了节点的输出端口只能去连接其他节点的输入端口,而不能连接端口宿主节点的输入端口。因为我还没想到这种情况存在的必要性。

同时,我还规定了一个输入端口只能和一个输出端口配对。也就是说输入端口有占线状态,当输入端口已经连通的输入端口时,将拒绝和“造访”的其他输出端口连通。与此相反,同一个输出端口可以连接到多个节点的输入端口。这是因为,输入端口需要的输入值同一时刻具有唯一性,而输出端口输出的数据可以存在多个数据副本。

断连

我思考过Airglass能具备的交互体验。就拿断开输入端与输出端口之间的连接的操作步骤来说。目的很单纯,断开连接意味着已有输入值的数据的擦出,或者再次连接时数据的覆盖,顺便更新渲染器。

最终我将断连操作放在了输入端口上。这样一来,输出端口能主动发起连接请求,输入端口能主动断开与输出端口的连接。

经过了一周断断续续的探索,Airglass.js的第二个组件NodeLink终于大功告成。这期间我从Dribbble和Behance等设计网站借鉴了不少创意灵感,以及Blender操作界面给我的启发。在开始下一个组件的开发之前,我想通过这篇想法简单谈谈NodeLink组件。顺便在这里公布Airglass.js的官方文档地址,因为我在写组件的同时更新Airglass.js库,所以目前的文档更新非常频繁也极不稳定,所以当前文档只是为了方便我参考。下方截图中显示的Airglass.js全部内容很可能在未来有大的改变。

节点连接演示

Airglass.js的NodeLink组件演示动图

上方的动图从呈现的角度全面的展示了NodeLink组件在节点之间连接关系和节点所承载信息的视觉效果。NodeLink组件除了呈现节点连接关系以外,还具备编辑节点的能力。和Label组件相同,NodeLink组件也是可配置的。并且NodeLink可以导出配置文件或导出配置文件。配置文件中包含了对每一个节点的名称、输入端口、输出端口的详细描述,以及输出端口与输入端口的连接关系的详细描述。关于NodeLink更多的开发细节可以在我之前发布的的开发笔记想法中查看。

Airglass.js文档

Airglass.js官方文档地址

既然Airglass.js目前的API不稳定,我为什么还要搞一个文档出来。这是因为我在写组件的同时不断调整着Airglass.js的API,这造成了一个已知的麻烦,就是在我回头将旧API写的组件升级到新API的书写方式时,已经记不清某一个功能的API是如何定义的了,好几种废弃的API调用方式在脑子里打架。将API梳理成文档的好处就是,可以非常清楚的看到当前Airglass.js中某个功能的最新使用方式。

说到自动生成文档的工具,这要感谢Deno的官方带给我的启发。Deno官方文档采用Typedoc生成,于是我照猫画虎,安装并配置好Typedoc。庆幸的是,我的Airglass.js源码也是使用Typescript编写的,所以很顺利的使用Typedoc生成了文档。配置参数我直接写到了tsconfig.json中,因为我也不会经常修改这些参数,不需要写成命令,就Airglass.js我是这样配置参数的:

"typedocOptions": {
  "mode": "file",
  "out": "../web/airglass/typedoc/",
  "name": "Airglass.js 官方文档",
  "readme": "none",
  "theme": "minimal"
}

Typedoc还提供解析注释的能力,方便浏览文档时理解各个功能的作用。我也在不断按照Typedoc官方给出的注释格式为Airglass.js增加注释,因为我发现,即使是自己的库,代码增多和逻辑变复杂之后连自己都会忘掉其中的一些实现细节。所以文档真的很重要,不仅对开发者来说,对作者也是很重要。比如Typedoc将类的继承关系清楚的罗列出来。过去我都是在百度脑图中绘制类的继承关系,但总是图形化的继承关系和代码是分割的。这下好了,和代码相关的都在文档里了。

改进命名空间

FUI风格其中一个非常重要的特征就是形式感细节。最一开始我在定义组件时,习惯将每一个具有形式感的细节都定义成一个类,每个文件代表一个类。后来越来越觉得,随着细节的增加,这样靠占据文件堆叠的细节会让我爆炸,所以我开始将从属于某个组件的细节的类直接定义在组件内部。就拿Node组件来说,节点的端口需要提取一个类表示它,节点之间的连接线也需要提取一个类表示它。

class NodePort {}
class NodeLine {}

class Node {
  createPort(){
    return new NodePort();
  }
  createLine(){
    return new NodeLine();
  }
}

这样做的好处包括可以简化命名。如果按照旧思路,所有细节都存储一个文件,我就需要花更多精力组织源代码的目录结构。换句话说,这种改进方式就是为各种细节绑定命名空间。

在输入端口与输出端口连通的一瞬间,除视觉形式外,更大的意义是执行相应的逻辑。更严谨地说,端口之间并不是瞬间连通的,这取决于逻辑执行的时间长短,我规定,只有端口处理器将逻辑成功执行完毕才可以将输入端口与输出端口连接。从视觉上能直观反应出逻辑执行成功与否。

NodeLink+WebAudioAPI,Bilibili演示DEMO

Airglass.js节点端口尝试连接并执行逻辑

逻辑处理器

起初,我将逻辑处理器安插在输出端口所在的节点上,节点负责维护一个数据,但这只能满足一个节点只有一个默认输出端口的情况。如果一个节点存在多个输出端口,反而将逻辑处理器安插在相应输出端口上更为便利。

我在序言中提到,端口之间并不是瞬间完成连接的,对于耗时的异步逻辑,真正的连接成功总是在异步逻辑成功运算后完成。不论从输出端口拖出一条连接线寻找到一个输出端口,或是在输入端口上断开与当前输入端口的输入端的连接,只能称得上的一种请求,Promise很适合完成这件事。

逻辑处理器处理应该包括处理请求连接和请求断开连接两种请求类型。起初,我所困惑的是不知道将This指向谁,当时的处理方式是,当请求类型是连接,This指向输出端口所在的节点;当请求类型是断开连接,This的指向是输入端口所在的节点。后来我也深陷泥沼,于是我对这种方式加以改进。

目前,无论请求连接或是断开连接,This总是指向输出端口,输入与输出端口是相互引用的,通过输出端口能找到输入端口,同样,通过输入端口也能找到输出端口。处理器不再由节点维护,而是由输出端口维护。有多少输出端口,理论上就有多少个逻辑处理器。

请使用Github账号登录留言