3、流程设计器:流程设计器架构、拖拽式设计、节点属性配置

好,咱们今天聊聊流程设计器。这东西说白了,就是工作流引擎的「脸面」。用户能不能用好你的引擎,一半看设计器好不好用。我见过太多项目,引擎内核牛得不行,结果设计器一塌糊涂,最后项目还是黄了。

我个人习惯把设计器分成三层来看:展示层、交互层、数据层。这样分层,后期维护起来心里有数。

3.1 流程设计器架构

先说说整体架构。你想想看,一个流程设计器要干哪些事?

  • 画流程图(节点+连线)
  • 拖拽操作
  • 配置节点属性
  • 保存/加载流程定义
  • 校验流程合法性

我建议采用MVVM模式来做。视图层只管渲染,ViewModel层处理交互逻辑,Model层管理数据。这样做的好处是,哪天你想换UI库,比如从jQuery换成Vue,只需要动视图层,业务逻辑不用重写。

我在项目中遇到过一种情况:团队直接用原生Canvas画流程图,结果后来要支持移动端适配,改得想哭。所以我现在更推荐基于SVG的方案,或者直接用成熟的图形库,比如mxGraphJointJS。别自己造轮子,除非你团队有图形学大牛。

架构上,核心模块就这几个:

模块 职责 技术选型参考
画布引擎 管理节点和连线的渲染、缩放、平移 SVG / Canvas / WebGL
拖拽管理器 处理拖拽事件、碰撞检测、吸附对齐 HTML5 Drag & Drop / 自定义事件
属性面板 展示和编辑节点/连线的配置 表单引擎 / JSON Schema
序列化器 将图形数据转为JSON/XML,或反向解析 自定义序列化 / BPMN 2.0
校验器 检查流程是否合法(如:是否有孤立节点) 图算法(DFS/BFS)
小提示: 数据层一定要和视图层解耦。我习惯把流程数据存成一颗「图」结构:nodes数组 + edges数组。这样不管前端用什么框架渲染,后端都能直接解析。

3.2 拖拽式设计

拖拽是设计器的灵魂。用户从左侧工具栏拖一个「审批节点」到画布上,这个动作背后发生了什么?

嗯,这里要注意几个关键点:

  1. 拖拽源:左侧的节点模板,要设置draggable="true",并绑定dragstart事件,把节点类型信息放到dataTransfer里。
  2. 拖拽目标:画布区域监听dragover(阻止默认行为)和drop事件。drop时读取dataTransfer里的类型,创建节点实例。
  3. 位置计算:drop时获取鼠标相对于画布的坐标,这个坐标就是节点的初始位置。

我曾经踩过一个坑:直接用了鼠标事件的clientX/clientY,结果画布有滚动条或者缩放时,节点位置全偏了。后来改成用getBoundingClientRect结合画布的transform矩阵来计算,才搞定。

代码示例(简化版):

// 画布drop事件
canvas.addEventListener('drop', function(e) {
    e.preventDefault();
    const nodeType = e.dataTransfer.getData('nodeType');
    if (!nodeType) return;

    // 获取画布相对坐标
    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.y;

    // 创建节点
    const newNode = {
        id: generateId(),
        type: nodeType,
        x: x,
        y: y,
        label: '新节点',
        properties: {}
    };

    // 添加到数据模型
    flowData.nodes.push(newNode);
    renderFlow();
});

除了基本的拖拽,还有几个体验细节:

  • 吸附对齐:拖拽节点时,当它靠近其他节点或网格线时,自动「吸」过去。这个用取模运算就能实现。
  • 连线拖拽:从节点的连接点拖出一条线,到目标节点松开。这里要判断目标点是否有效,无效就取消连线。
  • 多选拖拽:按住Ctrl键可以多选节点,然后整体拖拽。这个在调整布局时非常有用。
避坑指南: 我曾经在拖拽时直接操作DOM元素的位置,结果数据模型和视图不同步,导致保存时丢失了节点位置。记住:永远先更新数据,再重新渲染视图。数据是唯一真相来源。

3.3 节点属性配置

节点画出来了,但每个节点都有自己的「脾气」。审批节点要配置审批人、超时时间;条件节点要配置表达式;抄送节点要配置抄送人列表。这些属性怎么配?

我建议用属性面板 + 表单引擎的方案。点击某个节点,右侧弹出属性面板,面板里的表单根据节点类型动态生成。

具体做法:

  1. 为每种节点类型定义一份属性Schema,描述这个节点有哪些属性、每个属性的类型、校验规则等。
  2. 点击节点时,根据节点类型加载对应的Schema,渲染成表单。
  3. 用户修改属性后,实时更新到数据模型中的节点properties字段。

举个例子,审批节点的Schema可能是这样的:

{
    "type": "approval",
    "label": "审批节点",
    "fields": [
        {
            "key": "assignee",
            "label": "审批人",
            "type": "userPicker",  // 用户选择器
            "required": true
        },
        {
            "key": "timeout",
            "label": "超时时间(小时)",
            "type": "number",
            "default": 24,
            "min": 1,
            "max": 720
        },
        {
            "key": "allowReject",
            "label": "允许驳回",
            "type": "boolean",
            "default": true
        }
    ]
}

这样做的好处是:

  • 扩展性强:新增节点类型只需要加一份Schema,不用改代码。
  • 前后端统一:后端也可以用同一份Schema来做校验。
  • 支持复杂属性:比如「审批人」可以是一个用户选择器,也可以是一个表达式(如:部门经理)。

我记得有个项目,客户要求「审批节点」的审批人可以是动态的,比如「发起人的直属上级」。这个在属性配置里就要支持表达式编辑。我当时的做法是加一个「表达式模式」开关,打开后输入框变成代码编辑器,支持Groovy或JavaScript表达式。

核心原则: 属性配置的本质是「元数据驱动」。你定义好元数据,剩下的交给表单引擎去渲染。这样设计器才能灵活应对各种业务场景。

最后说一句,属性面板的交互也很重要。我习惯把属性分组:

  • 基础属性:节点名称、描述
  • 流程属性:审批人、超时时间、跳转条件
  • 高级属性:监听器、脚本、自定义扩展

分组后用户找配置项更方便,不会一上来就被一堆字段吓到。

好,关于流程设计器的架构、拖拽设计和属性配置,今天就聊到这儿。下一节咱们讲讲流程引擎的核心——流程实例的创建与执行,到时候会涉及状态机、令牌推进这些硬核内容。