乐趣区

关于javascript:iMove-基于-X6-formrender-背后的思考

作者:冷卉
原文地址:https://www.yuque.com/imove/b…

最近,咱们的我的项目 iMove 在 github 上的 star 数增长较快,想必是有值得大家必定的中央的。设计这款工具的初衷是为了进步开发者的开发效率,通过面向业务逻辑的可视化编排进步逻辑元件(如 uiapifunction)的复用能力。咱们在双 11 业务中投入使用 iMove 进行开发,不仅进步了开发的速度,还积攒了许多的逻辑元件,如下图所示:


大家可能会很好奇,咱们依照始终习惯的前端开发模式写代码不好吗?依据产品给的需要文档,依据 UI 划分一个个的组件,再一起把 UI 和逻辑实现了不是功败垂成吗?为什么要额定引入流程图的绘制,会不会减少工作量?

本文会讲 2 个要点

  • iMove 是如何进行开发的,咱们为什么要突破以往的开发模式。
  • 相比于绘制流程图,iMove 更吸引人的是它能够将流程图编译成业务我的项目中可理论运行的代码。

iMove 的可视化编排是如何实现的?

iMove 的外围就是基于 x6 协定实现的。

  • 有节点:利用 x6 的可视化界面,便于复用和编排。
  • 有指向边:即流程可视化,简略直观,边上还能够带参数。
  • function 和 schema2form,反对函数定义,这是面向开发者的。反对 form,让每个函数都能够配置入参,这部分是基于阿里开源的 form-render 实现的。

整个我的项目难度不大,基于 x6 和 form-render 进一步整合,将写法规范化,将编排工具化,这样克服的设计使得 imove 具备小而美的特点,便于开发应用。

基于 imove 的开发方式

绘制完流程图

依据你的业务逻辑绘制好流程图,这里的节点包含开始节点(圆形)、分支节点(菱形)和行为节点(矩形)。如下图所示,能够绘制多条执行链,每条执行链都从一个开始节点登程。

实现每个节点的函数编写

顺次双击节点,关上代码编辑框,为每个节点编写代码。

前端 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;
}

在我的项目中应用

以上步骤只波及到绘制流程图、编写节点代码,然而咱们须要把这些 js 逻辑代码退出到本人的我的项目中,能力实现一个残缺的我的项目。为了不便把这些编写的 js 逻辑退出到我的项目中,咱们能够抉择以下两种形式引入:

1)将 imove 编写的代码在线打包,将打包文件引入到我的项目中;
2)间接在本地启动开发模式 imove -d,能够联合我的项目进行实时调试,边在 imove 批改边看我的项目成果。以下介绍两种引入形式的具体步骤:

(1)本地打包出码

点击页面右上方的 ” 导出 ” 按钮后,能够在弹窗内抉择“导出代码”,此时流程图编译后的代码将以 zip 包的模式下载到本地,你能够解压后再引入我的项目中应用。

引入后应用办法如下:

  1. 通过 logic.on 办法监听事件,事件名和参数与流程图中节点代码的 ctx.emit 绝对应
  2. 通过 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)本地启动开发模式

  1. 装置 @imove/cli 
$ npm install -g @imove/cli
  1. 进入我的项目根目录,imove 初始化
$ cd yourProject 
$ imove --init # 或 imove -i
  1. 本地启动开发模式
$ imove --dev # 或 imove -d

本地启动胜利之后,能够看到原来的页面右上角会显示连贯胜利。

此时页面上触发 “保留快捷键 Ctrl + S” 时,就能够看到以后我的项目的 src 目录下会多出一个 logic 目录,这就是 imove 编译生成的代码,此时你只有在你的组件中调用它即可。调用的办法依然如 本地打包出码 中演示的统一。

为什么须要应用流程编排

理解了基于 iMove 的开发方式,接下来具体讨论一下为什么须要这么做。试想一下,你有没有遇到过以下场景:

  1. UI 常常发生变化,然而我想复用以往实现过的逻辑,却不晓得代码在哪里,又要从新写一遍
  2. 产品的需要文档都是文字,咱们只好在脑中构思逻辑,边写边想还容易脱漏逻辑,只好写完了代码重复查看
  3. 以前做的我的项目很久没做了,然而最近我想改,然而我的项目对我来说如此生疏,不晓得代码是什么意思,无奈疾速动手
  4. 我要接手一个老我的项目,然而外面的代码逻辑很简单,又没有什么正文,不晓得如何是好
  5. 我是个新人,在做新业务时可能有很多不太分明的实现逻辑,如关注店铺、判断登录、发送埋点……,没有什么文档参考,师兄又太忙,问起来很花工夫
  6. 实现某个逻辑 a 时,想参考下以前他人实现的代码,然而大串大串的业务逻辑耦合在一起,不晓得哪一部分才是逻辑 a,于是开始本人钻研……

以上这些种种问题,其实是前端开发中或多或少会遇到的痛点。其实,咱们能够采纳逻辑编排的开发方式,把业务中的一个个性能点依照逻辑程序组织起来,不仅可能及时查看逻辑破绽,还能积淀十分多的业务逻辑,达到参考和复用的成果。以下是总结的采纳可视化编排形式进行编程的益处:

(1)需要可视化

把需要依照流程图的形式展现进去,可视化的流程对于新人以及非开发同学来说是非常容易了解的,对理解业务有很大的帮忙。特地是在判断条件十分多、业务身份十分多、业务流程简短而简单的场景中,能梳理好全副的需要逻辑,能更好地查看代码是否思考了全副的需要场景。这使得交付、交换、接手会更容易。

(2)逻辑复用

因为代码是针对于节点粒度的,在做需要时,能够参考和复用已有的节点代码(如判断登录、关注店铺等等),不仅对新人上手十分敌对,也节约了重复编写反复代码的工夫,能缩短开发周期。随着逻辑节点的一直积淀,这种劣势也会越发显著。

(3)进步代码标准

在现有的开发方式下,随着需要一直迭代,可能有会有越来越多的业务逻辑被扩散在各个模块中,很可能会违反职责繁多、高内聚低耦合的编程准则。而通过逻辑编排的形式开发,每次只针对一个节点编写代码,能保障各个能力职责繁多,最初通过聚合的形式串联起整个业务逻辑。

当初思考一下,应用到逻辑编排后,上述列举出的种种问题,是不是会迎刃而解呢?

其实逻辑编排不是一种新呈现的思维,其实像咱们生存中,都有逻辑编排的影子。它只是依照行为逻辑,把一个个繁多的业务行为(如限流、限购、加购等等)有序编织为一个残缺流程,聚合成一条具备特定业务含意的执行链,每一个残缺的流程都会有一个触发点。如下图,就是在业务场景中整顿的流程图:

因而,在 coding 中引入逻辑编排的概念是有价值的,咱们也正在尝试着应用新的开发模式去进步生产效率。

基于 x6 的流程编排

在上文中具体展现了为什么须要应用到流程编排,上面具体介绍下流程图绘制的原理。iMove 底层采纳了蚂蚁团队提供的 antv-X6 图编辑引擎,从而实现了流程图绘制的能力。

为什么要选用 antv-x6 作为底层绘图引擎呢?因为它根本能笼罩到流程图绘制的全副需要,内置了图编辑场景的惯例交互和设计,能帮忙咱们疾速创立画布、节点和边,可能做到开箱即用,应用是十分不便的。它凋谢了丰盛的定制能力给到开发者,只需简略的配置就能实现想要的成果,其不仅仅反对流程图,还反对 DAG 图、ER 图、组织架构图等等。在 iMove 中咱们仅仅应用到了流程图绘制的能力,其实 x6 还有更多的能力值得大家去应用去摸索。上面联合 iMove 框架的实现,具体介绍下咱们实现流程图绘制的具体思路,如何从 0 到 1 实现可用的较为齐备的流程图绘制能力。

画布设计

应用 x6 创立画布非常容易,实例化一个 x6 裸露的 Graph 对象即可。以下是画布的具体配置项,包含了一下个性。

  1. 节点是否可旋转
  2. 节点是否可调整大小
  3. 跨画布的复制 / 剪切 / 粘贴
  4. 节点连线规定配置
  5. 画布背景配置(反对色彩 / 图片 / 水印等)
  6. 网格配置
  7. 点选 / 框选配置
  8. 对齐线配置
  9. 键盘快捷键配置
  10. 撤销 / 重做能力
  11. 画布滚动、平移、居中、缩放等能力
  12. 鼠标滚轮缩放配置
  13. ……

具体的代码实现如下,正文了各类能力对应的 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 配置的信息是远远不够的,还须要一步步欠缺起来。

快捷键设置

依据 键盘快捷键 Keyboard 介绍,Graph 实例会裸露一个 bindKey 办法,咱们能够用它来绑定快捷键。以应用频率最高的 ctrl + c / ctrl + v 举例,这里定义了一系列的键盘快捷键和 handler 监听函数,如 ctrl + c时获取以后选中的元素,利用 copy() 办法实现复制操作,ctrl + v 时间接应用 paste({offset:xx}) 办法实现粘贴操作。

首选须要在 new Graph 的参数中退出 keyboard 配置项,这里的 global 代表是否为全局键盘事件,设置为 true 时绑定在 Document 上,否则绑定在画布容器上。在这里咱们设置为 false,只在画布取得焦点才触发键盘事件。

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 中,实现了以下的操作:

  1. 双击节点关上代码编辑框,想进步交互体验
  2. 右键节点关上菜单栏,反对节点复制、删除、编辑文本、置于顶层 / 底层、编辑代码、执行代码
  3. 右键画布关上菜单栏,反对全选和粘贴

以下是具体的实现办法:

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 中,咱们设计了以下三种节点类型:事件节点(开始节点)、行为节点、分支节点。节点负责解决具体的逻辑流程,以下是三种节点的具体形容:

  1. 开始节点:逻辑起始,是所有流程的开始,能够是一次生命周期初始化 / 一次点击
  2. 行为节点:逻辑执行,能够是一次网络申请 / 一次扭转状态 / 发送埋点等
  3. 分支节点:逻辑路由,依据不同的逻辑执行后果跳转到不同的节点(注:一条逻辑流程必须以 开始节点 为起始)

根据上述的标准形容,咱们能够绘制出各种各样的逻辑流程图,例如 进入首页 的流程图如下所示:

节点属性构造

iMove 的流程图节点须要承载了多种属性,如节点文字、代码、投放配置模型、投放配置数据、依赖包等等,然而在 x6 中,节点的根本属性为 idshapepositionsize,次要包含与 x6 图形展现相干的根本数据,简称为图形化数据。因而咱们须要扩大节点属性,这些扩大的属性简称为自定义数据。

  1. 图形化数据(次要包含与 X6 图形展现相干的根本数据)

    • id: 32-bit 惟一标识符
    • shape: 形态
    • position: {x, y}: 横向 / 纵向位移
    • size: {width, height}: 宽高大小
  2. 自定义数据(扩大的属性)

    • type: 节点类型
    • label: 节点展现文案
    • code: 节点存储的代码
    • dependencies: js 代码的依赖包
    • trigger: 触发逻辑开始的事件名(开始节点才有)
    • ports: 节点进口配置(跳转逻辑,分支节点才有)
    • configSchema: 投放配置模型
    • configData: 投放配置数据
    • version: 版本号
    • forkId: 复制起源
    • referId: 援用起源

定义完这些节点属性后,就能够实现节点信息的存储,不至于失落信息。通常来说,信息配置应用频率最高的有:

  • 显示名称:更改节点名称
  • 逻辑触发名称:开始节点类型专属配置,我的项目代码应用时依据这个值触发逻辑调用
  • 投放配置 schema:批改投放配置的表单构造

这里编辑的每一条信息,都会保留在节点的属性里。

节点拖拽

单纯有画布是不够的,咱们还需反对在画布上增加节点:

能够应用 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 提供了对立批改节点款式的办法。工具栏如下所示:

应用 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})

边设计

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 事件实现的:

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', '');
    }
  }
});

这些 x6 都曾经提供好了,开发和定制都是非常简单。

边选中款式定制化

默认的边是没有选中高亮的款式的,这里咱们能够间接在画布上绑定边选中的事件,扭转边的款式即可:

flowChart.on('edge:selected', (args) => {args.edge.attr('line/stroke', '#feb663');
  args.edge.attr('line/strokeWidth', '3px');
});

如果大家想依照本人的爱好来定制流程图上的边展现,能够非常简单的实现。

基于流程图的代码编译

流程图画好了,咱们如何实现残缺流程的代码运行呢?其实,流程图对应是的一个 JSON Schema,这样咱们就能够依据节点的连贯程序进行编译了:

流程图编译的 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 能够找到全副的节点,于是能够串联起全副的节点和边。这里有几点要留神的是:

  1. 节点(Node)和边(Edge)的 id 永远都是惟一的, 能够依据 id 找到对应的节点 / 边。
  2. 方向永远都是从边(Edge)的 source 节点流向 target 节点的,因而节点之间的流动关系也是固定的。
  3. 判断节点有两个输入方向,须要依据节点输入的 boolean 值去判断走向。其中判断节点记录了两条边对应的方向和 boolean 值,在编译时须要思考。

依照以上剖析的思路,找到以后节点的下一个节点代码如下:

// 找到下一个节点
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);
  }
};

基于 form-render 的可视化搭建

在电商畛域,业务场景会时常波及到营销表单的配置。因而代码中会暴露出一些字段供经营配置,这些字段须要以适合的表单模式出现,因而波及到表单 Schema 构造的定义。iMove 在定义表单 Schema 构造时,是能够应用可视化的形式设计表单构造的,这里得益于 form-render 这个开源我的项目的优良设计,咱们能够应用其提供的 fr-generator 库,通过可视化拖拽批改的模式疾速生成投放表单构造,不便后续进行数据投放。具体成果如下:

如何做到 Schema to Form

以上提到的 fr-generator 表单设计器是如何疾速地进行表单搭建的呢?这不得不先说一下如何依据规范化 Schema 转化为 Form。知其然知其所以然,接下来咱们看看 form-render 是如何实现这一步的:

  1. 入口是 AntdForm/FusionForm 组件(别离兼容 antd 组件库和 fusion 组件库)。其中传入 widgets 参数蕴含了裸露的所有组件,如 checkboxinputradioselect 等,mapping 参数蕴含了 schematype 字段与 widgetName(组件名)的映射关系。
  2. AntdForm/FusionForm 组件是由 RenderField 组件实现的,在这里组合了全副的 Widget 生成了 Field 组件(即纯展现的表单组件)。
  3. RenderField 是由 Field 组件实现的,在这里将纯展现表单组件和 schema 的属性联合在一起,转化为真正带有属性的表单组件。转化函数详见 parser.js,这里的转化函数是基于 form-render 设计的 json 标准实现的,应用 'ui:className''ui:hidden''ui:width' 等统一的属性命名标准去承载款式数据。

接下来探讨下 fr-generator 表单设计器的外围操作是什么。点击左侧表单组件,就能够将其退出到表格中:

以下的 Element 组件即代表左侧每一个表单选项,点击左侧表单组件,会调用 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 扭转了,两头渲染的表单就会追随发生变化。外围步骤如下:

  1. 获取画布中选中的节点(表单项)
  2. 获取此节点的父节点 children 属性
  3. 找到此节点在 children 数组中的地位
  4. 在选中节点后插入新的表单项(其实是在操作父节点的 children 数组)
  5. JSON Schema 构造扭转
  6. 画布更新
// 点击左侧菜单增加表单构造项
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 renderfomily 这样的库,能够通过简略的 JSON Schema 生成表单,都是基于数据驱动的思维实现的。保护本身的一套 schema 标准,依照标准解析 schema 文件能够间接实现表单的渲染。数据驱动为何让开发者如此热衷?reactvue 等风行框架的底层也是数据驱动的思维,解放了程序员繁琐反复的工作。

就拿 vue 框架来说,以下数据驱动带来的便利性十分受人欢送:

  1. 模板渲染:依据模板生成 AST,最初依据 AST 树填充数据生成实在 DOM
  2. 数据绑定:能够监听 交互输出 /http 申请响应 / 定时器触发 等行为,当数据发生变化时,做 diff 操作,实现 DOM 的更新
  3. 路由引擎:依据 host/path/params 等数据,解析对应页面

在数据驱动的场景下,咱们只须要实现两步:

  1. 将产品、业务、设计进行抽象化,将 UI、交互形象为数据
  2. 将数据用逻辑解决连接起来,通过数据去间接影响后果

数据驱动的思维可能给前端带来很多的便当。以前开发时,咱们解决页面元素就会解决 DOM,处理事件逻辑就会解决 JavaScript,解决款式就会解决 CSS。切换为数据驱动的思维之后,咱们能够把页面元素、事件逻辑、款式都视为数据,设计好数据与状态之间的转换关系,通过扭转数据间接去扭转状态。以上介绍的 x6 和 form-render 都是能够通过已有的标准 JSON 数据,间接生成对应的流程图和表单,其实数据和 UI 的转换关系曾经暗藏在了框架的外部实现中。

总结

这篇文章次要探讨了 imove 基于 X6form-render背地的思考以及相干的实现原理,次要是在已有开源库的根底上一直去欠缺,直到满足我的项目的需要,这样能力做到 ROI 最大化,互相成就。iMove 做的比拟好的是定位,继而将写法规范化,将编排工具化,这样克服的设计使得 iMove 具备小而美的特点,便于开发应用。

咱们在首次应用某个开源框架或库时能够依据官网文档提供的示例实现 Demo 成果,因为每个人业务需要都不尽相同,可能须要进行不同的配置甚至进行二次开发能力实现。这时候,能够失当地应用现有的 API 能力去尽量实现需求,如果没有提供相应的能力,也能够看看有没有提供自定义插件、自定义函数或组件能满足需要。如果还是没有适合的计划,或者能够尝试一下本人实现?如果框架体积十分大然而你只须要应用到其中很小一部分,这时候能够思考下看看对应的源码,学习下原理尝试本人实现,其实 x6 和 form-render 这种开源根底库在很多场景下都是十分实用的。将来 iMove 还会继续迭代,喜爱的敌人们欢送来踩踩哦~

iMove 系列举荐浏览

  1. 《2021 年前端趋势预测》
  2. 《F2C 是否让前端像经营配置一样开发?》
  3. 《登上 Github 趋势榜,iMove 原理技术大揭秘!》
  4. 《iMove 基于 X6 + form-render 背地的思考》
  5. 《所见即所得! iMove 在线执行代码摸索》
退出移动版