基于Vue和Canvas,轻舟低代码Web端可视化编辑器设计解析|低代码技术内幕
自2020年来,网易数帆探索可视化低代码编程已两年有余,打造了轻舟低代码平台用于企业应用开发。然而,不少编程技术人员对这一领域还比较陌生。我们开设《低代码技术内幕》专栏,旨在讨论低代码编程领域中的困难、问题,以及高效的解决方案。本文为第二篇,结合我们的产品研发经验解读打造web端可视化代码编辑器需要权衡的因素以及技术实现的要点。
专栏第一篇:低代码编程及其市场机遇剖析|低代码技术内幕
轻舟低代码平台是一款基于云服务的web端产品,面向零基础或者有一定编程基础的用户。用户不需要额外安装软件,就可以在任何有浏览器的电脑上编写和发布应用。可视化代码编辑器是轻舟低代码平台的重要组成部分,用户通过可视化界面开发应用。然而,在web端构建一个拥有良好体验的可视化编程工具是一个很大的挑战:良好的体验要求良好的视觉效果、交互、性能;而视觉效果越花哨,交互越复杂,性能也就越低,过低的性能(卡顿)会影响体验,但如果单纯为了性能,减配视觉效果和交互,又会显得简陋,所以可视化代码编辑器需要掌握好这个平衡。
为了完成这个挑战,我们在canvas上模拟实现了浏览器的事件、容器、布局等功能。另外,为了兼顾团队本身的技术栈(Vue)和项目的可维护性,我们最终使用了与Vue框架结合,通过Vue模板来控制canvas渲染的方案。
下面我们从渲染、交互、数据与视图三个方面来介绍。其中渲染部分主要考虑了性能问题,交互部分介绍了如何模拟浏览器的事件机制,数据与视图部分说明了如何与支持双向绑定特性的Vue框架结合。
高性能渲染
低代码可视化编辑器保留了控制流的设计,所以在整体结构上类似于传统的流程图。但其与流程图有两个明显的区别:
1. 流程图的节点相对简单且布局自由,而轻舟低代码的可视化代码编辑器的节点多且复杂(超一个量级)且布局严格。这种复杂性来自于编程语言本身:我们的低代码编辑器的结点相当于编程语言中的语句(statement),如循环语句for、条件语句if等,这些语句内部又包含很多条表达式(expression),如算数运算、函数调用等;表达式可以嵌套组合,因此每个语句结点可以异常复杂。
2. 流程图的交互操作简单,而可视化代码编辑器的交互复杂。我们提供给用户的交互粒度是表达式级别的,它包括了鼠标悬停、点击、拖拽和键盘输入等,我们需要处理好语句中嵌套的、表达式中嵌套的每一个表达式,这些交互的复杂性都是流程图所不具有的。
为了处理好这些问题,我们调研了主流的渲染方式。目前浏览器提供的主流渲染方式有以下三种:
1. HTML+SVG+CSS 这是一种成熟的渲染方式,它提供的API中包含事件以及对于内部绘制对象操作的方法。但这种渲染方式有很多历史包袱,要保持不同浏览器的渲染一致性比较困难。其次由于它要使用DOM来操作节点,会比下面提到的2D canvas要慢。并且这种方式只适合于布局相对稳定的整体交互,因为布局变化会触发DOM重排,而频繁的DOM重排会成为性能瓶颈。另外这种方式在高分辨率的屏幕上有时无法做到抗锯齿,渲染效果无法保证。
2. 2D Canvas 2D canvas是一种高性能的相对高度抽象的立即绘制模式的API,其渲染依赖于一个Frame内执行的指令。但是API中并不包含事件以及对于内部绘制对象操作的方法,需要额外设计框架实现。
3. 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的框架结合来解决这个问题。但是在结合之前,我们还需要补齐一些能力。
1. 数据对象与渲染节点:在浏览器内部,布局是由CSS来控制的,而在JFlow内部,布局由布局算法来确定,布局算法建立了数据和布局之间的关系。在编辑器场景下,顶层的布局需要根据AST来绘制出基于控制流的固定布局,为了控制AST上节点的位置,需要根据具体AST上的节点来查找到具体的渲染节点。JFlow通过引入WeakMap来解决这个问题:在节点被添加时,建立从AST节点到具体渲染节点的映射关系。
2. 数据改变与渲染变更:MVVM框架的模板会响应数据的变化,数据变化会引起视图上的变化。JFlow通过vue plugin提供了响应数据变化渲染的能力,内部通过调用scheduleRender方法,在浏览器每次重绘之前触发一次性的渲染。
现在我们可以引入MVVM框架了:
数据流向
虚线左侧是AST的使用部分,右侧是框架控制的部分,原始数据决定了布局算法,布局算法控制渲染节点的位置,原始数据节点经过布局节点对应的模板,来控制渲染节点,框架再通过事件通知原始数据或模板更新,进而更新渲染节点。
上图更具体说明了整体数据流向,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在低代码平台中的效果
我们用同一个JFlow框架,搭建了轻舟低代码平台的可视化编程引擎、流程图引擎、实体-联系图(ER图)引擎,下面我们通过一组截图,分别展示其效果。
可视化编程:
流程图:
实体-关系图:
结论
我们在框架设计实现中一直在寻求用户体验、可拓展性、性能、学习梯度、团队合作之间的平衡。这些框架设计可能跟具体特性不太一样,只有在缺失的时候才会被注意到,但是这些设计为我们的团队和产品带来了深远的影响和改变。
作为框架的设计和实现者,我们很开心轻舟低代码可视化编程能够为零基础或者有一定基础的开发者,带来良好的编程体验。未来,我们会继续探索可视化编程的可能性。
作者简介
网易数帆编程语言实验室,负责轻舟低代码平台核心编程能力的设计,包括类型系统、语义语法、声明式编程、可视化交互等NASL的语言设计,Language Server、可视化引擎等,以及后续演进方案的规划和预研,旨在创造低门槛高上限的低代码开发体验。