We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
最近,我们的项目 imove 在github上的star数增长较快,想必是有值得大家肯定的地方的。设计这款工具的初衷是为了提高开发者的开发效率,通过面向业务逻辑的可视化编排提高逻辑元件(如ui、api、function)的复用能力。我们在双11业务中投入使用imove进行开发,不仅提高了开发的速度,还积累了许多的逻辑元件,如下图所示:
大家可能会很好奇,我们按照一直习惯的前端开发模式写代码不好吗?根据产品给的需求文档,根据UI划分一个个的组件,再一起把UI和逻辑实现了不是大功告成吗?为什么要额外引入流程图的绘制,会不会增加工作量?我们先来看看imove是如何进行开发的,再接下来讲讲我们为什么要打破以往的开发模式。
1. 绘制完流程图 根据你的业务逻辑绘制好流程图,这里的节点包括开始节点(圆形)、分支节点(菱形)和行为节点(矩形)。如下图所示,可以绘制多条执行链,每条执行链都从一个开始节点出发。
2. 完成每个节点的函数编写 依次双击节点,打开代码编辑框,为每个节点编写代码。
前端js代码实现包含了同步逻辑和异步逻辑,大多数可以使用同步逻辑实现,但是涉及到接口请求、定时任务等场景时就需要使用到异步逻辑。因此在imove中,我们支持书写同步代码和异步代码,并在编译中考虑了这两种情况。具体写法如下所示:
// 同步代码 export default function(ctx) { const data = ctx.getPipe(); return doSomething(data); } // 异步代码 // 写法1 使用promise export default function(ctx) { return new Promise(resolve => { setTimeout(() => resolve(), 2000); }); } // 写法2 使用async await export default async function(ctx) { const data = await fetchData(); return data; }
3. 在项目中使用 以上步骤只涉及到绘制流程图、编写节点代码,但是我们需要把这些js逻辑代码加入到自己的项目中,才能实现一个完整的项目。为了方便把这些编写的js逻辑加入到项目中,我们可以选择以下两种方式引入:1)将imove编写的代码在线打包,将打包文件引入到项目中;2)直接在本地启动开发模式 imove -d,可以结合项目进行实时调试,边在imove修改边看项目效果。以下介绍两种引入方式的具体步骤:
(1)本地打包出码 点击页面右上方的"导出"按钮后,可以在弹窗内选择“导出代码”,此时流程图编译后的代码将以zip包的形式下载到本地,你可以解压后再引入项目中使用。
zip
引入后使用方法如下:
logic.on
logic.invoke
import React, {useEffect} from 'react'; import logic from './logic'; const App = () => { // 引入方法 useEffect(() => { // 事件监听——在节点代码中,通过`ctx.emit('a')`执行a事件,以下是监听此事件的函数 logic.on('a', (data) => { }); // 执行一条流程——触发执行“开始节点”逻辑触发名称为b的那条流程 logic.invoke('b'); }, []); return <div>xxx</div> }; export default App;
(2)本地启动开发模式
$ npm install -g @imove/cli
$ cd yourProject
$ imove --init # 或 imove -i
$ imove --dev # 或 imove -d
此时页面上触发“保存快捷键 Ctrl + S”时,就可以看到当前项目的 src 目录下会多出一个 logic 目录,这就是 imove 编译生成的代码,此时你只要在你的组件中调用它即可。调用的方法仍然如本地打包出码中演示的一致。
src
logic
imove
本地打包出码
了解了基于imove的开发方式,接下来具体讨论一下为什么需要这么做。试想一下,你有没有遇到过以下场景:
以上这些种种问题,其实是前端开发中或多或少会遇到的痛点。其实,我们可以采用逻辑编排的开发方式,把业务中的一个个功能点按照逻辑顺序组织起来,不仅能够及时检查逻辑漏洞,还能沉淀非常多的业务逻辑,达到参考和复用的效果。以下是总结的采用可视化编排方式进行编程的好处:
(1)需求可视化 把需求按照流程图的方式展示出来,可视化的流程对于新人以及非开发同学来说是非常容易理解的,对了解业务有很大的帮助。特别是在判别条件非常多、业务身份非常多、业务流程冗长而复杂的场景中,能梳理好全部的需求逻辑,能更好地检查代码是否考虑了全部的需求场景。这使得交付、交流、接手会更容易。
(2)逻辑复用 因为代码是针对于节点粒度的,在做需求时,可以参考和复用已有的节点代码(如判断登录、关注店铺等等),不仅对新人上手非常友好,也节约了反复编写重复代码的时间,能缩短开发周期。随着逻辑节点的不断沉淀,这种优势也会越发明显。
(3)提高代码规范 在现有的开发方式下,随着需求不断迭代,可能有会有越来越多的业务逻辑被分散在各个模块中,很可能会违反职责单一、高内聚低耦合的编程原则。而通过逻辑编排的方式开发,每次只针对一个节点编写代码,能保证各个能力职责单一,最后通过聚合的方式串联起整个业务逻辑。
现在思考一下,使用到逻辑编排后,上述列举出的种种问题,是不是会迎刃而解呢?
其实逻辑编排不是一种新出现的思想,其实像我们生活中,都有逻辑编排的影子。它只是按照行为逻辑,把一个个单一的业务行为(如限流、限购、加购等等)有序编织为一个完整流程,聚合成一条具有特定业务含义的执行链,每一个完整的流程都会有一个触发点。如下图,就是在业务场景中整理的流程图:
因此,在coding中引入逻辑编排的概念是有价值的,我们也正在尝试着使用新的开发模式去提高生产效率。
在上文中详细展示了为什么需要使用到流程编排,下面详细介绍下流程图绘制的原理。imove底层采用了蚂蚁团队提供的 antv-X6 图编辑引擎 ,从而实现了流程图绘制的能力。为什么要选用antv-X6作为底层绘图引擎呢?因为它基本能覆盖到流程图绘制的全部需求,内置了图编辑场景的常规交互和设计,能帮助我们快速创建画布、节点和边,能够做到开箱即用,使用是非常方便的。它开放了丰富的定制能力给到开发者,只需简单的配置就能实现想要的效果,其不仅仅支持流程图,还支持DAG图、ER图、组织架构图等等。在imove中我们仅仅使用到了流程图绘制的能力,其实X6还有更多的能力值得大家去使用去探索。下面结合imove框架的实现,详细介绍下我们实现流程图绘制的具体思路,如何从0到1实现可用的较为完备的流程图绘制能力。
antv-X6
使用 X6 创建画布非常容易,实例化一个 X6 暴露的 Graph 对象即可。以下是画布的具体配置项,包括了:
具体的代码实现如下,注释了各类能力对应的api文档,大家有兴趣可以试试~
import {Graph} from '@antv/X6'; const flowChart = new Graph({ // 渲染指定 dom 节点 container: document.getElementById('flowChart'), // 节点是否可旋转 rotating: false, // 节点是否可调整大小 resizing: true, // 剪切板,支持跨画布的复制/粘贴(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/clipboard) clipboard: { enabled: true, useLocalStorage: true, }, // 节点连线规则配置(详细文档:https://X6.antv.vision/zh/docs/api/graph/interaction#connecting) connecting: { snap: true, dangling: true, highlight: true, anchor: 'center', connectionPoint: 'anchor', router: { name: 'manhattan' } }, // 画布背景,支持颜色/图片/水印等(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/background) background: { color: '#f8f9fa', }, // 网格配置(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/grid) grid: { visible: true, }, // 点选/框选配置(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/selection) selecting: { enabled: true, multiple: true, rubberband: true, movable: true, strict: true, showNodeSelectionBox: true }, // 对齐线配置,辅助移动节点排版(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/snapline) snapline: { enabled: true, clean: 100, }, // 撤销/重做能力(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/history) history: { enabled: true, }, // 使画布具备滚动、平移、居中、缩放等能力(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/scroller) scroller: { enabled: true, }, // 鼠标滚轮缩放(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/mousewheel) mousewheel: { enabled: true, minScale: MIN_ZOOM, maxScale: MAX_ZOOM, modifiers: ['ctrl', 'meta'], }, });
完成了画布的开发,接下来在主界面中引入即可。这里我们发现还是存在很多问题:
仅仅依赖于以上new Graph配置的信息是远远不够的,还需要一步步完善起来。
new Graph
根据 键盘快捷键 Keyboard 介绍,Graph 实例会暴露一个 bindKey 方法,我们可以用它来绑定快捷键。以使用频率最高的 ctrl + c / ctrl + v 举例,这里定义了一系列的键盘快捷键和handler监听函数,如ctrl + c时获取当前选中的元素,利用copy()方法完成复制操作,ctrl + v时直接使用paste({offset:xx})方法完成粘贴操作。
Graph
bindKey
ctrl + c
ctrl + v
copy()
paste({offset:xx})
首选需要在new Graph的参数中加入keyboard配置项,这里的global代表是否为全局键盘事件,设置为 true 时绑定在 Document 上,否则绑定在画布容器上。在这里我们设置为false,只在画布获得焦点才触发键盘事件。
keyboard
global
const flowChart = new Graph({ // 键盘快捷键能力(详细文档:https://X6.antv.vision/zh/docs/tutorial/basic/keyboard) keyboard: { enabled: true, global: false, } })
接下来要配置支持的快捷键,如复制、粘贴、保存、撤销、缩放、全选……需要指定对应的按键以及监听函数。感兴趣的朋友可以看下shortcuts.ts~
import { Cell, Edge, Graph, Node } from '@antv/X6'; interface Shortcut { keys: string | string[]; handler: (flowChart: Graph) => void; } const shortcuts: { [key: string]: Shortcut } = { // 复制 copy: { keys: 'meta + c', handler(flowChart: Graph) { const cells = flowChart.getSelectedCells(); if (cells.length > 0) { flowChart.copy(cells); message.success('复制成功'); } return false; }, }, // 粘贴 paste: { keys: 'meta + v', handler(flowChart: Graph) { if (!flowChart.isClipboardEmpty()) { const cells = flowChart.paste({ offset: 32 }); flowChart.cleanSelection(); flowChart.select(cells); } return false; }, }, // many funcions can be defined here save: {}, // 保存 undo: {}, // 撤销 redo: {}, // 重做 zoomIn: {}, // 放大 zoomOut: {}, // 缩小 delete: {}, // 删除 selectAll: {}, // 全选 bold: {}, // 加粗 italic: {}, // 斜体 underline: {}, // 下划线 bringToTop: {}, // 置于顶层 bringToBack: {} // 置于底层 }; export default shortcuts;
我们也可以实现快捷键或滚轮放大、缩小的能力,需要自定义每次缩放尺度的改变量,根据当前的缩放尺度去增减这个改变量即可。为了避免用户无限放大或无限缩小带来不好的视觉体验,最好定义一个最大和最小的缩放尺度。
const shortcuts: { [key: string]: Shortcut } = { zoomIn: { keys: 'meta + shift + +', handler(flowChart: Graph) { const nextZoom = (flowChart.zoom() + ZOOM_STEP).toPrecision(2); flowChart.zoomTo(Number(nextZoom), { maxScale: MAX_ZOOM }); return false; }, }, zoomOut: { keys: 'meta + shift + -', handler(flowChart: Graph) { const nextZoom = (flowChart.zoom() - ZOOM_STEP).toPrecision(2); flowChart.zoomTo(Number(nextZoom), { minScale: MIN_ZOOM }); return false; }, }
最后我们把所有的快捷键绑定在Graph上,即可完成全部快捷点的配置。
shortcuts.forEach(shortcur => { const { key, handler } = shortcut; graph.bindKey(key, () => handler(graph)); });
在当前画布上,我们需要监听一系列常用的方法,如双击节点、画布右键、节点右键……在imove中,完成了以下的操作:
以下是具体的实现方法:
const registerEvents = (flowChart: Graph): void => { // 监听节点双击事件,用于打开代码编辑界面 flowChart.on('node:dblclick', () => { }); // 监听画布右键菜单 flowChart.on('blank:contextmenu', (args) => { }); // 监听节点右键菜单 flowChart.on('node:contextmenu', (args) => { }); };
目前已经完成了画布通用配置、快捷键设置、事件绑定,接下来实现一个createFlowChart的工厂函数。在工厂函数中,我们创建了画布实例,并为其注册绑定的事件、注册快捷键、注册服务端存储。
// 注册快捷键 const registerShortcuts = (flowChart: Graph): void => { Object.values(shortcuts).forEach((shortcut) => { const { keys, handler } = shortcut; flowChart.bindKey(keys, () => handler(flowChart)); }); }; const createFlowChart = (container: HTMLDivElement, miniMapContainer: HTMLDivElement): Graph => { const flowChart = new Graph({ // many configuration }) registerEvents(flowChart); // 注册绑定事件 registerShortcuts(flowChart); // 注册快捷键 registerServerStorage(flowChart); // 注册服务端存储 return flowChart; }; export default createFlowChart;
这样,画布的功能越来越完善了,已经支持绘制流程图了。
在imove中,需要支持绘制的流程图以DSL、代码、流程图的导出。我们实现的相关函数如下:
// 导出DSL const onExportDSL = () => { const dsl = JSON.stringify(flowChart.toJSON(), null, 2); const blob = new Blob([dsl], { type: 'text/plain' }); DataUri.downloadBlob(blob, 'imove.dsl.json'); }; // 导出代码 const onExportCode = () => { const zip = new JSZip(); const dsl = flowChart.toJSON(); const output = compileForProject(dsl); Helper.recursiveZip(zip, output); zip.generateAsync({ type: 'blob' }).then((blob) => { DataUri.downloadBlob(blob, 'logic.zip'); }); }; // 导出流程图 const onExportFlowChart = () => { flowChart.toPNG((dataUri: string) => { DataUri.downloadDataUri(dataUri, 'flowChart.png'); }, { padding: 50, ratio: '3.0' }); };
在imove中,我们设计了以下三种节点类型:事件节点(开始节点)、行为节点、分支节点。节点负责处理具体的逻辑流程,以下是三种节点的具体描述:
根据上述的规范描述,我们可以绘制出各种各样的逻辑流程图,例如 进入首页 的流程图如下所示:
imove的流程图节点需要承载了多种属性,如节点文字、代码、投放配置模型、投放配置数据、依赖包等等,但是在X6中,节点的基本属性为id、shape、position、size,主要包括与X6图形展示相关的基本数据,简称为图形化数据。因此我们需要扩展节点属性,这些扩展的属性简称为自定义数据。
图形化数据(主要包括与X6图形展示相关的基本数据)
自定义数据(扩展的属性)
定义完这些节点属性后,就可以实现节点信息的存储,不至于丢失信息。通常来说,信息配置使用频率最高的有:
这里编辑的每一条信息,都会保存在节点的属性里。
单纯有画布是不够的,我们还需支持在画布上添加节点:
可以使用 addNode 和 addEdge 方法来动态添加节点和边:
// 添加起点 const source = graph.addNode({ id: 'node1', x: 40, y: 40, width: 80, height: 40, label: 'Hello', }); // 添加终点 const target = graph.addNode({ id: 'node2', x: 160, y: 180, width: 80, height: 40, label: 'World', }); // 连线 graph.addEdge({ source, target });
然而这种方式并不能达到通过拖拽来生成流程图的要求,不过也不用担心,X6已经考虑到了这点,封装了 Dnd 类(drag and drop)来解决这个问题。代码如下:
import { Addon, Graph } from '@antv/X6'; const { Dnd } = Addon; // 创建主画布 const graph = new Graph({ id: document.getElementById('flowchart'), grid: true, snapline: { enabled: true } }); // 创建 Dnd 实例 const dnd = new Dnd({ target: graph, scaled: false, animation: true }); // 创建侧边栏 const sideBar = new Graph({ id: document.getElementById('sideBar'), interacting: false }); // 侧边栏添加内置节点1 sideBar.addNode({ id: 'node1', x: 80, y: 80, width: 80, height: 40, label: 'Hello', }); // 侧边栏添加内置节点2 sideBar.addNode({ id: 'node2', x: 80, y: 140, width: 80, height: 40, label: 'iMove', }); // 监听 mousedown 事件,调用 dnd.start 处理拖拽 sideBar.on("cell:mousedown", (args) => { const { node, e } = args; dnd.start(node.clone(), e); });
为了不限制绘制流程图的体验,iMove工具栏提供了绘制流程图常用到的一些功能(例如修改字号、加粗、斜体、文字颜色、背景颜色、对齐等等),这主要也是得益于 X6 提供了统一修改节点样式的方法。工具栏如下所示:
X6
使用setAttrs方法可以配置指定的样式:
// 设置字号 cell.setAttrs({ label: { fontSize: 14 } }; // 设置字重 cell.setAttrs({ label: { fontWeight: 'bold' } }); // 设置斜体 cell.setAttrs({ label: { fontStyle: 'italic' } }); // 设置文字颜色 cell.setAttrs({ label: { fill: 'red' } }); // 设置背景颜色 cell.setAttrs({ body: { fill: 'green' } }); // …………
每个节点的代码等价于一个 js 模块,因此你不用担心全局变量的命名污染问题,甚至可以 import 现有的 npm 包,但最后必须 export 出一个函数。需要注意的是,由于 iMove 天生支持节点代码的异步调用,因此 export 出的函数默认是一个 promise。
就以 是否登录 这个分支节点为例,我们来看下节点代码应该如何编写:
export default async function() { return fetch('/api/isLogin') .then(res => res.json()) .then(res => { const {success, data: {isLogin} = {}} = res; return success && isLogin; }).catch(err => { console.log('fetch /api/isLogin failed, the err is:', err); return false; }); }
由于该节点是分支节点,因此其 boolean 返回值决定了整个流程的走向。如果是非分支节点,直接流向下一个连接的节点即可。
最后,我们再来看下节点之间是如何进行数据通信的。
在 iMove 中,数据是以流(pipe)的形式从前往后进行流动的,也就是说前一个节点的返回值会是下一个节点的输入。不过也有一个例外,由于 分支节点 的返回值会是 boolean 类型,因此它的下游节点拿到的输入必将是一个 boolean 类型值,从而造成数据流的中断。为此,我们进行了一定的改造,分支节点的作用只负责数据流的转发,就像一个开关一样,只决定数据流的走向,但不改变流向下游的数据——流入分支节点后一个节点的数据依然是分支节点前一个节点输出的数据。
因此,以下例子中“请求profile接口”和“返回数据“两个节点会成为数据流的上下游关系。
我们再来看下他们之间是如何进行数据通信的:
节点: 请求profile接口
export default async function() { return fetch('/api/profile') .then(res => res.json()) .then(res => { const {success, data} = res; return {success, data}; }).catch(err => { console.log('fetch /api/isLogin failed, the err is:', err); return {success: false}; }); }
节点: 接口成功
export default async function(ctx) { // 获取上游数据 const pipe = ctx.getPipe() || {}; return pipe.success; }
节点: 返回数据
const processData = (data) => { // TODO: 数据加工处理 return data; }; export default async function(ctx) { // 这里获取到的上游数据,不是"接口成功"这个分支节点的返回值,而是"请求profile接口"这个节点的返回值 const pipe = ctx.getPipe() || {}; // 触发updateUI这个方法更新界面,传入的值为profileData ctx.emit('updateUI', {profileData: processData(pipe.data)}); }
如上代码所述,每个下游节点可以调用ctx.getPipe方法获取上游节点返回的数据流。另外,需要注意的是 返回数据 节点的最后一行代码 ctx.emit('updateUI', data) 需要和项目中的代码配合使用,项目中想监听这个事件,需要执行logic.on('updateUI',data=>{ //handler })。
ctx.getPipe
ctx.emit('updateUI', data)
logic.on('updateUI',data=>{ //handler })
在imove中,边的作用被弱化,仅表示图形上的节点连接关系,主要控制流程的走向,使用过程中仅用于连线。因此我们没有额外设计边属性,只采用了X6默认的边的属性:id(唯一标识符)、shape(形状)、source(起点)、target(终点)。如下所示:
{ "id": "5d034984-e0d5-4636-a5ab-862f1270d9e0", "shape": "edge", "source": { "cell": "1b44f69a-1463-4f0e-b8fc-7de848517b4e", "port": "bottom" }, "target": { "cell": "c18fa75c-2aad-40e9-b2d2-f3c408933d53", "port": "top" } }
边连线是通过在画布上绑定edge:connected事件实现的:
edge:connected
flowChart.on('edge:connected', (args) => { const edge = args.edge as Edge; const sourceNode = edge.getSourceNode() as Node; if (sourceNode && sourceNode.shape === 'imove-branch') { const portId = edge.getSourcePortId(); if (portId === 'right' || portId === 'bottom') { edge.setLabelAt(0, sourceNode.getPortProp(portId, 'attrs/text/text')); sourceNode.setPortProp(portId, 'attrs/text/text', ''); } } });
默认的边是没有选中高亮的样式的,这里我们可以直接在画布上绑定边选中的事件,改变边的样式即可:
flowChart.on('edge:selected', (args) => { args.edge.attr('line/stroke', '#feb663'); args.edge.attr('line/strokeWidth', '3px'); });
流程图画好了,我们如何实现完整流程的代码运行呢?其实,这也是根据节点的连接顺序进行编译的:
流程图编译的schema如下所示:
分支节点 { "shape": "imove-branch", "data": { "ports": { "right": { "condition": "true" }, "bottom": { "condition": "false" } }, "code": "export default async function(ctx) {\n return true;\n}" }, "id": "a6da6684-96c8-4595-bec6-a94122b61e30" } 连线 { "shape": "edge", "id": "cbcbd0ea-4a2a-4d2a-8135-7b4b7d7ec50d", "source": { "cell": "de868d18-9ec0-4dac-abbe-5cb9e3c20e2f", "port": "right" }, "target": { "cell": "a6da6684-96c8-4595-bec6-a94122b61e30", "port": "left" } }
通过流程图schema,可以解析到节点属性和节点关系,如开始节点、行为节点如何流向下一个节点,分支节点如何根据运行结果分别流向两条边。具体的编译过程如下所示:
首先根据DSL可以获取全部的边(Edge),边的属性上存储了source节点和target节点的id和方向,即起始节点和终点节点的id和方向,再根据节点的id可以找到全部的节点,于是可以串联起全部的节点和边。这里有几点要注意的是:
按照以上分析的思路,找到当前节点的下一个节点代码如下:
// 找到下一个节点 const getNextNode = (curNode: Cell.Properties, dsl: DSL) => { const nodes = dsl.cells.filter((cell) => cell.shape !== 'edge'); const edges = dsl.cells.filter((cell) => cell.shape === 'edge'); const foundEdge = edges.find((edge) => edge.source.cell === curNode.id); if (foundEdge) { return nodes.find((node) => node.id === foundEdge.target.cell); } };
在电商领域,业务场景会时常涉及到营销表单的配置。因此代码中会暴露出一些字段供运营配置,这些字段需要以合适的表单形式呈现,因此涉及到表单schema结构的定义。imove在定义表单schema结构时,是可以使用可视化的方式设计表单结构的,这里得益于 form-render 的优秀设计,我们可以使用其提供的fr-generator库,通过可视化拖拽修改的形式快速生成投放表单结构,方便后续进行数据投放。具体效果如下:
fr-generator
以上提到的fr-generator表单设计器是如何快速地进行表单搭建的呢?这不得不先说一下如何根据规范化schema转化为form。知其然知其所以然,接下来我们看看form-render是如何实现这一步的:
AntdForm/FusionForm
antd
fusion
widgets
checkbox
input
radio
select
mapping
schema
type
widgetName
RenderField
Widget
Field
form-render
'ui:className'
'ui:hidden'
'ui:width'
接下来讨论下fr-generator表单设计器的核心操作是什么。点击左侧表单组件,就可以将其加入到表格中:
以下的Element组件即代表左侧每一个表单选项,点击左侧表单组件,会调用handleElementClick方法,实际上是调用了addItem方法:
handleElementClick
addItem
const Element = ({ text, name, schema, icon }) => { // ...... const { selected, flatten, onFlattenChange } = useStore(); const handleElementClick = () => { const { newId, newFlatten } = addItem({ selected, name, schema, flatten }); onFlattenChange(newFlatten); setGlobal({ selected: newId }); }; return ( <div ref={dragRef}> <WidgetUI text={text} icon={icon} onClick={handleElementClick} /> </div> ); };
addItem对应的代码实现如下,其实是改变了原有的json schema数据,json schema改变了,中间渲染的表单就会跟随发生变化。核心步骤如下:
// 点击左侧菜单添加表单结构项 export const addItem = ({ selected, name, schema, flatten }) => { // ...... let _name = name + '_' + nanoid(6); const idArr = selected.split('/'); idArr.pop(); idArr.push(_name); newId = idArr.join('/'); const newFlatten = { ...flatten }; try { // 拿到选中的节点 const item = newFlatten[selected]; // 拿到选中节点的父节点children属性 const siblings = newFlatten[item.parent].children; // 找到选中节点在children数组中的位置 const idx = siblings.findIndex(x => x === selected); // 将选中节点后插入新的表单项 siblings.splice(idx + 1, 0, newId); const newItem = { parent: item.parent, schema: { ...schema, $id: newId }, data: undefined, children: [], }; newFlatten[newId] = newItem; } catch (error) { console.error(error); } return { newId, newFlatten }; };
其实,类似于form render、fomily这样的库,可以通过简单的JSON Schema生成表单,都是基于数据驱动的思想实现的。维护自身的一套schema规范,按照规范解析schema文件可以直接完成表单的渲染。数据驱动为何开发者如此热衷?react、vue等流行框架的底层也是数据驱动的思维,解放了程序员繁琐重复的工作。
就拿vue框架来说,以下数据驱动带来的便利性非常受人欢迎:
在数据驱动的场景下,我们只需要完成两步:
数据驱动的思想能够给前端带来很多的便利。以前开发时,我们处理页面元素就会处理DOM,处理事件逻辑就会处理js,处理样式就会处理css。切换为数据驱动的思想之后,我们可以把页面元素、事件逻辑、样式都视为数据,设计好数据与状态之间的转换关系,通过改变数据直接去改变状态。以上介绍的X6和form-render都是可以通过已有的规范json数据,直接生成对应的流程图和表单,其实数据和UI的转换关系已经隐藏在了框架的内部实现中。
这篇主要讨论了imove基于X6和form-render背后的思考以及相关的实现原理,其实是在已有库的基础上不断去完善,直到满足项目的需求。我们在初次使用某个框架或库时可以根据官方文档提供的示例实现demo效果,由于每个人业务需求都不尽相同,可能需要进行不同的配置甚至进行二次开发才能实现。这时候,可以恰当地使用现有的api能力去尽量实现需求,如果没有提供相应的能力,也可以看看有没有提供自定义插件、自定义函数或组件能满足需求。如果还是没有合适的方案,或许可以尝试一下自己实现?如果框架体积非常大但是你只需要使用到其中很小一部分,这时候可以考虑下看看对应的源码,学习下原理尝试自己实现。未来 imove 还会持续迭代,喜欢的朋友们欢迎来踩踩哦~
The text was updated successfully, but these errors were encountered:
No branches or pull requests
imove的可视化编排是如何实现的?
最近,我们的项目 imove 在github上的star数增长较快,想必是有值得大家肯定的地方的。设计这款工具的初衷是为了提高开发者的开发效率,通过面向业务逻辑的可视化编排提高逻辑元件(如ui、api、function)的复用能力。我们在双11业务中投入使用imove进行开发,不仅提高了开发的速度,还积累了许多的逻辑元件,如下图所示:
大家可能会很好奇,我们按照一直习惯的前端开发模式写代码不好吗?根据产品给的需求文档,根据UI划分一个个的组件,再一起把UI和逻辑实现了不是大功告成吗?为什么要额外引入流程图的绘制,会不会增加工作量?我们先来看看imove是如何进行开发的,再接下来讲讲我们为什么要打破以往的开发模式。
基于imove的开发方式
1. 绘制完流程图
根据你的业务逻辑绘制好流程图,这里的节点包括开始节点(圆形)、分支节点(菱形)和行为节点(矩形)。如下图所示,可以绘制多条执行链,每条执行链都从一个开始节点出发。
2. 完成每个节点的函数编写
依次双击节点,打开代码编辑框,为每个节点编写代码。
前端js代码实现包含了同步逻辑和异步逻辑,大多数可以使用同步逻辑实现,但是涉及到接口请求、定时任务等场景时就需要使用到异步逻辑。因此在imove中,我们支持书写同步代码和异步代码,并在编译中考虑了这两种情况。具体写法如下所示:
3. 在项目中使用
以上步骤只涉及到绘制流程图、编写节点代码,但是我们需要把这些js逻辑代码加入到自己的项目中,才能实现一个完整的项目。为了方便把这些编写的js逻辑加入到项目中,我们可以选择以下两种方式引入:1)将imove编写的代码在线打包,将打包文件引入到项目中;2)直接在本地启动开发模式 imove -d,可以结合项目进行实时调试,边在imove修改边看项目效果。以下介绍两种引入方式的具体步骤:
(1)本地打包出码
点击页面右上方的"导出"按钮后,可以在弹窗内选择“导出代码”,此时流程图编译后的代码将以
zip
包的形式下载到本地,你可以解压后再引入项目中使用。引入后使用方法如下:
logic.on
方法监听事件,事件名和参数与流程图中节点代码的 ctx.emit 相对应logic.invoke
方法调用逻辑,事件名与流程图中的开始节点的 逻辑触发名称 相对应,否则会调用失败(2)本地启动开发模式
$ npm install -g @imove/cli
$ cd yourProject
$ imove --init # 或 imove -i
$ imove --dev # 或 imove -d
本地启动成功之后,可以看到原来的页面右上角会显示连接成功。
此时页面上触发“保存快捷键 Ctrl + S”时,就可以看到当前项目的
src
目录下会多出一个logic
目录,这就是imove
编译生成的代码,此时你只要在你的组件中调用它即可。调用的方法仍然如本地打包出码
中演示的一致。为什么需要使用流程编排
了解了基于imove的开发方式,接下来具体讨论一下为什么需要这么做。试想一下,你有没有遇到过以下场景:
以上这些种种问题,其实是前端开发中或多或少会遇到的痛点。其实,我们可以采用逻辑编排的开发方式,把业务中的一个个功能点按照逻辑顺序组织起来,不仅能够及时检查逻辑漏洞,还能沉淀非常多的业务逻辑,达到参考和复用的效果。以下是总结的采用可视化编排方式进行编程的好处:
(1)需求可视化
把需求按照流程图的方式展示出来,可视化的流程对于新人以及非开发同学来说是非常容易理解的,对了解业务有很大的帮助。特别是在判别条件非常多、业务身份非常多、业务流程冗长而复杂的场景中,能梳理好全部的需求逻辑,能更好地检查代码是否考虑了全部的需求场景。这使得交付、交流、接手会更容易。
(2)逻辑复用
因为代码是针对于节点粒度的,在做需求时,可以参考和复用已有的节点代码(如判断登录、关注店铺等等),不仅对新人上手非常友好,也节约了反复编写重复代码的时间,能缩短开发周期。随着逻辑节点的不断沉淀,这种优势也会越发明显。
(3)提高代码规范
在现有的开发方式下,随着需求不断迭代,可能有会有越来越多的业务逻辑被分散在各个模块中,很可能会违反职责单一、高内聚低耦合的编程原则。而通过逻辑编排的方式开发,每次只针对一个节点编写代码,能保证各个能力职责单一,最后通过聚合的方式串联起整个业务逻辑。
现在思考一下,使用到逻辑编排后,上述列举出的种种问题,是不是会迎刃而解呢?
其实逻辑编排不是一种新出现的思想,其实像我们生活中,都有逻辑编排的影子。它只是按照行为逻辑,把一个个单一的业务行为(如限流、限购、加购等等)有序编织为一个完整流程,聚合成一条具有特定业务含义的执行链,每一个完整的流程都会有一个触发点。如下图,就是在业务场景中整理的流程图:
因此,在coding中引入逻辑编排的概念是有价值的,我们也正在尝试着使用新的开发模式去提高生产效率。
基于antv-X6的流程编排
在上文中详细展示了为什么需要使用到流程编排,下面详细介绍下流程图绘制的原理。imove底层采用了蚂蚁团队提供的 antv-X6 图编辑引擎 ,从而实现了流程图绘制的能力。为什么要选用
antv-X6
作为底层绘图引擎呢?因为它基本能覆盖到流程图绘制的全部需求,内置了图编辑场景的常规交互和设计,能帮助我们快速创建画布、节点和边,能够做到开箱即用,使用是非常方便的。它开放了丰富的定制能力给到开发者,只需简单的配置就能实现想要的效果,其不仅仅支持流程图,还支持DAG图、ER图、组织架构图等等。在imove中我们仅仅使用到了流程图绘制的能力,其实X6还有更多的能力值得大家去使用去探索。下面结合imove框架的实现,详细介绍下我们实现流程图绘制的具体思路,如何从0到1实现可用的较为完备的流程图绘制能力。画布设计
实现画布
使用 X6 创建画布非常容易,实例化一个 X6 暴露的 Graph 对象即可。以下是画布的具体配置项,包括了:
具体的代码实现如下,注释了各类能力对应的api文档,大家有兴趣可以试试~
完成了画布的开发,接下来在主界面中引入即可。这里我们发现还是存在很多问题:
仅仅依赖于以上
new Graph
配置的信息是远远不够的,还需要一步步完善起来。快捷键设置
根据 键盘快捷键 Keyboard 介绍,
Graph
实例会暴露一个bindKey
方法,我们可以用它来绑定快捷键。以使用频率最高的ctrl + c
/ctrl + v
举例,这里定义了一系列的键盘快捷键和handler监听函数,如ctrl + c
时获取当前选中的元素,利用copy()
方法完成复制操作,ctrl + v
时直接使用paste({offset:xx})
方法完成粘贴操作。首选需要在
new Graph
的参数中加入keyboard
配置项,这里的global
代表是否为全局键盘事件,设置为 true 时绑定在 Document 上,否则绑定在画布容器上。在这里我们设置为false,只在画布获得焦点才触发键盘事件。接下来要配置支持的快捷键,如复制、粘贴、保存、撤销、缩放、全选……需要指定对应的按键以及监听函数。感兴趣的朋友可以看下shortcuts.ts~
我们也可以实现快捷键或滚轮放大、缩小的能力,需要自定义每次缩放尺度的改变量,根据当前的缩放尺度去增减这个改变量即可。为了避免用户无限放大或无限缩小带来不好的视觉体验,最好定义一个最大和最小的缩放尺度。
最后我们把所有的快捷键绑定在Graph上,即可完成全部快捷点的配置。
画布方法注册
在当前画布上,我们需要监听一系列常用的方法,如双击节点、画布右键、节点右键……在imove中,完成了以下的操作:
以下是具体的实现方法:
创建画布实例
目前已经完成了画布通用配置、快捷键设置、事件绑定,接下来实现一个createFlowChart的工厂函数。在工厂函数中,我们创建了画布实例,并为其注册绑定的事件、注册快捷键、注册服务端存储。
这样,画布的功能越来越完善了,已经支持绘制流程图了。
导出模型
在imove中,需要支持绘制的流程图以DSL、代码、流程图的导出。我们实现的相关函数如下:
节点设计
节点类型
在imove中,我们设计了以下三种节点类型:事件节点(开始节点)、行为节点、分支节点。节点负责处理具体的逻辑流程,以下是三种节点的具体描述:
(注:一条逻辑流程必须以 开始节点 为起始)
根据上述的规范描述,我们可以绘制出各种各样的逻辑流程图,例如 进入首页 的流程图如下所示:
节点属性结构
imove的流程图节点需要承载了多种属性,如节点文字、代码、投放配置模型、投放配置数据、依赖包等等,但是在X6中,节点的基本属性为id、shape、position、size,主要包括与X6图形展示相关的基本数据,简称为图形化数据。因此我们需要扩展节点属性,这些扩展的属性简称为自定义数据。
图形化数据(主要包括与X6图形展示相关的基本数据)
自定义数据(扩展的属性)
定义完这些节点属性后,就可以实现节点信息的存储,不至于丢失信息。通常来说,信息配置使用频率最高的有:
这里编辑的每一条信息,都会保存在节点的属性里。
节点拖拽
单纯有画布是不够的,我们还需支持在画布上添加节点:
可以使用 addNode 和 addEdge 方法来动态添加节点和边:
然而这种方式并不能达到通过拖拽来生成流程图的要求,不过也不用担心,X6已经考虑到了这点,封装了 Dnd 类(drag and drop)来解决这个问题。代码如下:
节点样式设置
为了不限制绘制流程图的体验,iMove工具栏提供了绘制流程图常用到的一些功能(例如修改字号、加粗、斜体、文字颜色、背景颜色、对齐等等),这主要也是得益于
X6
提供了统一修改节点样式的方法。工具栏如下所示:使用setAttrs方法可以配置指定的样式:
节点代码编写
每个节点的代码等价于一个 js 模块,因此你不用担心全局变量的命名污染问题,甚至可以 import 现有的 npm 包,但最后必须 export 出一个函数。需要注意的是,由于 iMove 天生支持节点代码的异步调用,因此 export 出的函数默认是一个 promise。
就以 是否登录 这个分支节点为例,我们来看下节点代码应该如何编写:
由于该节点是分支节点,因此其 boolean 返回值决定了整个流程的走向。如果是非分支节点,直接流向下一个连接的节点即可。
节点间数据通信
最后,我们再来看下节点之间是如何进行数据通信的。
在 iMove 中,数据是以流(pipe)的形式从前往后进行流动的,也就是说前一个节点的返回值会是下一个节点的输入。不过也有一个例外,由于 分支节点 的返回值会是 boolean 类型,因此它的下游节点拿到的输入必将是一个 boolean 类型值,从而造成数据流的中断。为此,我们进行了一定的改造,分支节点的作用只负责数据流的转发,就像一个开关一样,只决定数据流的走向,但不改变流向下游的数据——流入分支节点后一个节点的数据依然是分支节点前一个节点输出的数据。
因此,以下例子中“请求profile接口”和“返回数据“两个节点会成为数据流的上下游关系。
我们再来看下他们之间是如何进行数据通信的:
节点: 请求profile接口
节点: 接口成功
节点: 返回数据
如上代码所述,每个下游节点可以调用
ctx.getPipe
方法获取上游节点返回的数据流。另外,需要注意的是 返回数据 节点的最后一行代码ctx.emit('updateUI', data)
需要和项目中的代码配合使用,项目中想监听这个事件,需要执行logic.on('updateUI',data=>{ //handler })
。边设计
边属性结构
在imove中,边的作用被弱化,仅表示图形上的节点连接关系,主要控制流程的走向,使用过程中仅用于连线。因此我们没有额外设计边属性,只采用了X6默认的边的属性:id(唯一标识符)、shape(形状)、source(起点)、target(终点)。如下所示:
边连线实现
边连线是通过在画布上绑定
edge:connected
事件实现的:边选中样式定制化
默认的边是没有选中高亮的样式的,这里我们可以直接在画布上绑定边选中的事件,改变边的样式即可:
基于流程图的代码编译
流程图画好了,我们如何实现完整流程的代码运行呢?其实,这也是根据节点的连接顺序进行编译的:
流程图编译的schema如下所示:
通过流程图schema,可以解析到节点属性和节点关系,如开始节点、行为节点如何流向下一个节点,分支节点如何根据运行结果分别流向两条边。具体的编译过程如下所示:
首先根据DSL可以获取全部的边(Edge),边的属性上存储了source节点和target节点的id和方向,即起始节点和终点节点的id和方向,再根据节点的id可以找到全部的节点,于是可以串联起全部的节点和边。这里有几点要注意的是:
按照以上分析的思路,找到当前节点的下一个节点代码如下:
基于form-render的可视化搭建
在电商领域,业务场景会时常涉及到营销表单的配置。因此代码中会暴露出一些字段供运营配置,这些字段需要以合适的表单形式呈现,因此涉及到表单schema结构的定义。imove在定义表单schema结构时,是可以使用可视化的方式设计表单结构的,这里得益于 form-render 的优秀设计,我们可以使用其提供的
fr-generator
库,通过可视化拖拽修改的形式快速生成投放表单结构,方便后续进行数据投放。具体效果如下:如何做到schema to form
以上提到的
fr-generator
表单设计器是如何快速地进行表单搭建的呢?这不得不先说一下如何根据规范化schema转化为form。知其然知其所以然,接下来我们看看form-render是如何实现这一步的:AntdForm/FusionForm
组件(分别兼容antd
组件库和fusion
组件库)。其中传入widgets
参数包含了暴露的所有组件,如checkbox
、input
、radio
、select
等,mapping
参数包含了schema
的type
字段与widgetName
(组件名)的映射关系。AntdForm/FusionForm
组件是由RenderField
组件实现的,在这里组合了全部的Widget
生成了Field
组件(即纯展示的表单组件)。RenderField
是由Field
组件实现的,在这里将纯展示表单组件和schema
的属性结合在一起,转化为真正带有属性的表单组件。转化函数详见parser.js,这里的转化函数是基于form-render
设计的json规范实现的,使用'ui:className'
、'ui:hidden'
、'ui:width'
等一致的属性命名规范去承载样式数据。接下来讨论下
fr-generator
表单设计器的核心操作是什么。点击左侧表单组件,就可以将其加入到表格中:以下的Element组件即代表左侧每一个表单选项,点击左侧表单组件,会调用
handleElementClick
方法,实际上是调用了addItem
方法:addItem
对应的代码实现如下,其实是改变了原有的json schema数据,json schema改变了,中间渲染的表单就会跟随发生变化。核心步骤如下:数据驱动带来的便利性
其实,类似于form render、fomily这样的库,可以通过简单的JSON Schema生成表单,都是基于数据驱动的思想实现的。维护自身的一套schema规范,按照规范解析schema文件可以直接完成表单的渲染。数据驱动为何开发者如此热衷?react、vue等流行框架的底层也是数据驱动的思维,解放了程序员繁琐重复的工作。
就拿vue框架来说,以下数据驱动带来的便利性非常受人欢迎:
在数据驱动的场景下,我们只需要完成两步:
数据驱动的思想能够给前端带来很多的便利。以前开发时,我们处理页面元素就会处理DOM,处理事件逻辑就会处理js,处理样式就会处理css。切换为数据驱动的思想之后,我们可以把页面元素、事件逻辑、样式都视为数据,设计好数据与状态之间的转换关系,通过改变数据直接去改变状态。以上介绍的X6和form-render都是可以通过已有的规范json数据,直接生成对应的流程图和表单,其实数据和UI的转换关系已经隐藏在了框架的内部实现中。
总结
这篇主要讨论了imove基于
X6
和form-render
背后的思考以及相关的实现原理,其实是在已有库的基础上不断去完善,直到满足项目的需求。我们在初次使用某个框架或库时可以根据官方文档提供的示例实现demo效果,由于每个人业务需求都不尽相同,可能需要进行不同的配置甚至进行二次开发才能实现。这时候,可以恰当地使用现有的api能力去尽量实现需求,如果没有提供相应的能力,也可以看看有没有提供自定义插件、自定义函数或组件能满足需求。如果还是没有合适的方案,或许可以尝试一下自己实现?如果框架体积非常大但是你只需要使用到其中很小一部分,这时候可以考虑下看看对应的源码,学习下原理尝试自己实现。未来 imove 还会持续迭代,喜欢的朋友们欢迎来踩踩哦~The text was updated successfully, but these errors were encountered: