写点什么

基于 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:008163
用户头像
蔡芳芳 InfoQ 总编辑

发布了 851 篇内容, 共 627.5 次阅读, 收获喜欢 2826 次。

关注

评论 1 条评论

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

那些关于DIP器件不得不说的坑

华秋PCB

插件 DIP 元器件 PCB PCB设计

一个解决tcp粘包问题的c++代码

linux大本营

TCP 网络协议 C++ TCP 粘包

Springboot之如何纯文本转成.csv格式文件?|超级详细,建议收藏

bug菌

Spring Boot 2 spring-boot 三周年连更

c++实现一个tcp高性能网络服务器

linux大本营

TCP 多线程 异步IO epoll 高性能服务器

5.10版本的linux内核pgtable_init函数解析

linux大本营

Linux内核

和面试官聊1小时Java并发,多亏GitHub上这份笔记

Java 并发编程

第二届广州·琶洲算法大赛启动,百度飞桨助力广州打造中国算法新高地

飞桨PaddlePaddle

算法 百度飞桨 文心大模型

在毫秒量级上做到“更快”!DataTester助力飞书提升页面秒开率

字节跳动数据平台

大数据 AB testing实战 用户体验 企业号 4 月 PK 榜 秒开率

机器学习分布式框架Ray

AIWeker

Python 分布式 python小知识 三周年连更

华为开发者大赛中国区正式启动 携手探索ICT无限可能

极客天地

sqlserver锁表产生的原因

linux大本营

数据库· SQL sever 表锁

sougou的workflow的10个技术点

linux大本营

workflow 异步框架 C++

Apifox WebSocket 调试功能你会用了吗?

Apifox

程序员 接口 websocket API API 调试

【微信小程序管理】第三方软件的优势有哪些

没有用户名丶

awk常量和标识符

linux大本营

脚本 awk

Spring Boot 整合 Redis 基于 Stream 消息队列 实现异步秒杀下单

使用了Spring的事件机制真香!

Java spring

Gradio入门到进阶全网最详细教程[二]:快速搭建AI算法可视化部署演示(侧重参数详解和案例实践)

汀丶人工智能

人工智能 机器学习 深度学习 Gradio AI可视化

强强联手:机器学习与运筹学

鼎道智联

算法

linuxc获取文件内容

linux大本营

Linux

ChatGPT 会在三年内终结编程吗?| 社区征文

神木鼎

三周年征文

麻了,不要再动不动就BeanUtil.copyProperties!

Linux常用命令

追赶者

进程 SSH Liunx 端口占用

大连理工大学OpenHarmony技术俱乐部正式揭牌成立

极客天地

用c++写一段快速排序算法

linux大本营

排序算法 数据结构与算法 C++

从源码全面解析LinkedBlockingQueue的来龙去脉

阿里新一代微服务,内部大佬手抄的笔记+脑图不容错过,全是精华

Java 架构 微服务 Spring Cloud Aliababa

ByteHouse云数仓版查询性能优化和MySQL生态完善

NineData

数据库 架构 字节跳动 Clickhouse bytehouse

蚂蚁安全科技 Nydus 镜像加速实践

SOFAStack

开源 镜像 镜像安全 OCI Nydus

Apache Flink ML 2.2.0 发布公告

阿里云大数据AI技术

大数据 算法 企业号 4 月 PK 榜

eBPF的发展演进---从石器时代到成为神(二)

统信软件

Linux Kenel 内核 Linux内核

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