引言
本系列文章将全面介绍如何从零实现一个可视化节点编程语言,包含节点编程的IDE,编译器,解释器,语言内部的数据结构。通过阅读本文,读者可以更深的理解可视化编程的原理。如果是希望在自己的系统中集成可视化编程语言,也可以从本文获得有价值的信息。
1 可视化编程语言简介
可视化编程语言是通过图形化的符号及它们的连接等图形表达方式来描述数据和逻辑的的编程语言。
相对的,我们通常的编程语言,是通过文本的方式表达数据和逻辑。
目前编程领域,文本形式的编程语言占据主导地位。不过在一些特定领域里,比如艺术创作领域的软件,游戏设计领域,电路设计领域,自动化等,可视化编程却占据主导的地位。
还有一些非严谨用途的领域,比如教育,可视化编程也是占据了主导地位,代表作有 MIT 的scratch。
可视化编程语言按照表达的方式可以分为两类:
一类是通过拼图的方式,把逻辑块按顺序拼接在一起,代表语言有 MIT scratch和 google blockly
scratch
blockly
另一类是基于节点的可视化编程语言,这个类型的产品比较多,也覆盖了很多领域
比如游戏领域,虚幻引擎的 blueprints
比如设计领域,三维建模软件 blender 的 nodes
影视特效软件 houdini
历史
可视化编程语言的诞生可以追溯到上个世纪6~70年代。
如1968年的 GRaIL
GRaIL 1968
1975年的Pygmalion:
优势
简单直观
可视化编程语言一般面向非专业编程领域的人,用于特定领域的逻辑开发。这些用户通常是某个领域的专家,但他们不擅长编程。所以需要提供一种对于他们很直观容易学习使用的方式来设计逻辑,可视化编程就是最好的选择。直观的功能模块,配合功能模块之间的连线,就能表达一定的逻辑。比如多媒体设计的用户一般都是艺术家,他们一般都缺乏编程领域的知识,也不太可能让他们去深入了解编程。再比如,游戏设计,也是类似的情形,在游戏设计的初期,需要快速的进行原型设计,这部分工作一般由游戏策划来完成。在儿童编程教育领域,可视化编程的简单直观,也被作为儿童编程的启蒙工具。
可配置化
每一个可视化节点,都可以有数字输入,选项选择等UI交互,通过改变输入,可以方便的调整逻辑。对于艺术设计这类需要反复调整参数,实时查看效果的场景,就很适合了。在游戏设计领域也有类似的述求。
丰富的领域能力
通常可视化编程语言都是集成在某个特定领域的系统中,供专业人员使用。为了帮助专业人员快速实现功能,可视化编程语言都会集成大量与领域相关的功能模块。这也符合可视化编程语言的初衷,就是为了降低门槛,提升效率。
缺点
可扩展性差
可视化编程语言由于是基于图形化的块和连线,随着要表达的逻辑的规模变得复杂,所需要的块和连线也会变多,错综复杂。所以其不适合复杂的逻辑开发。
灵活性差
由于可视化编程需要直观简单的呈现,所以其图元不可能设计得很复杂,很灵活,不然会增加使用的难度。但也因为这样,造成了可视化编程不如文本编程灵活。
不利于版本管理
现在的版本管理系统,如 git 等都是基于文本比较的方式进行版本管理,文本天生有版本管理的优势。而可视化编程语言保存的方式是一堆块和线进行序列化后的数据,这些数据很难从字面上理解其意思,虽然也可以用 git 进行版本管理,但其版本比较所展示的差异,会比较难以辨认。
2 VL 节点编程语言的实现
前面大致介绍了可视化编程语言的基本情况。
本文着重要实现的是基于节点的可视化编程语言。我们暂且将其命名为 VL 节点编程语言。 VL 代表的是 Visual Language。
VL 从虚幻引擎的 blueprints 借鉴了语法表达方式,从 blender nodes 借鉴了样式。
之所以从 blueprints 借鉴了语法,是因为 blueprints 在游戏开发领域广泛应用,并且被普遍认可,目前有不少 3A游戏大作都采用了 blueprints 作为部分功能的开发语言。借鉴一款被认可的语言,让 VL 更加具有价值。
样式借鉴了 blender nodes 属于笔者个人的喜好。
VL 节点编程语言最终的实现效果如下:
2.1 整体架构
VL 语言系统分为4个主要模块,分别是:
可视化节点编辑器:
编辑器提供可视化编程的操作:包含了节点的创建,移动,连接,修改节点输入输出等操作
节点编译器:
通过编辑器绘制的逻辑表达,并不能直接作为解释器的输入,需要经过一轮转换让其成为抽象语法树(AST)
节点解释器:
领域功能函数:
2.2 技术选型
VL 的编辑器使用 svelte + typescript 的技术栈编写而成。
由于 VL 是运行在浏览器上,所以 VL 的编译器和运行时,都采用了 typescript 来编写。
2.3 语法的设计
VL 语言是定位为通用编程语言,所以其语法与我们常见的文本编程语言类似。同样包含了流程控制语句,运算符,变量的取值和赋值等。
但节点编程语言与文本编程语言有差异的时,节点编程需要用事件来驱动程序的执行,所以 VL 语言也有一系列事件语句。
2.3.1 节点的结构
每个节点由5个部分组成:
-
节点名:表示节点的功能
-
流程端口(链接上一个节点):用于连接上一个语句节点
-
流程端口(链接下一个节点):用于连接下一个语句节点,逻辑将沿着连线执行
-
输入端口:类似于函数的入参,可以通过把计算值的节点连接到输入端口,实现值的输入
-
输出端口:类似于函数的返回值,可以将输出端口连接到其他节点的输入端口。输出端口也可以用作流程端口,通常出现在流程控制节点上,用于处理多逻辑分支。比如 if 节点,可以有两个分支,truePart 和 falsePart,truePart连接到条件为真的流程的首个节点,同理,falsePart 连接到条件为假的流程的首个节点。
2.3.2 事件语句
begin 语句:程序逻辑的开始,解释器会找到 begin,并从 begin 开始执行程序。
2.3.3 变量
set value 语句:用于为一个变量赋值。在 VL 里,不需要对变量进行声明,首次赋值相当于一次声明和初始化。
get value 语句:负责取得变量的值。
constant:用于定义常量。可以输入字符串,数字等。
2.3.4 运算
VL 目前运算包含两种类型:四则运算和比较运算
add 语句:用于计算两个数字的和,类似的节点还有 sub(减法),mul(乘法), div(除法), mod(取余) 等语句。
语句:用于比较两数的大于关系,类似的节点还有 >=, <, <=, ==, != 等。
2.3.5 流程控制语句
if node 语句:用于处理判断逻辑,condition 节点连接判断条件节点,接收判断条件的值。
truePart节点连接条件为真的流程, falsePart节点连接条件为假的流程。
while node 语句:用于迭代逻辑,condition 节点连接判断迭代条件节点,loop节点连接循环体
2.3.6 函数库
函数库节点是一系列 VL 语言内置的函数,可以处理数学,绘制图形,输出等功能。
这里拿 print 函数做示范:
message 是 print 的输入端口,用于提供打印的文本
2.4 数据结构的定义
可视化节点语言最核心的数据结构是节点
前文已经详细讲解了节点的图形化表达方式,如图所示。
笔者为节点定义了两种数据结构,分别对应于显示在编辑器的节点,及处在运行时的节点。
2.4.1 编辑器节点定义
在编辑器显示的节点,除了需要显示其节点名称,端口名称,还需要端口的连接,及节点的位置,所以可以定义如下的数据结构表示:
// 端口的定义
export type IPort = {
// 端口的名称
name: string;
// 端口往外连接的另一个端口的ID
toPort?: string;
// 端口被连接的另一个端口ID
fromPort?: string;
// 端口的附加数据,通常用于携带端口的输入
data?: string;
}
// 编辑器节点
export type INode = {
// 节点ID
id: string;
// 节点名称
title: string;
// 输入端口
inPorts: IPort[];
// 输出端口
outPorts: IPort[];
// 节点坐标
x: number;
y: number;
}
数据结构的关联关系可以看下图所示
每个节点都有两个数组分别保存节点的输入端口和输出端口。
两个端口的连接,通过 fromPort 和 toPort 两个字段进行关联,起点端口的 toPort 会指向结点端口,而结点端口 的 fromPort 会指向起点端口。
端口的 data 字段用于记录端口的值,通常当端口不通过连接其他节点来获取输入时,可通过输入框获取值,而该值会被保存到 data 字段中。
2.4.2 运行时节点
运行时的节点与编辑器节点一一对应。运行时节点其实就是抽象语法树(AST)节点,它代表 VL 的语义关系。
运行时节点的结构更为紧凑,相较于编辑器节点,运行时节点不需要下面的信息:
-
节点坐标:坐标信息
-
端口信息:端口信息会在编译过程被转换成节点间的关联关系
数据结构定义如下:
// 运行时节点
export class IAstNode {
// 节点指令
command: string;
// 指向编辑器节点,用于可视化调试
sourceId?: string;
// 指向流程的下一个节点
nextNode?: IAstNode;
// 指向其他节点,具体的语义随节点指令变化
nodes: IAstNode[];
// 节点数据,与编辑器节点的输入框数据关联
data?: string;
}
2.5 编辑器的实现-画一个程序出来
实现节点编辑器,主要是实现下面的功能:
- 节点的绘制和拖拽
- 端口的连接及连线的绘制
2.5.1 节点的绘制
笔者采用的是 svelte 来开发编辑器,所以节点的绘制,可以通过声明式的模板方式来编写:
<div
class="node">
<div id={node.id} class="title">
{node.title}
</div>
<div class="ports">
<div class="in-ports">
{#each node.inPorts as port}
<Port name={port.name} nodeId={node.id} />
{/each}
</div>
<div class="out-ports">
{#each node.outPorts as port}
<Port name={port.name} isOut={true} nodeId={node.id} />
{/each}
</div>
</div>
</div>
每一个编辑器节点都会有一个唯一节点ID(node.id),每一个端口同样也有一个端口ID(port.name)。
节点ID和端口ID是实现节点的拖拽的关键,不仅起到识别具体节点的作用,也可以用来区分鼠标拖拽在节点和端口上的不同行为。比如,节点上的拖拽,会使节点发生位移,而对端口的拖拽则是用于连线。
2.5.2 节点的拖拽
节点的拖拽这里采用鼠标的 mousedown, mousemove, mouseup 事件配合实现。
其实也可以使用 html 的 draggable, dragstart 等事件实现,但这里为了实现实时拖拽效果,选择了通过鼠标基础事件自行实现拖拽。
拖拽的实现逻辑如下:
-
当鼠标按下时,判断当前的图形元素是否为节点
-
如果是节点,标记当前状态为拖拽中(即 dragging = true),并记录当前移动的节点
- 鼠标移动时,如果状态为拖拽中,根据鼠标移动偏移量,更新节点的坐标(抛出拖拽事件,通知逻辑层根据节点ID和坐标偏移量,更新对应的节点坐标)
-
当鼠标释放时,清除拖拽状态(即 dragging = false)
鼠标按下:
const handleMouseDown = (e) => {
// 判断当前图形是否为节点,即 id 符合 node-{num} 这样的规则
if (/^node-\d+$/.test(e?.target?.id)) {
// 标记当前正在拖拽
dragging = true;
// 标记当前移动的节点
movingNode = e.target.id;
return;
}
};
鼠标移动:
const handleMouseMove = (e) => {
// 只有在拖拽状态下,才通知节点移动
if (dragging) {
// 使用 svelte 的事件通知,往父级组件抛出拖拽事件
dispatch("command", {
cmd: "move",
id: movingNode,
movementX: e.movementX,
movementY: e.movementY,
});
}
};
鼠标释放:
const handleMouseUp = (e) => {
// 清除拖拽状态
dragging = false;
};
2.5.3 端口的连接
端口的连接,使用的机制,与节点拖拽类似,也是响应鼠标按下,移动,释放。
区别是,端口连接需要额外处理拖拽过程中临时连线的状态(当用户未将连线连接到另一个端口时,需要展示一条临时的连线)
并且,连线中的数据结构,与连线完成的数据结构,是不同的。
因为连线中,我们并不清楚另一端的端口,所以最好是记录连线两端的坐标,如下所示
export type IPoint = {
x: number;
y: number;
}
export type IEdge = {
start: IPoint;
end: IPoint;
}
而连线完成时,两个端口的信息已知,我们只需要知道端口的连接信息(通过 fromPort, toPort)即可计算出连线。
export type IPort = {
name: string;
// 指向另一个端口
toPort?: string;
// 指向另一个端口
fromPort?: string;
data?: string;
}
连线的逻辑实现如下:
-
当鼠标按下时,判断是否为一个端口
-
如果是一个端口,设置连线状态 (editingEdge = true)
-
当鼠标移动时,如果当前状态为连线状态,获取当前坐标作为连线的终点坐标,并同时逻辑层绘制一条连线
-
当鼠标释放时,如果不是一个端口,清除连线状态,销毁临时连线
-
当鼠标释放时,如果是一个端口,获取端口信息,与起始端口一起发起端口连接操作
-
端口连接操作,首先需要对端口的顺序进行排序,连线方向必须是从输出端口到输入端口,然后发送事件到逻辑层添加端口的关联信息
2.5.4 连线的绘制
连线是使用贝塞尔曲线绘制的,通常在网页端绘制曲线,可以使用 canvas 或者 svg。
这里选用的是 svg。svg 声明式的风格,结合 svelte 模板更加和谐。
代码如下:
edge.svelte
<script lang="ts">
export let start: IPoint;
export let end: IPoint;
let d = "";
$: {
let midX = start.x + Math.round((end.x - start.x) / 2);
const { x: x0, y: y0 } = start;
const { x: x1, y: y1 } = end;
d = `M${x0} ${y0} C${midX} ${y0} ${midX} ${y1} ${x1} ${y1}`;
}
</script>
<path {d} stroke="white" stroke-width="2" fill="transparent" />
根据 edge 组件的入参起点坐标 start 和终点坐标 end,计算出中点,从而计算出两个贝塞尔控制点,通过这四个坐标可以绘制出一条圆滑的贝塞尔曲线。
2.6 编译器的实现-转换成 AST
2.6.1 为何需要编译
其实我们完成了编辑器的实现后,基本具备了 VL 语言的可视化编辑功能,同时我们也具备了节点需要运行的所有信息,按理说,可以开始编写 VL 的运行时,让它跑起来。
但实际上距离运行时还差一步,我们需要编译过程。
为何还需要编译,主要是因为前面定义的编辑器节点的数据结构,使用在运行时存在一些缺点,会导致运行时不够高效,比如:冗余的信息(坐标,端口),连接信息更适合用于可视化绘制,而不适合用于运行时,每个端口查找下一个节点,需要先查找下一个端口,在查找其所在的节点。
所以需要通过编译,把编辑器节点转换为 AST 节点:
-
去除冗余的信息(坐标,端口)
-
转换端口连接为 AST 节点的连接,赋予语法属性,比如哪些是流程的下一个节点,哪些是输入节点,哪些是分支节点等
2.6.1 编译流程
编译的流程如下:
- 查找 begin 节点,作为第一个编译的节点
- 开始编译 begin 节点,获取 begin 节点指向的下一个节点,开始编译下一个节点,并把编译后的 AST 节点关联到 begin AST 节点的 nextNode 字段
- 编译一个常规节点时
- 获取其输入节点,先编译成 AST,再放入当前节点 AST 的 nodes 中
- 查找下一个节点,编译后设置到当前 AST 的 nextNode 中
- 设置 sourceId 关联到编辑器节点,方便调试时可视化显示
- 直到编译某个节点时,查找不到下一个节点,编译过程结束
AST 的关联:
开始编译:
编译节点:
2.6.3 if node 的编译
下面以 if node 为例讲解编译的流程
if node 总共有 4 个需要处理的节点,分别是:
-
condition 节点:用于计算条件的值,从而决定接下来的分支是走 true 分支还是 false 分支
-
true 节点:当条件计算为 true 时,需要跳转的流程节点
-
false 节点:当条件计算为 false 时,需要跳转的流程节点
-
next 节点:if 节点包含其 true 逻辑分支或者 false 逻辑分支都执行完之后要继续执行的流程
如何对这些节点进行处理并输出 if AST 节点的流程如下所示:
编译后的 AST 结构如下:
2.7 运行时的实现-让它跑起来
2.7.1 运行机制
我们已经完成编译步骤,并且得到 AST 树。
如果读者熟悉编译原理,下一步应该会是需要遍历 AST 进行中间代码的输出。
但 VL 不这么做,节点编程语言一般不会用在计算密集的任务,所以没必要生成中间代码。
可以直接遍历 AST 执行(早期的 ruby 解释器也是这么实现的)
2.7.2 AST 节点类型
AST 节点可以分为两类:
-
语句节点:用于函数调用,流程控制
-
计算节点:用于值的计算
2.7.3 语句节点的执行
通过编译,我们可以获得一个从 begin 开始构建的 AST 语法树,所以执行的过程,应该是:
-
从 begin 节点开始,先执行当前节点的逻辑
-
完成后,通过 nextNode 跳转到下一个节点,继续执行
-
直到 nextNode 为空时,程序结束。
代码如下:
async function run(ctx: Record<string, any>, ast: IAstNode) {
let cur: IAstNode | undefined = ast;
while (cur) {
switch (cur.command) {
case Command.SetValue:
// process set value
break;
case Command.IfNode:
// process if node
break;
case Command.WhileNode:
// process while node
break;
...
}
cur = cur.nextNode;
}
}
2.7.4 计算节点的执行
计算节点通常是被动执行,而不是流程中的一个环节。
计算节点有四则运算,数值比较等。
为何它是被动执行的,举个例子,当 if 节点要知道 codition 的值是多少时,这时候就需要拿到 condition 指向的计算节点,并对计算节点进行求值,再用这个值来决定接下来的流程。
计算节点的运行代码如下:
function evalNode(ctx: Record<string, any>, ast: IAstNode): Promise<string | boolean | number | undefined> {
switch (ast.command) {
case Command.GetValue:
{
const varName = ast.data!;
return ctx[varName];
}
case Command.Constant:
return ast.data
case Command.Add:
{
const [n1, n2] = ast.nodes;
const v1 = evalNode(ctx, n1!);
const v2 = evalNode(ctx, n2!);
return (Number(v1) + Number(v2))
}
case Command.Sub:
...
case Command.Mul:
...
case Command.Div:
...
case Command.Mod:
...
case Command.GT:
...
case Command.GE:
...
case Command.LT:
...
case Command.LE:
...
case Command.Equal:
...
case Command.NotEqual:
...
}
}
add 节点取值过程:
3 写点 Demo 试试
让我们来编写一些现实中的程序试试 VL 可视化编程语言的能力吧。
3.1 Hello, World
不可避免的 Hello,World
运行结果:
成功打印出“Hello, Wrold"
3.2 计算阶乘
待补充
3.3 旋转的方块
待补充
4 总结
至此,我们完成了一个简单的可视化节点编程语言,它具备了节点的可视化编程,编译,运行等基本功能,但距离一个完整的可视化节点编程语言,还缺少了可视化调试,函数定义,FFI,面向对象等功能,只有添加这些功能,才能让其更具实用性。这些功能将留在《从0到1教你用 svelte 和 typescript 实现低代码可视化节点编程语言(二)》里为大家奉上。
未完待续。。。
快过年了,正好有些摸鱼的时间把本系列第二篇文章的内容和其他陈年的技术文章整理一下(摸鱼ing