写点什么

基于 Vue 和 Canvas,轻舟低代码 Web 端可视化编辑器设计解析 | 低代码技术内幕

  • 2023-04-10
    北京
  • 本文字数:6351 字

    阅读完需:约 21 分钟

基于 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 重排会成为性能瓶颈。另外这种方式在高分辨率的屏幕上有时无法做到抗锯齿,渲染效果无法保证。


  1. 2D Canvas 2D canvas 是一种高性能的相对高度抽象的立即绘制模式的 API,其渲染依赖于一个 Frame 内执行的指令。但是 API 中并不包含事件以及对于内部绘制对象操作的方法,需要额外设计框架实现。

  1. WebGL WebGL 是一种更为底层的渲染技术,它通过基于 OpenGL ES 的 API 控制着色器渲染 2D 或 3D 场景,性能比 2D canvas 更强,但是技术门槛较高。它提供的 API 中也不包含事件以及对于内部绘制对象操作的方法,需要额外引入框架来实现。


在考虑到团队技术栈和开发周期的同时,为追求更好的用户体验,我们选择了使用 2D canvas API,并实现了一个自研的框架 JFlow。JFlow 框架通过模拟浏览器的事件系统以及布局系统,可以无缝嵌入到我们的 Vue 工程下,让我们的前端工程师能够快速迭代业务需求。

细粒度交互


为了支持表达式级别的细粒度交互,我们在 JFlow 框架中实现了一整套可交互的基础设施,我们的交互设计是基于浏览器内部基本的事件,没有超出浏览器给出的事件范围。HTML 中 <canvas> 的事件都是从顶层触发的,如果要在画布内部,针对每个绘图单元来实现交互,得从 <canvas> 上的原始事件出发,通过构建画布内部的多级坐标系统,定位到具体的绘图单元(捕获),结合浏览器事件本身和定位到的绘图单元层级,再模拟事件抛出(冒泡)的过程,在画布内模拟出浏览器的原生事件系统,通过结合前端事件基础知识,从而实现业务中的交互设计。下面我们从定位、状态、事件三点来介绍:


在什么定位下、在什么状态下、在什么浏览器事件下才能触发什么交互?交互是单一确定的,还是个像事件列表那样有优先级?会冒泡到父对象的交互中处理吗?


定位 定位的基础在于坐标系统,canvas 的坐标系 是固定的,而 canvas 内部对象的坐标系 也是相对固定的,坐标系 之间存在以下关系:


节点的位置由具体的节点布局算法来确定,算法基于内部对象坐标系来计算。


坐标系 上的节点及内部节点均设计为中心对称,即节点在其父级坐标系的坐标为图形中心,这种设计方便设置行列对齐。节点内部的子坐标系以图形中心 为原点,若子坐标为 ,父坐标为 ,则内部的父子坐标系存在如下关系:



节点内部绘图单元的位置,由节点上具体布局算法来确定,算法基于父级坐标系来计算。


状态 判断状态的最常见的方法是碰撞检测,鼠标交互的实现仅需要判断交互点与具体几何图形的关系,即接触到图形或未接触到图形。通过捕捉这个状态的变化,来判断当前正在交互的对象。计算是判断的意思?


事件 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.jsclass 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 = falseVue.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、可视化引擎等,以及后续演进方案的规划和预研,旨在创造低门槛高上限的低代码开发体验。

2023-04-10 15:008094
用户头像
蔡芳芳 InfoQ 总编辑

发布了 819 篇内容, 共 621.0 次阅读, 收获喜欢 2823 次。

关注

评论 1 条评论

发布
用户头像
JFlow计划开源吗?
2023-05-12 15:13 · 北京
回复
没有更多了
发现更多内容

架构实战营模块五作业

渐行渐远

架构实战营

6000字 | 深入理解 Ribbon 的架构原理

悟空聊架构

悟空聊架构

四步轻松玩转微服务敏捷开发

阿里巴巴中间件

阿里云 微服务 云原生 敏捷开发 中间件

架构训练营第3期模块5作业

吴霏

架构训练营

新晋 CNCF 沙箱项目 OpenClusterManagement 带来了它的最新特性

阿里巴巴中间件

阿里云 中间件 KubeVela cncf OCM

iOS Pod Update 指数级变慢?看 Flutter 新一代仲裁算法 Pubgrub 如何解

阿里巴巴终端技术

flutter ios 算法 仲裁

模块5-微博评论高性能高可用计算架构分析

小何

「架构实战营」

32 K8S之DaemonSet/Job/CronJob控制器

穿过生命散发芬芳

k8s 28天写作 12月日更

资产数字化的当下,数据隐私危如累卵

CECBC

040022-week5-design

InfoQ_70156470130f

博文推荐|多图科普 Apache Pulsar

Apache Pulsar

开源 架构 分布式 云原生 Apache Pulsar

后端程序员福利套餐,22份资料合集,你能想到的关键技术,都在这里

奔着腾讯去

c++ golang Linux 音视频 学习资料

11 张图 | 讲透原理,最细的 Eureka 增量拉取

悟空聊架构

悟空聊架构

解密 Dubbo 三大中心的部署架构

阿里巴巴中间件

阿里云 微服务 云原生 dubbo 中间件

未来,区块链将在这些领域广泛应用!

CECBC

架构实战训练营|课后作业 模块 5

Geek_6bb688

8 张图 | 剖析 Eureka 的首次同步注册表

悟空聊架构

一场关于元宇宙公司之死的剧本杀

脑极体

快速云原生化,从数据中心到云原生的迁移最佳实践

阿里巴巴云原生

阿里云 云原生 实践 迁云方案

RocketMQ这样做,压测后性能提高30%

中间件兴趣圈

RocketMQ 性能 Apache RocketMQ

说出你和「云原生」的故事,获得年度云原生顶级盛会通行证

阿里巴巴云原生

阿里云 开源 云原生 投稿

如何在信息不完备下进行快速决策?

石云升

决策 28天写作 职场经验 12月日更

模块一作业

Geek_e6f7f6

架构实战营

Android C++系列:Linux进程间关系

轻口味

c++ android 28天写作 12月日更

超基础的机器学习入门-原理篇

凹凸实验室

机器学习 AI 低代码平台

中年人的沉重 2

张老蔫

28天写作

车用能源的终极:氢能车落地普及还要多久?

脑极体

硬核图解 SpringCloud 源码系列

悟空聊架构

SpringCloud 悟空聊架构 内容合集 签约计划第二季 技术专题合集

SpringCloudAlibaba微服务技术栈精讲大合集

XiaoLin_Java

内容合集 签约计划第二季 技术专题合集

Google 宣布将 Knative 捐赠给 CNCF

QiLab

Google Knative cncf

AI:人工智能 or 异类智能(Alien Intelligence)

mtfelix

28天写作

基于 Vue 和 Canvas,轻舟低代码 Web 端可视化编辑器设计解析 | 低代码技术内幕_大前端_网易数帆编程语言实验室_InfoQ精选文章