从0到1搭建一个属于自己的工作流站点——初具雏形(bpmn-js、Next.js)

468 阅读9分钟

往期回顾

  1. 从0到1搭建一个属于自己的工作流站点——入门篇(bpmn.js、Next.js)
    • 对bpmn-js的基本概念以及BPMN基础元素进行解释说明,并演示了如何使用bpmn-js来构建和配置一个简单的工作流程。
  2. 从0到1搭建一个属于自己的工作流站点——筑基篇(bpmn.js、Next.js)
    • 更具体地介绍了什么是流程图,一张规范的流程图里都有哪些元素,配置流程图的编辑器都具备哪些功能,以及如何使用流程编辑器去配置一张流程图。

前言

在本系列的前两篇文章里,我们基本上完成了对工作流的理论知识的学习。

这就像我们考驾照一样,先学习基本的理论,明白交通规则;然后了解小汽车的相关信息,比如掌握刹车、油门、方向盘、离合器等等的作用;最后,我们才能够去尝试实车上路。

image.png

如今我们也到了“实车上路”的环节了,在本篇文章中,我们将会真正地去动手写代码,旨在 让我们的项目里成功出现流程设计器这个组件 ,并使用这个组件展示一个简单的流程图。

(本篇文章的重点主要还是放在基于 bpmn-js 实现的组件身上,视角的层次停留在组件层次。对于基于 create-next-app 创建的Next.js项目不做展开讨论,会在下一篇文章里和大家聊一聊 create-next-app 。)

创建第一步,ReadMe来帮助

我想无论我们接触什么新的工具,我们在试图使用它们时,第一步往往都是先阅读此工具的README.md文件。

使用 bpmn-js 自然也免不了俗,一起来看一下它的README.md文件吧。

image.png

好像非常的抽象,对吧?

我觉得这也是为什么 bpmn-js 的入门难度比较高的原因。这一块有关【使用】的描述内容实在是太意识流了。

我们拿它和 antdREADME.md 文件对比一下:

image.png

很明显,在阅读完 antd 的 README.md 文件后,我们明确的知道,只要在我们的文件里从 'antd'import 一些组件(如:<Button>DatePicker),我们便可以直接使用它们。😊

但是阅读完 bpmn-jsREADME.md 文件,我们并不知道这行

const viewer = new BpmnJS({
  container: 'body'
});

中的new BpmnJS()中的这个BpmnJS是从哪来的?😵

我该从哪个依赖中导出这玩意儿??

于是我们带着这个疑问又去官网看了一眼:

image.png

好嘛,一模一样,只不过这次加了个<script>标签,告诉你这是在 JavaScript 中使用的。

(👆说了和没说一样,我还能不知道这几行代码要在 JavaScript 中用吗😶?👆)

怎么办呢,我该如何在一个 React 项目里使用 bpmn-js 去帮助我创建一个组件呢?

从这一章节开始直到现在的全部内容,真的都是当初我在公司接手这个需求后,去做技术调研时真实遇到的情况,让人欲哭无泪🤦‍♂️。

bpmn-js:我知道你很急,但你先别急,你看看官网截图的下面那行小字呗?

image.png

walkthrough ,可以翻译为 操作指南 。点进去之后,不禁感叹“ 踏破铁鞋无觅处,得来全不费工夫 ”。

image.png

👆在 《操作指南》 的这一部分内容里👆,我们终于看到了类似 antd 的 README.md 文件中令我们熟悉的写法了。

那就让我们直接动手试试看:

创建实例

在写代码之前,还有一件事

image.png

我们可以从示例代码中看到:

new Modeler({ container: '#canvas' })

当我们 new 一个 Modeler 实例时,我们将一个类似 dom id 格式的字符串作为值传递给了初始化对象中的 container 属性。这一步操作意味着 我们需要在HTML中提供一个带有id为"canvas"的元素,供 Modeler 渲染器使用

因此我们一定要确保我们的函数组件return的内容里,一定包含一个id名为"canvas"的元素,否则将会报错。

我们使用的是 React,因此我们期望 Modeler 在以id为"canvas"的元素为渲染平台执行某些初始化操作时(后续的文章会分析这个初始化操作,这里先一笔带过),这个元素(这个真实DOM)一定已经挂载到了页面上。

因此我们使用一个依赖为空的 useEffect 去执行这个副作用,在挂载完毕后,将 Modeler 的实例创建出来,我们可以写出如下代码:

import { useEffect, useState } from "react";

import Modeler from "bpmn-js/lib/Modeler";

const BpmDemo = () => {
  const [bpmnModeler, setBpmnModeler] = useState<any | null>(null);
  useEffect(() => {
    setBpmnModeler(new Modeler({ container: "#canvas" }));
  }, []);

  return <div id="canvas" style={{ width: 400, height: 300 }}></div>;
};

export default BpmDemo;

我们期望能够创建出一张 400 * 300 的画布,运行后我们看下效果:

image.png

为啥创建了两张画布?即我们以id为"canvas"的元素为渲染平台,初始化了2次?

因为我们现在是开发环境,在开发环境下,React 有意重复挂载我们的组件。由此也衍生出一个经典的问题:

如何处理在开发环境中 Effect 执行两次?

有关 useEffect 的小插曲

这里我将介绍2种方式去处理这种情况,一种是不优雅的,直接粗暴的,另一种是官方文档建议的优雅实现。

使用 useRef

我们可以使用 useRef 帮助我们去判断 Modeler 实例是否被初始化过了,如果没有,则初始化,反之,则什么也不做。

import { useEffect, useState, useRef } from "react";

import Modeler from "bpmn-js/lib/Modeler";

const BpmDemo = () => {
  const [bpmnModeler, setBpmnModeler] = useState<any | null>(null);
  const renderFlag = useRef<boolean>(false);

  useEffect(() => {
    if (!renderFlag.current) {
      renderFlag.current = true;
      setBpmnModeler(new Modeler({ container: "#canvas" }));
    }
  }, []);

  return <div id="canvas" style={{ width: 400, height: 300 }}></div>;
};

export default BpmDemo;

从上述的代码中我们可以看到,在 Effect 第一次执行的时候,renderFlag.currentfalse,因此我们进行 Modeler 的实例化,并将 renderFlag.current 置为 true

之后在 Effect 第二次执行时,由于不满足条件语句中的 !renderFlag.current,因此便不会进入内部逻辑,从而避免了 Modeler 的重复实例化。

改版后运行效果如下,我们成功地使得页面上只出现了一张画布:

image.png

及时在卸载阶段做相应处理

第一种使用 useRef 的方式固然能够帮助我们解决问题,但这并不是解决问题的最佳实践。

我们访问 React 官方文档,看看它建议我们的处理方式:

image.png

因此我们应该在清理函数中,及时清空画布、并释放 new Modeler 创建的实例。我们应该使用这种方案去解决问题:

import { useEffect, useState } from "react";

import Modeler from "bpmn-js/lib/Modeler";

const BpmDemo = () => {
  const [bpmnModeler, setBpmnModeler] = useState<any | null>(null);

  useEffect(() => {
    setBpmnModeler(new Modeler({ container: "#canvas" }));
    return () => {
      // 步骤1: 清理画布
      const canvasContainer = document.querySelector("#canvas");
      if (canvasContainer) {
        canvasContainer.innerHTML = ""; // 清空画布内容
      } 
      // 步骤2: 释放实例
      setBpmnModeler(null);
    };
  }, []);

  return <div id="canvas" style={{ width: 400, height: 300 }}></div>;
};

export default BpmDemo;

改版后,再次运行代码,我们可以看到如下结果:

image.png

我们同样成功地使得页面上只出现了一张画布,并且遵循了 React 官方文档的建议,采用了更优雅的方式。

不管怎么说,我们现在成功地创建出了一张可以绘制流程图的画布,虽然它现在是空白的,并且看起来和官网Demo也不太相似。

相信我,会变得相似的。

怎么样都好,我想先看到张流程图

一张空白的画布真的很难有什么说服力,至少出现一点前两篇文章提到过的BPMN图元吧?像是开始节点、结束节点、用户任务之类的。

我们回顾一下 《操作指南》 中的实例代码,并结合 README.md 文件一起看:


const bpmnXML = '...'; // my BPMN 2.0 xml


// import diagram
try {
  await modeler.importXML(bpmnXML);
  // ...
} catch (err) {
  // err...
}

从上面的代码中我们可以看到, Modeler 提供了一个实例方法 importXML ,让我们可以导入一份符合 BPMN 2.0 规范的xml,从而使我们的画布上能够呈现出流程图。

那么从哪里可以获得一份符合 BPMN 2.0 规范的xml文件呢?

我们可以用官网的在线demo导出一份xml数据,然后再在我们自己的项目里使用它。

image.png

导出后的结果如下:diagram.bpmn

<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_1q2zuyn" targetNamespace="http://bpmn.io/schema/bpmn" exporter="bpmn-js (https://demo.bpmn.io)" exporterVersion="17.7.1">
  <bpmn:process id="Process_0pw7aj3" isExecutable="false">
    <bpmn:startEvent id="StartEvent_1nls6bn">
      <bpmn:outgoing>Flow_0y3ajkq</bpmn:outgoing>
    </bpmn:startEvent>
    <bpmn:task id="Activity_1o6n4zx" name="海石">
      <bpmn:incoming>Flow_0y3ajkq</bpmn:incoming>
      <bpmn:outgoing>Flow_1gbaigo</bpmn:outgoing>
    </bpmn:task>
    <bpmn:sequenceFlow id="Flow_0y3ajkq" sourceRef="StartEvent_1nls6bn" targetRef="Activity_1o6n4zx" />
    <bpmn:endEvent id="Event_0jxha22">
      <bpmn:incoming>Flow_1gbaigo</bpmn:incoming>
    </bpmn:endEvent>
    <bpmn:sequenceFlow id="Flow_1gbaigo" sourceRef="Activity_1o6n4zx" targetRef="Event_0jxha22" />
  </bpmn:process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_0pw7aj3">
      <bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1nls6bn">
        <dc:Bounds x="152" y="102" width="36" height="36" />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape id="Activity_1o6n4zx_di" bpmnElement="Activity_1o6n4zx">
        <dc:Bounds x="240" y="80" width="100" height="80" />
        <bpmndi:BPMNLabel />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape id="Event_0jxha22_di" bpmnElement="Event_0jxha22">
        <dc:Bounds x="392" y="102" width="36" height="36" />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNEdge id="Flow_0y3ajkq_di" bpmnElement="Flow_0y3ajkq">
        <di:waypoint x="188" y="120" />
        <di:waypoint x="240" y="120" />
      </bpmndi:BPMNEdge>
      <bpmndi:BPMNEdge id="Flow_1gbaigo_di" bpmnElement="Flow_1gbaigo">
        <di:waypoint x="340" y="120" />
        <di:waypoint x="392" y="120" />
      </bpmndi:BPMNEdge>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</bpmn:definitions>

请注意,直接使用文件路径导入是不行的,我们查看 bpmn-js 的源码,可以找到 importXML 方法的相关文件:

image.png

👇在这个方法的内部处理逻辑中,它会使用 fromXML 去解析我们传入的 xml 字符串👇。

image.png

⚠因此我们要记得对刚刚导出的文件做字符串化的处理。⚠

我们修改一下代码,并将画布大小调整为 800*600 ,看看效果:

import { useEffect, useState } from "react";

import Modeler from "bpmn-js/lib/Modeler";
const bpmnXMLString = `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_17n1c2u" targetNamespace="http://bpmn.io/schema/bpmn" exporter="bpmn-js (https://demo.bpmn.io)" exporterVersion="17.7.1">
  <bpmn:process id="Process_1q0g8bt" isExecutable="false">
    <bpmn:startEvent id="StartEvent_0hcpxtn">
      <bpmn:outgoing>Flow_0c7xmc2</bpmn:outgoing>
    </bpmn:startEvent>
    <bpmn:task id="Activity_1xe73bk" name="海石">
      <bpmn:incoming>Flow_0c7xmc2</bpmn:incoming>
      <bpmn:outgoing>Flow_0ox8xns</bpmn:outgoing>
    </bpmn:task>
    <bpmn:sequenceFlow id="Flow_0c7xmc2" sourceRef="StartEvent_0hcpxtn" targetRef="Activity_1xe73bk" />
    <bpmn:endEvent id="Event_0vau36o">
      <bpmn:incoming>Flow_0ox8xns</bpmn:incoming>
    </bpmn:endEvent>
    <bpmn:sequenceFlow id="Flow_0ox8xns" sourceRef="Activity_1xe73bk" targetRef="Event_0vau36o" />
  </bpmn:process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1q0g8bt">
      <bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_0hcpxtn">
        <dc:Bounds x="152" y="102" width="36" height="36" />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape id="Activity_1xe73bk_di" bpmnElement="Activity_1xe73bk">
        <dc:Bounds x="240" y="80" width="100" height="80" />
        <bpmndi:BPMNLabel />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape id="Event_0vau36o_di" bpmnElement="Event_0vau36o">
        <dc:Bounds x="392" y="102" width="36" height="36" />
      </bpmndi:BPMNShape>
      <bpmndi:BPMNEdge id="Flow_0c7xmc2_di" bpmnElement="Flow_0c7xmc2">
        <di:waypoint x="188" y="120" />
        <di:waypoint x="240" y="120" />
      </bpmndi:BPMNEdge>
      <bpmndi:BPMNEdge id="Flow_0ox8xns_di" bpmnElement="Flow_0ox8xns">
        <di:waypoint x="340" y="120" />
        <di:waypoint x="392" y="120" />
      </bpmndi:BPMNEdge>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</bpmn:definitions>`;
const BpmDemo = () => {
  const [bpmnModeler, setBpmnModeler] = useState<any | null>(null);

  useEffect(() => {
    setBpmnModeler(new Modeler({ container: "#canvas" }));
    return () => {
      // 步骤1: 清理画布
      const canvasContainer = document.querySelector("#canvas");
      if (canvasContainer) {
        canvasContainer.innerHTML = ""; // 清空画布内容
      } // 步骤2: 释放实例
      setBpmnModeler(null);
    };
  }, []);

  useEffect(() => {
    if (bpmnModeler) {
      (async function () {
        await bpmnModeler.importXML(bpmnXMLString);
      })();
    }
  }, [bpmnModeler]);

  return <div id="canvas" style={{ width: 800, height: 600 }}></div>;
};

export default BpmDemo;

我们成功地将一张流程图展示在了我们的画布上,这也意味着我们成功迈出了从0到1的第一步!

image.png

结语

在本篇文章中,我们亲自感受了一番接取需求任务,调研技术栈的过程。

万事开头难,在经历了多次检索之后,我们找到了如何以npm安装依赖的方式去调用 bpmn-js 创建一个 Modeler 实例的使用方式。

顺便还回顾了 React 在开发环境时会重复挂载组件的这一问题,探讨了优雅实现。

最后,我们通过调用 Modeler 实例方法:importXML,成功地在我们的流程设计器组件上呈现了一张流程图!

但是我们发现,目前的流程设计器对比官方的在线Demo,还缺少了很多东西,比如左侧工具栏、属性面板、导出/保存 按钮等等。

在下一篇文章中,我们会继续动手实践,将这些缺少的功能一个接着一个地实现。

期待与你在下一篇文章相遇!

image.png

示例完整代码

我将代码push到了我的 git仓库里,大家可以在这里找到。

image.png