基于 Vue 和 Canvas,轻舟低代码 Web 端可视化编辑器设计解析 | 低代码技术内幕
专栏内容回顾:低代码编程及其市场机遇剖析 | 低代码技术内幕
轻舟低代码平台是一款基于云服务的 web 端产品,面向零基础或者有一定编程基础的用户。用户不需要额外安装软件,就可以在任何有浏览器的电脑上编写和发布应用。可视化代码编辑器 是轻舟低代码平台的重要组成部分,用户通过可视化界面开发应用。然而,在 web 端构建一个拥有良好体验的可视化编程工具是一个很大的挑战:良好的体验要求良好的视觉效果、交互、性能;而视觉效果越花哨,交互越复杂,性能也就越低,过低的性能(卡顿)会影响体验,但如果单纯为了性能,减配视觉效果和交互,又会显得简陋,所以可视化代码编辑器需要掌握好这个平衡。
为了完成这个挑战,我们在 canvas 上模拟实现了浏览器的事件、容器、布局等功能。另外,为了兼顾团队本身的技术栈(Vue)和项目的可维护性,我们最终使用了与 Vue 框架结合,通过 Vue 模板来控制 canvas 渲染的方案。
下面我们从渲染、交互、数据与视图三个方面来介绍。其中渲染部分主要考虑了性能问题,交互部分介绍了如何模拟浏览器的事件机制,数据与视图部分说明了如何与支持双向绑定特性的 Vue 框架结合。
低代码可视化编辑器保留了控制流的设计,所以在整体结构上类似于传统的 流程图。但其与流程图有两个明显的区别:
流程图的节点相对简单且布局自由,而轻舟低代码的可视化代码编辑器的 节点多且复杂(超一个量级)且布局严格。这种复杂性来自于编程语言本身:我们的低代码编辑器的结点相当于编程语言中的语句(statement),如循环语句
for
、条件语句if
等,这些语句内部又包含很多条表达式(expression),如算数运算、函数调用等;表达式可以嵌套组合,因此每个语句结点可以异常复杂。流程图的交互操作简单,而可视化代码编辑器的 交互复杂。我们提供给用户的 交互粒度 是表达式级别的,它包括了鼠标悬停、点击、拖拽和键盘输入等,我们需要处理好语句中嵌套的、表达式中嵌套的每一个表达式,这些交互的复杂性都是流程图所不具有的。
为了处理好这些问题,我们调研了主流的渲染方式。目前浏览器提供的主流渲染方式有以下三种:
HTML + SVG + CSS 这是一种成熟的渲染方式,它提供的 API 中包含事件以及对于内部绘制对象操作的方法。但这种渲染方式有很多历史包袱,要保持不同浏览器的渲染一致性比较困难。其次由于它要使用 DOM 来操作节点,会比下面提到的 2D canvas 要慢。并且这种方式只 适合于布局相对稳定的整体交互,因为布局变化会触发 DOM 重排,而频繁的 DOM 重排会成为性能瓶颈。另外这种方式在高分辨率的屏幕上有时无法做到抗锯齿,渲染效果无法保证。
HTML + SVG + CSS 渲染
2D Canvas 2D canvas 是一种 高性能 的相对高度抽象的立即绘制模式的 API,其渲染依赖于一个 Frame 内执行的指令。但是 API 中并不包含事件以及对于内部绘制对象操作的方法,需要额外设计框架实现。
2D Canvas 渲染
WebGL WebGL 是一种更为底层的渲染技术,它通过基于 OpenGL ES 的 API 控制着色器渲染 2D 或 3D 场景,性能比 2D canvas 更强,但是技术门槛较高。它提供的 API 中也不包含事件以及对于内部绘制对象操作的方法,需要额外引入框架来实现。
在考虑到团队技术栈和开发周期的同时,为追求更好的用户体验,我们选择了使用 2D canvas API,并 实现了一个自研的框架 JFlow。JFlow 框架通过模拟浏览器的事件系统以及布局系统,可以无缝嵌入到我们的 Vue 工程下,让我们的前端工程师能够快速迭代业务需求。
为了支持表达式级别的细粒度交互,我们在 JFlow 框架中实现了一整套可交互的基础设施,我们的交互设计是基于浏览器内部基本的事件,没有超出浏览器给出的事件范围。HTML 中 <canvas>
的事件都是从顶层触发的,如果要在画布内部,针对每个绘图单元来实现交互,得从 <canvas>
上的原始事件出发,通过构建画布内部的多级坐标系统,定位到具体的绘图单元(捕获),结合浏览器事件本身和定位到的绘图单元层级,再模拟事件抛出(冒泡)的过程,在画布内模拟出浏览器的原生事件系统,通过结合前端事件基础知识,从而实现业务中的交互设计。下面我们从定位、状态、事件三点来介绍:
在什么定位下、在什么状态下、在什么浏览器事件下才能触发什么交互?交互是单一确定的,还是个像事件列表那样有优先级?会冒泡到父对象的交互中处理吗?
定位 定位的基础在于坐标系统,canvas 的坐标系 A (x', y') 是固定的,而 canvas 内部对象的坐标系 B (x, y) 也是相对固定的,坐标系 A 与 B 之间存在以下关系:
节点的位置由具体的节点布局算法来确定,算法基于内部对象坐标系 B 来计算。
坐标系 B 上的节点及内部节点均设计为中心对称,即节点在其父级坐标系的坐标为图形中心,这种设计方便设置行列对齐。节点内部的子坐标系以图形中心 (cx, cy) 为原点,若子坐标为 (x, y),父坐标为 (x', y'),则内部的父子坐标系存在如下关系:
节点内部绘图单元的位置,由节点上具体布局算法来确定,算法基于父级坐标系来计算。
状态 判断状态的最常见的方法是碰撞检测,鼠标交互的实现仅需要判断交互点与具体几何图形的关系,即接触到图形或未接触到图形。通过捕捉这个状态的变化,来判断当前正在交互的对象。计算是判断的意思?
事件 Canvas 内部本身不存在事件,要模拟内部事件,需要从顶层分析 canvas 的原始事件,模拟浏览器事件捕获和冒泡的过程来实现。在有了定位的坐标系和逐层的状态判断之后,就能够从顶层的坐标计算出当前交互的对象,并向上抛出事件。
通过构建这些基础设施,我们模拟了事件机制并获得了对内部绘制对象操作的方法,但是 JFlow 作为语言的基础渲染框架,还需要增强对抽象语法树(abstract syntax trees,AST)的互操作能力,见下一小节。
我们上面的设计相当于在 canvas 上加了 DOM 的部分能力,使其能够更方便地接收事件和操作内部绘图对象。但是直接操作 DOM 的缺点在于视图操作和业务逻辑会耦合在了一起,在我们的场景下就是在操作 AST 的同时,还要同时操作渲染对象。为了解耦,以及减少业务代码出错造成的渲染问题,我们需要引入 MVC 或者 MVVM 之类的框架。
我们选择了与现有的一些 MVVM 的框架结合来解决这个问题。但是在结合之前,我们还需要补齐一些能力。
数据对象与渲染节点:在浏览器内部,布局是由 CSS 来控制的,而在 JFlow 内部,布局由布局算法来确定,布局算法建立了数据和布局之间的关系。在编辑器场景下,顶层的布局需要根据 AST 来绘制出基于控制流的固定布局,为了控制 AST 上节点的位置,需要根据具体 AST 上的节点来查找到具体的渲染节点。JFlow 通过引入 WeakMap 来解决这个问题:在节点被添加时,建立从 AST 节点到具体渲染节点的映射关系。
数据改变与渲染变更:MVVM 框架的模板会响应数据的变化,数据变化会引起视图上的变化。JFlow 通过
vue plugin
提供了响应数据变化渲染的能力,内部通过调用scheduleRender
方法,在浏览器每次重绘之前触发一次性的渲染。
现在我们可以引入 MVVM 框架了:
数据流向
虚线左侧是 AST 的使用部分,右侧是框架控制的部分,原始数据决定了布局算法,布局算法控制渲染节点的位置,原始数据节点经过布局节点对应的模板,来控制渲染节点,框架再通过事件通知原始数据或模板更新,进而更新渲染节点。
vue数据流向
上图更具体说明了整体数据流向,Vue 起到了 MVVM 的作用。原始数据更新布局,相当于浏览器里面的重排。
假设在某种语言里有
class A extends B mixin C, D {}
class B implements E {}
class C {}
class D {}
interface E {}
这样的代码,我们将用 jFlow 来实现如下的展示效果:
代码展示
首先,我们构建描述 A、B、C、D、E 这几个类型之间的关系的 JSON:
[
{
"name": "A" ,
"extends": "B",
"mixins": ["C", "D"]
},
{
"name": "B" ,
"implements": "E"
},
{
"name": "C"
},
{
"name": "D"
},
{
"name": "E"
}
]
然后,我们根据视图和数据的关系来构建总布局。
总布局由布局节点和布局组成:
// DemoLayout.js
class VirtualNode {
constructor(source) {
this.type = 'VirtualNode';
this.source = source;
}
makeLink(callback) {
const {
extends: ext, mixins, implements: impl
} = this.source;
if(ext) {
callback({
from: ext,
to: this.source.name,
part: 'extends',
})
}
if(mixins) {
mixins.forEach(t => {
callback({
from: t,
to: this.source.name,
part: 'mixins',
fontSize: '24px',
lineDash: [5, 2]
})
})
}
if(impl) {
callback({
from: impl,
to: this.source.name,
part: 'implements',
})
}
}
}
class DemoLayout {
constructor(source) {
this.static = false;
// 布局实例必须包含节点 (flowStack) 和边界 (flowLinkStack)
this.flowStack = [];
this.flowLinkStack = [];
const nodeMap = {};
const nodes = source.map(s => {
const node = new VirtualNode(s);
nodeMap[s.name] = node
return node;
});
nodes.forEach(node => {
this.flowStack.push({
type: node.type,
source: node.source,
layoutNode: node,
})
node.makeLink((configs) => {
const fromNode = nodeMap[configs.from];
const toNode = nodeMap[configs.to];
if(!fromNode) return;
if(!toNode) return;
this.flowLinkStack.push({
...configs,
from: fromNode,
to: toNode
})
})
});
this.erNodes = nodes;
}
reflow(jflow) {
const nodes = this.erNodes;
nodes.forEach((node, idx) => {
// 计算 节点位置
const renderNode = jflow.getRenderNodeBySource(node.source)
renderNode.anchor = [-idx * 220, (idx % 2) * 80];
});
}
}
export default DemoLayout;
接着,在全局引入 vue
插件
import Vue from 'vue'
import { JFlowVuePlugin } from '@joskii/jflow';
import App from './App.vue'
Vue.config.productionTip = false
Vue.use(JFlowVuePlugin);
new Vue({
render: h => h(App),
}).$mount('#app')
其中导入的 App.vue
代码如下:
<template>
<j-jflow
style="width: 600px; height: 300px; border: 1px solid #000"
:genVueComponentKey="genVueComponentKey"
:configs="configs">
<template #VirtualNode="{ source }">
<virtual-node :node="source" ></virtual-node>
</template>
<template #plainlink="{ configs }">
<jBezierLink
:configs="{
...configs,
content: configs.part,
backgroundColor: '#EB6864',
fontSize: '24px'
}"
:from="configs.from.source"
:to="configs.to.source">
</jBezierLink>
</template>
</j-jflow>
</template>
<script>
import { commonEventAdapter } from '@joskii/jflow';
import DemoLayout from './demo-layout';
import VirtualNode from './virtual-node.vue';
import source from './data.json'
const layout = new DemoLayout(source);
export default {
components: {
VirtualNode,
},
data() {
return {
configs: {
allowDrop: false,
layout,
eventAdapter: commonEventAdapter
}
}
},
methods: {
genVueComponentKey(source){
return source.name;
}
}
};
</script>
其中引入的 virtual-node.vue
代码如下:
<template>
<j-group
:source="node"
@click="onClick"
:configs="configs">
<j-text :configs="{
textColor: this.coin ? '#60CFC4' : '#EB6864',
content: node.name,
}">
</j-text>
</j-group>
</template>
<script>
import { LinearLayout } from '@joskii/jflow';
const layout = new LinearLayout({
direction: 'vertical',
gap: 0,
});
export default {
props: {
node: Object,
},
data() {
return {
configs: {
layout,
borderRadius: 8,
borderColor: '#EB6864',
borderWidth: 2,
padding: 20,
},
coin: false,
}
},
methods: {
onClick() {
this.coin = !this.coin;
}
}
}
</script>
运行这些代码,即可得到本小节开头的效果展示图。
我们用同一个 JFlow 框架,搭建了轻舟低代码平台的可视化编程引擎、流程图引擎、实体 - 联系图(ER 图)引擎,下面我们通过一组截图,分别展示其效果。
可视化编程:
逻辑
流程图:
流程
实体 - 关系图:
实体关系图
我们在框架设计实现中一直在寻求用户体验、可拓展性、性能、学习梯度、团队合作之间的平衡。这些框架设计可能跟具体特性不太一样,只有在缺失的时候才会被注意到,但是这些设计为我们的团队和产品带来了深远的影响和改变。
作为框架的设计和实现者,我们很开心轻舟低代码可视化编程能够为零基础或者有一定基础的开发者,带来良好的编程体验。未来,我们会继续探索可视化编程的可能性。
网易数帆编程语言实验室负责轻舟低代码平台核心编程能力的设计,包括类型系统、语义语法、声明式编程、可视化交互等 NASL 的语言设计,Language Server、可视化引擎等,以及后续演进方案的规划和预研,旨在创造低门槛高上限的低代码开发体验。
警方通报网传中电科加班事件调查结果;拼多多解散恶意功能团队;逼死程序员诈骗千万的“翟欣欣案”一审宣判 | Q资讯
微信扫码关注该文公众号作者