LogicFlow节点连线规则实战:如何用sourceRules和targetRules实现精准控制
最近在重构一个内部流程设计器时,我遇到了一个典型问题:用户总是能随意连接节点,导致生成大量无效甚至错误的流程图。比如,把“结束”节点连到“开始”节点,或者在审批节点后面直接接上另一个审批节点。这种自由度过高的设计,表面上给了用户灵活性,实际上却成了数据混乱和逻辑错误的源头。对于需要严谨流程的业务场景——比如工单流转、BPMN建模或者低代码平台的逻辑编排——我们必须对节点间的连接施加精确的控制。
LogicFlow作为一款专注于流程图绘制的开源库,其强大之处不仅在于基础的绘制能力,更在于它提供了一套声明式、可编程的规则引擎,允许开发者深入到“连接”这一原子操作层面进行干预。sourceRules和targetRules正是这套引擎的核心。它们不是简单的“允许”或“禁止”开关,而是可以承载复杂业务逻辑的校验函数。掌握它们,意味着你能将业务规则无缝嵌入到可视化交互中,让画布本身成为一道坚固的校验防线。
这篇文章,我将抛开官方文档的平铺直叙,结合几个真实的项目案例,带你深入理解如何运用sourceRules和targetRules,以及与之紧密相关的锚点(Anchor)定制,来构建一个既智能又健壮的流程设计器。我们会从基础规则实现,讲到基于锚点的精细化控制,最后探讨如何结合状态反馈提升用户体验。无论你是在构建OA审批流、状态机设计器,还是复杂的业务编排工具,这里的内容都能提供直接的参考。
1. 理解规则引擎:从“能不能连”到“按什么规则连”
在LogicFlow的世界里,一条边(Edge)的诞生,始于鼠标在源节点上的按下,终于在目标节点上的松开。在这两个关键时刻,sourceRules和targetRules分别扮演着守门员的角色。
sourceRules:当连线操作从一个节点开始时触发。它校验的是“我(源节点)允许连接到哪些节点?”。targetRules:当连线试图连接到一个节点时触发。它校验的是“我(目标节点)允许接受来自哪些节点的连接?”。
两者的校验是且的关系。一条连线要最终建立,必须同时通过源节点的sourceRules和目标节点的targetRules。这种设计将控制权分散到各个节点模型,非常符合面向对象的设计思想,也让规则的管理和维护变得清晰。
1.1 基础规则实现:让开始节点只能出不能进
让我们从一个最简单的场景开始:一个流程必须有且仅有一个开始节点,并且这个开始节点不能被任何其他节点连接(即只有出边,没有入边)。
首先,我们需要自定义一个开始节点。通常,我们会继承CircleNode或RectNode,但核心逻辑在对应的Model类中。
import { CircleNode, CircleNodeModel } from '@logicflow/core'; // 视图部分,这里我们简单使用内置圆形 class StartNodeView extends CircleNode {} // 模型部分,在这里定义规则 class StartNodeModel extends CircleNodeModel { initNodeData(data) { super.initNodeData(data); // 设置节点基础属性 this.text = data.text || '开始'; this.width = 60; this.height = 60; this.style.fill = '#FF6B6B'; // 用红色区分 // 定义作为目标节点时的规则:禁止任何连接进入 const noIncomingRule = { message: '开始节点不能作为目标节点(不能有入边)', validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => { // 此规则在StartNode作为target时执行,直接返回false表示禁止连接 return false; }, }; this.targetRules.push(noIncomingRule); // 定义作为源节点时的规则:可以连接到任何类型节点(或按需限制) const outgoingRule = { message: '开始节点只能连接到任务或网关节点', validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => { const allowedTypes = ['task-node', 'gateway-node']; return allowedTypes.includes(targetNode.type); }, }; this.sourceRules.push(outgoingRule); } } // 导出节点配置 export default { type: 'start-node', // 节点类型标识符 view: StartNodeView, model: StartNodeModel, };在上面的代码中,validate函数是关键。它接收四个参数,提供了丰富的上下文信息:
| 参数 | 类型 | 描述 |
|---|---|---|
sourceNode | BaseNodeModel | 连线起始的节点模型实例 |
targetNode | BaseNodeModel | 连线目标的节点模型实例 |
sourceAnchor | AnchorConfig | 起始节点上被点击的锚点对象 |
targetAnchor | AnchorConfig | 目标节点上悬停的锚点对象 |
提示:
validate函数应保持纯净,避免副作用。它的唯一职责是根据输入参数返回一个布尔值。复杂的业务逻辑判断(如调用API)应在此函数外部处理,并将结果以节点属性或外部状态的形式提供给validate函数使用。
1.2 组合规则:实现复杂的流程拓扑约束
单一规则往往不够。例如,在一个审批流中,我们可能要求:“会签网关”后面必须接“用户任务”,而“用户任务”后面只能接“排他网关”或“结束事件”。这就需要为不同类型的节点配置不同的规则集。
假设我们有三种节点:user-task(用户任务),parallel-gateway(并行网关),exclusive-gateway(排他网关)。规则如下:
parallel-gateway的出口只能连接user-task。user-task的出口只能连接exclusive-gateway。exclusive-gateway的出口可以连接user-task或end-event。
我们可以为每种节点定义其Model:
// ParallelGatewayModel 示例 class ParallelGatewayModel extends PolygonNodeModel { initNodeData(data) { super.initNodeData(data); // ... 样式设置 this.sourceRules.push({ message: '并行网关后必须为用户任务', validate: (source, target) => target.type === 'user-task' }); // 并行网关允许被任何节点连接(如果需要限制,可添加targetRules) } } // UserTaskModel 示例 class UserTaskModel extends RectNodeModel { initNodeData(data) { super.initNodeData(data); // ... 样式设置 this.sourceRules.push({ message: '用户任务后必须为排他网关', validate: (source, target) => target.type === 'exclusive-gateway' }); this.targetRules.push({ message: '用户任务前必须为并行网关或排他网关', validate: (source, target) => ['parallel-gateway', 'exclusive-gateway'].includes(source.type) }); } }通过为每个节点类型精确配置sourceRules和targetRules,我们就在画布层面构建了一个强类型的流程语法。用户无法进行非法连接,从源头保证了流程图的正确性。
2. 超越节点类型:利用锚点实现端口级控制
只控制节点类型的连接有时粒度太粗。比如,一个数据库表节点,我们可能希望它的每个字段(对应一个锚点)只能连接到另一个表的特定字段上。这时,就需要用到锚点(Anchor)和更精细的规则校验。
LogicFlow允许我们完全自定义节点的锚点位置、数量、外观和属性。结合sourceAnchor和targetAnchor参数,我们可以实现端口到端口的连接控制。
2.1 自定义锚点与属性绑定
让我们创建一个简单的“输入/输出”节点,它左侧有一排输入锚点,右侧有一排输出锚点,并且规则是:只能从输出锚点连出,只能连接到输入锚点。
import { RectNode, RectNodeModel } from '@logicflow/core'; class IoNodeView extends RectNode {} class IoNodeModel extends RectNodeModel { setAttributes() { this.width = 120; this.height = 80; this.style.fill = '#E3F2FD'; this.style.stroke = '#2196F3'; // 规则1:作为源节点时,只允许从类型为'output'的锚点连出 this.sourceRules.push({ message: '只能从右侧输出端口连线', validate: (source, target, sourceAnchor) => { return sourceAnchor && sourceAnchor.type === 'output'; } }); // 规则2:作为目标节点时,只允许连接到类型为'input'的锚点 this.targetRules.push({ message: '只能连接到左侧输入端口', validate: (source, target, sourceAnchor, targetAnchor) => { return targetAnchor && targetAnchor.type === 'input'; } }); } // 重写此方法以定义自定义锚点 getDefaultAnchor() { const { x, y, width, height } = this; const anchors = []; // 左侧三个输入锚点 for (let i = 0; i < 3; i++) { anchors.push({ x: x - width / 2, // 左侧边 y: y - height / 2 + (i + 1) * (height / 4), // 垂直均匀分布 id: `input_${i}`, type: 'input' // 自定义属性 }); } // 右侧三个输出锚点 for (let i = 0; i < 3; i++) { anchors.push({ x: x + width / 2, // 右侧边 y: y - height / 2 + (i + 1) * (height / 4), id: `output_${i}`, type: 'output' // 自定义属性 }); } return anchors; } }在这个例子中,我们通过在getDefaultAnchor返回的锚点对象上添加自定义的type属性(input/output),然后在validate函数中通过sourceAnchor.type和targetAnchor.type来访问它。这样,规则就从节点级别细化到了端口级别。
2.2 动态锚点与上下文感知
更高级的场景是锚点本身需要根据节点状态或属性动态显示或隐藏。例如,一个“条件分支”节点,只有在满足某些条件时才显示额外的输出端口。
LogicFlow的节点模型提供了isSelected、isHovered等状态,以及自定义的properties。我们可以在getDefaultAnchor方法中根据这些状态动态计算锚点数组。
class ConditionalNodeModel extends RectNodeModel { getDefaultAnchor() { const anchors = []; const { x, y, width, height, properties } = this; // 始终有一个默认输出 anchors.push({ x: x + width / 2, y: y, id: 'default_output', type: 'output' }); // 仅当节点属性中标记了需要“异常出口”时,才显示第二个输出锚点 if (properties.hasExceptionExit) { anchors.push({ x: x + width / 2, y: y + height / 4, id: 'exception_output', type: 'exception_output' }); } return anchors; } // 当属性变化时,可能需要更新锚点(触发重绘) updateAttributes(attr) { super.updateAttributes(attr); // 强制刷新锚点(某些版本可能需要调用特定方法或触发事件) this.forceRefreshAnchor(); } }注意:动态改变锚点后,需要确保LogicFlow能正确重绘节点和相关的边。有时需要手动调用
lf.graphModel.refresh或对节点实例进行更新操作。同时,要处理好已有连线在锚点消失时的行为(是断开还是保持)。
3. 提供可视化反馈:从静默拒绝到明确指引
仅仅在连接失败时在控制台抛出一个错误,或者静默地阻止连接,对用户来说体验是不够友好的。好的设计器应该能提供即时的、可视化的反馈,告诉用户“为什么不能连”。
3.1 监听连接拒绝事件
LogicFlow在连接被规则拒绝时,会触发connection:not-allowed事件。这是我们向用户提供反馈的第一个入口点。
// 在初始化LogicFlow实例后 lf.on('connection:not-allowed', ({ data, msg }) => { // data 包含更详细的错误信息,如 sourceNodeId, targetNodeId // msg 是我们在规则中定义的 message 字符串 console.warn('连接被禁止:', msg); // 方式1:使用浏览器原生提示(简单粗暴,可能干扰体验) // alert(`无法连接: ${msg}`); // 方式2:在UI上显示一个自定义的Toast或提示框(推荐) showToast(`操作受限: ${msg}`, 'warning'); // 方式3:高亮相关的源节点或目标节点 // lf.graphModel.getNodeModelById(data.sourceNodeId).setState('not-allowed', true); });3.2 利用节点状态进行样式反馈
除了弹出提示,我们还可以直接改变节点的视觉状态。LogicFlow节点模型有一个state属性,当连接校验失败时,相关的节点(源节点或目标节点)的state会被设置为5(NODE_STATE.NOT_ALLOWED)。我们可以重写getNodeStyle方法来响应这个状态。
class FeedbackNodeModel extends RectNodeModel { getNodeStyle() { const style = super.getNodeStyle(); const baseStyle = { fill: '#FFFFFF', stroke: '#333333', strokeWidth: 1, }; // 合并基础样式 Object.assign(style, baseStyle); // 根据状态覆盖样式 if (this.state === 5) { // 连接不被允许的状态 style.stroke = '#FF4757'; // 红色边框 style.strokeWidth = 3; style.strokeDasharray = '5,5'; // 虚线边框 style.fill = '#FFE5E7'; // 浅红色填充 } else if (this.isHovered) { style.stroke = '#1E90FF'; style.strokeWidth = 2; } else if (this.isSelected) { style.stroke = '#FFA502'; style.strokeWidth = 2; } return style; } }这样,当用户试图进行一个非法连接时,相关的节点会立即呈现错误状态(比如红色虚线高亮),松开鼠标后恢复。这种即时反馈比弹窗更直观,干扰也更小。
3.3 预验证与悬停反馈
更进一步,我们可以在鼠标拖拽连线、悬停在潜在目标节点上时,就进行预验证并给出反馈。这需要结合mouseover事件和规则的提前校验。
一种实现思路是监听边的拖拽事件,在targetNode变化时,手动调用规则校验函数,并提前更新节点状态。
let currentDragEdge = null; lf.on('edge:add', ({ data }) => { // 开始创建边时,记录当前边信息(可能需要自定义边视图来捕获更早的事件) }); // 监听画布鼠标移动,计算当前悬停的节点 lf.on('blank:mousemove', ({ e }) => { if (!currentDragEdge) return; const point = lf.getPointByClient(e.clientX, e.clientY); const node = lf.getNodeModelById(lf.getNodeInPoint(point)); // 伪代码,需根据坐标查找节点 if (node && node !== lastHoveredNode) { // 对 lastHoveredNode 清除状态 // 对当前 node 进行预校验 const isAllowed = validateConnection(currentDragEdge.sourceNode, node); node.setState('preview', isAllowed ? 'allowed' : 'not-allowed'); lastHoveredNode = node; } }); // 一个简单的校验函数,模拟LogicFlow内部的规则检查 function validateConnection(sourceNodeModel, targetNodeModel) { // 这里需要遍历 sourceNodeModel.sourceRules 和 targetNodeModel.targetRules // 这是一个简化示例,实际逻辑更复杂 for (const rule of sourceNodeModel.sourceRules) { if (!rule.validate(sourceNodeModel, targetNodeModel, null, null)) { return false; } } for (const rule of targetNodeModel.targetRules) { if (!rule.validate(sourceNodeModel, targetNodeModel, null, null)) { return false; } } return true; }实现完整的预验证悬停反馈需要较多工作,因为它触及了LogicFlow内部的事件和状态管理机制。但对于追求极致用户体验的专业工具,这项投入是值得的。
4. 实战:构建一个表单审批流程设计器
让我们综合运用以上知识,设计一个简化的表单审批流程设计器。这个设计器包含以下节点类型:
- 开始事件 (StartEvent):只能出,不能入。
- 用户任务 (UserTask):代表一个审批环节。入边可以来自开始事件、其他用户任务(串行)或网关;出边只能指向网关或结束事件。
- 并行网关 (ParallelGateway):用于发起会签。入边来自用户任务;出边指向多个用户任务。
- 排他网关 (ExclusiveGateway):用于条件分支。入边来自用户任务;出边指向多个用户任务(每个出口可配置条件)。
- 结束事件 (EndEvent):只能入,不能出。
4.1 定义节点模型与规则
我们将为每种节点创建独立的模型文件。这里以UserTaskModel为例展示其规则配置的复杂性。
// UserTaskModel.js class UserTaskModel extends RectNodeModel { initNodeData(data) { super.initNodeData(data); this.properties = data.properties || { assignee: '', formKey: '' }; this.style.fill = '#D4EDDA'; this.style.stroke = '#C3E6CB'; // 作为目标节点:允许被哪些节点连接? this.targetRules.push( { message: '用户任务的前置节点只能是开始事件、用户任务或网关', validate: (source) => ['start-event', 'user-task', 'parallel-gateway', 'exclusive-gateway'].includes(source.type) }, { message: '并行网关后必须连接至少两个用户任务(此规则更适用于网关的sourceRules)', validate: (source, target, sourceAnchor) => { // 这是一个更复杂的业务规则示例:如果来源是并行网关,检查当前是否已有足够出边 if (source.type === 'parallel-gateway') { const outgoingEdges = source.outgoing.edges; // 简化逻辑:假设并行网关必须至少连接两个任务 // 实际中,可能需要更复杂的图遍历来判断 return outgoingEdges.length < 4; // 假设最多支持4个分支 } return true; } } ); // 作为源节点:允许连接到哪些节点? this.sourceRules.push({ message: '用户任务的后置节点只能是排他网关、并行网关或结束事件', validate: (source, target) => ['exclusive-gateway', 'parallel-gateway', 'end-event'].includes(target.type) }); } // 可以重写getTextStyle等方法自定义外观 }4.2 实现基于角色/部门的连接限制
在实际审批流中,连接规则可能不仅取决于节点类型,还取决于节点所承载的业务数据。例如,“部门经理审批”节点后面,只能连接“总监审批”或“财务审批”节点,而不能连接另一个“部门经理审批”。
这需要我们在节点属性(properties)中存储业务信息,并在validate函数中访问这些信息。
// 在UserTaskModel的targetRules中添加一条基于角色的规则 this.targetRules.push({ message: '同级审批角色不能相互连接', validate: (source, target) => { // 假设节点属性中有 role 字段,如 'dept_manager', 'director', 'finance' const sourceRole = source.properties?.role; const targetRole = this.properties?.role; // 如果目标节点是用户任务,且角色相同,则禁止连接 if (source.type === 'user-task' && targetRole === sourceRole) { return false; } // 其他情况允许 return true; } });4.3 序列化与规则的一致性维护
当用户保存流程图时,我们得到的是节点和边的JSON数据。这些数据本身并不包含规则。因此,当在另一个会话中加载此图时,必须确保注册的节点模型和规则与保存时一致。一个最佳实践是将节点类型(type字符串)与具体的模型类进行强绑定,并通过一个中心化的注册表或依赖注入来管理,避免在代码各处散落lf.register调用。
此外,对于极其复杂的规则(例如需要查询组织架构API来判断是否允许连接),validate函数可能不适合执行异步操作。在这种情况下,可以考虑采用“预验证+后校验”的模式:前端进行基本的类型和属性校验,在后端保存流程时再进行一次完整的、基于最新业务数据的规则校验,并返回详细的错误信息。
5. 避坑指南与性能考量
在项目中使用sourceRules和targetRules时,我踩过一些坑,也总结了一些优化经验。
1. 规则函数的性能validate函数在拖拽连线过程中会被高频调用。务必保持函数轻量,避免在内部进行复杂的计算、DOM操作或同步的API调用。如果需要用到复杂数据,尽量在节点初始化时计算好并缓存。
2. 规则与锚点ID的唯一性自定义锚点时,id必须是唯一的,通常结合节点ID和锚点索引来生成(如${nodeId}_anchor_${index})。如果ID重复,会导致连线定位和规则校验出现不可预知的问题。
3. 动态规则更新如果节点的连接规则需要根据其属性动态变化(例如,勾选一个复选框后允许连接更多类型的节点),在修改属性后,需要触发节点的更新,以确保规则被重新计算和应用。有时需要调用lf.graphModel.updateNode或类似方法。
4. 规则冲突与优先级当一个节点拥有多条sourceRules或targetRules时,所有规则必须全部通过,连接才被允许。要小心规则之间是否存在逻辑冲突。建议为规则编写清晰的message,便于调试。对于复杂的业务逻辑,可以考虑将多条规则合并为一条,在validate函数内部进行分支判断,以提高可读性和可控性。
5. 与“移动规则(moveRules)”的区分LogicFlow还有moveRules用于控制节点移动。不要将连接规则和移动规则混淆。它们虽然机制相似,但应用的场景完全不同。
最后,记住sourceRules和targetRules是构建自描述、自校验流程图的核心工具。它们将业务逻辑从后端校验部分地前移到了交互层面,让错误在发生的那一刻就被发现和阻止。结合精心设计的可视化反馈,你能打造出既专业又用户友好的图形化配置体验。在实际项目中,我从最初只控制节点类型,到后来基于锚点和业务属性实现字段级的连接控制,这套机制始终稳定而强大。花时间设计好规则,远比在用户提交错误数据后再去报错要划算得多。