☰
LiteFlow与LogicFlow可视化编排:从画布到EL表达式实战
2026/10/20 13:55:25 网站建设 项目流程

简介:这份资源面向Web应用开发与流程可视化配置场景,提供一套基于Liteflow规则引擎、借助LogicFlow前端流程图编辑库搭建的可视化配置页面。用户可在界面上拖拽节点、连线并定义规则,配置结果以JSON格式提交后端,由后端解析生成Liteflow可执行的EL表达式,从而把复杂业务逻辑转化为可维护的可视化流程,适合需要快速落地规则编排的中高级前端与全栈开发者参考。压缩包共22个文件,约180KB,以9个TypeScript源码、4个JSON配置、2个Vue组件及2张PNG示意图为主,另含HTML入口、图标与许可证等,结构紧凑、便于直接运行与二次开发。资源已有1497人学习下载,可帮助读者理解LogicFlow与Liteflow的前后端协同思路,掌握从图形配置到EL表达式生成的完整链路,并借鉴其目录组织与组件拆分方式。

1. 规则引擎 LiteFlow 与 LogicFlow 前端配置页:把可视化编排落到 EL 表达式

很多团队在做业务规则编排时,都会卡在同一个地方:后端用 LiteFlow 写好了组件和链路,但每次调整规则都要改 Java 代码、重新发版,运营和产品根本插不上手。我见过一个订单风控场景,光是「满减叠加优先级」这一条规则,前后改了七版,每版都要走一次完整发布流程,开发怨声载道。后来我们把 LiteFlow 的 EL 表达式生成能力,和前端可视化编排工具 LogicFlow 对接起来,做了一套「页面拖拽配置 → 生成 JSON → 后端转 EL」的链路,运营自己就能改规则,开发只负责维护组件库。这套方案的核心不是炫技,而是把「规则变更」这件事从研发流程里摘出来,交给真正懂业务的人。它适合已经有 LiteFlow 基础、又被频繁规则变更折磨的后端团队,也适合想理解「可视化编排如何落地」的前端同学。下面我按实际搭建顺序,把选型理由、数据结构、转换逻辑和踩过的坑一次讲清楚。

2. 为什么选 LogicFlow 做前端编排:节点模型与 LiteFlow 组件的映射关系

2.1 可视化编排工具的选型对比:LogicFlow 胜在哪

市面上能做流程编排的前端库不少,常见的有 X6、G6、React Flow、LogicFlow。我当初选型时列了一张对比表,重点看三件事:节点自定义成本、数据格式是否干净、社区是否还在维护。

工具自定义节点数据输出学习曲线适合场景
LogicFlow继承类,改 SVG纯 JSON中等业务规则编排
X6配置化,改 SVG纯 JSON中等通用流程图
G6配置化,Canvas纯 JSON较陡图分析、拓扑
React Flow组件化纯 JSON平缓React 技术栈

LogicFlow 的优势在于它的节点模型是「类继承」的,你可以直接继承一个 RectNode,重写setHtml或getShape,把 LiteFlow 组件需要的参数表单嵌进去。而且它输出的graphData结构非常干净,就是nodes和edges两个数组,没有多余的渲染状态,这对后端解析极其友好。X6 也能做,但它的配置项偏多,团队里前端同学上手要花更久。G6 更适合做图分析,不是流程编排。React Flow 如果团队不是 React 栈,迁移成本高。综合下来,LogicFlow 在「业务规则编排」这个细分场景里,平衡得最好。

2.2 把 LiteFlow 组件映射成 LogicFlow 节点

LiteFlow 的核心概念是「组件」和「链路」。组件是具体的执行逻辑,链路是组件的编排顺序。EL 表达式长这样:

THEN(a, b, WHEN(c, d), e);

对应到前端,我们需要把a、b、c、d、e这些组件变成画布上的节点,把THEN、WHEN这些关键字变成连线规则。我的做法是:后端提供一个组件元数据接口,返回所有可用组件的列表,每个组件包含id、name、params三个字段。前端拿到后,动态注册成 LogicFlow 的自定义节点。

// 注册 LiteFlow 组件节点 import { RectNode, RectNodeModel } from '@logicflow/core'; class LiteFlowNodeModel extends RectNodeModel { setAttributes() { this.width = 160; this.height = 60; // 根据组件类型设置不同颜色 const colorMap = { common: '#e6f7ff', condition: '#fff7e6', loop: '#f6ffed' }; this.fill = colorMap[this.properties.nodeType] || '#e6f7ff'; } } class LiteFlowNode extends RectNode { getShape() { const { model } = this.props; const { x, y, width, height, radius } = model; const style = model.getNodeStyle(); return { ...super.getShape(), // 在节点内显示组件名和参数摘要 children: [ { type: 'text', x, y: y - 8, text: model.properties.label, style: { fontSize: 14, fontWeight: 'bold' } }, { type: 'text', x, y: y + 12, text: model.properties.paramSummary || '', style: { fontSize: 12, fill: '#888' } } ] }; } } export const registerLiteFlowNode = (lf) => { lf.register({ type: 'liteflow-node', view: LiteFlowNode, model: LiteFlowNodeModel }); };

这段代码的关键点有三个。第一,setAttributes里根据nodeType设置不同填充色,让运营一眼能区分普通组件、条件组件和循环组件。第二,getShape里重写了children,在节点内部显示组件名和参数摘要,这样画布上不用点开就能看到大概逻辑。第三,register时传入的type是liteflow-node,后续生成 JSON 时,每个节点的type字段都会是这个值,后端解析时靠properties里的componentId来识别具体组件。

参数说明:width和height是节点尺寸,建议 160×60,太小放不下参数摘要,太大画布容易乱。radius是圆角,默认 4 就行。properties是 LogicFlow 节点上挂载自定义数据的字段,所有业务数据都放这里,不要污染顶层结构。

2.3 连线规则:用边上的条件表达式表达 THEN、WHEN、IF

LiteFlow 的 EL 里,THEN是串行,WHEN是并行,IF是条件分支。在 LogicFlow 里,这些语义要靠「边的类型」和「边的属性」来承载。我的做法是:定义三种边类型,serial、parallel、condition,分别对应 THEN、WHEN、IF。

// 注册自定义边 import { PolylineEdge, PolylineEdgeModel } from '@logicflow/core'; class LiteFlowEdgeModel extends PolylineEdgeModel { setAttributes() { const edgeType = this.properties.edgeType; const colorMap = { serial: '#1890ff', parallel: '#faad14', condition: '#52c41a' }; this.stroke = colorMap[edgeType] || '#1890ff'; // 条件边显示条件表达式 if (edgeType === 'condition') { this.text = this.properties.condition || '默认'; } } } class LiteFlowEdge extends PolylineEdge {} export const registerLiteFlowEdge = (lf) => { lf.register({ type: 'liteflow-edge', view: LiteFlowEdge, model: LiteFlowEdgeModel }); };

逻辑说明:setAttributes里根据edgeType设置边的颜色,条件边额外显示condition文本。这样运营在画布上就能直观看到「这条线是串行还是并行,条件是什么」。后端解析时,遍历edges数组,根据edgeType决定生成THEN、WHEN还是IF表达式。

参数说明:condition字段只在edgeType为condition时有效,内容是一个字符串,比如order.amount > 100。后端拿到后直接拼进 EL 的IF条件里。注意,条件表达式不要在前端做任何求值,只做展示和存储,求值交给后端。

3. 从画布到 JSON:节点数据结构设计与序列化

3.1 定义一套后端能直接消费的 JSON Schema

前端画布上的数据是 LogicFlow 的graphData,但直接把这个结构扔给后端,后端解析起来会很痛苦,因为里面混了渲染相关的字段。我的做法是:在前端做一层「序列化」,把graphData转成一套精简的、语义明确的 JSON Schema。

// 将 LogicFlow graphData 序列化为 LiteFlow 配置 JSON function serializeToLiteFlow(graphData) { const { nodes, edges } = graphData; // 构建节点映射,方便后续查找 const nodeMap = new Map(); nodes.forEach(node => { nodeMap.set(node.id, { id: node.id, componentId: node.properties.componentId, label: node.properties.label, params: node.properties.params || {}, nodeType: node.properties.nodeType || 'common' }); }); // 构建边映射,记录每个节点的出边和入边 const edgeMap = new Map(); edges.forEach(edge => { const source = edge.sourceNodeId; const target = edge.targetNodeId; if (!edgeMap.has(source)) { edgeMap.set(source, []); } edgeMap.get(source).push({ target, edgeType: edge.properties.edgeType || 'serial', condition: edge.properties.condition || '' }); }); // 找到起始节点(入度为 0 的节点) const inDegree = new Map(); nodes.forEach(node => inDegree.set(node.id, 0)); edges.forEach(edge => { inDegree.set(edge.targetNodeId, (inDegree.get(edge.targetNodeId) || 0) + 1); }); const startNodes = nodes.filter(node => inDegree.get(node.id) === 0); return { version: '1.0', startNodeIds: startNodes.map(n => n.id), nodes: Array.from(nodeMap.values()), edges: Array.from(edgeMap.entries()).map(([source, targets]) => ({ source, targets })) }; }

逻辑说明:这段代码做了三件事。第一,把nodes数组转成Map,只保留业务字段,去掉x、y、width、height这些渲染数据。第二,把edges按source分组,每个源节点对应一个targets数组,数组里包含目标节点 ID、边类型和条件。第三,计算入度,找到起始节点,因为 LiteFlow 的 EL 需要一个明确的入口。

参数说明:version字段用于后续 Schema 升级时的兼容处理。startNodeIds是一个数组,因为可能存在多个起始节点(并行链路的入口)。params是组件的参数对象,结构由组件元数据定义,前端只负责收集,不负责校验。

3.2 处理嵌套与分支:把画布上的「菱形」转成 IF 表达式

画布上最麻烦的是条件分支。运营画一个菱形节点,分出两条边,一条标「是」,一条标「否」。序列化时,这个菱形节点本身不产生 EL,它的两条出边才是关键。

// 处理条件分支节点的序列化 function serializeConditionNode(node, edgeMap, nodeMap) { const outEdges = edgeMap.get(node.id) || []; if (outEdges.length !== 2) { throw new Error(`条件节点 ${node.id} 必须有两个出边`); } // 区分「是」和「否」分支 const trueEdge = outEdges.find(e => e.condition === 'true'); const falseEdge = outEdges.find(e => e.condition === 'false'); if (!trueEdge || !falseEdge) { throw new Error(`条件节点 ${node.id} 的出边必须标记 true/false`); } // 递归序列化两个分支的子链路 const trueBranch = serializeBranch(trueEdge.target, edgeMap, nodeMap); const falseBranch = serializeBranch(falseEdge.target, edgeMap, nodeMap); return { type: 'IF', condition: node.properties.conditionExpression, trueBranch, falseBranch }; } // 递归序列化一条分支 function serializeBranch(startNodeId, edgeMap, nodeMap, visited = new Set()) { if (visited.has(startNodeId)) { throw new Error(`检测到循环引用:${startNodeId}`); } visited.add(startNodeId); const node = nodeMap.get(startNodeId); if (!node) return null; const outEdges = edgeMap.get(startNodeId) || []; if (outEdges.length === 0) { return { type: 'NODE', componentId: node.componentId, params: node.params }; } // 如果出边多于一条,说明是并行或分支 if (outEdges.length > 1) { const edgeTypes = outEdges.map(e => e.edgeType); if (edgeTypes.every(t => t === 'parallel')) { return { type: 'WHEN', branches: outEdges.map(e => serializeBranch(e.target, edgeMap, nodeMap, visited)) }; } // 其他情况按条件分支处理 return serializeConditionNode(node, edgeMap, nodeMap); } // 单出边,串行 const next = serializeBranch(outEdges[0].target, edgeMap, nodeMap, visited); return { type: 'THEN', steps: [ { type: 'NODE', componentId: node.componentId, params: node.params }, ...(next ? [next] : []) ] }; }

逻辑说明:serializeConditionNode要求条件节点必须有两个出边,分别标记true和false,然后递归序列化两个分支。serializeBranch是核心递归函数,它沿着边一路往下走,遇到多出边时判断是并行还是分支,遇到单出边时继续串行。visited集合用于检测循环引用,防止画布上出现环导致死循环。

参数说明:conditionExpression是条件节点上配置的表达式,比如order.amount > 100。trueBranch和falseBranch是递归序列化后的子树。注意,这里的THEN结构是嵌套的,后端生成 EL 时需要递归展开。

3.3 一个完整的序列化示例:从画布到 JSON

假设画布上有三个节点:A(普通组件)、B(条件组件)、C(普通组件)。连线是A -> B,B的true分支连C,false分支不连。序列化后的 JSON 大致如下:

{ "version": "1.0", "startNodeIds": ["node_A"], "nodes": [ { "id": "node_A", "componentId": "checkStock", "label": "检查库存", "params": {}, "nodeType": "common" }, { "id": "node_B", "componentId": "conditionNode", "label": "库存是否充足", "params": {}, "nodeType": "condition" }, { "id": "node_C", "componentId": "deductStock", "label": "扣减库存", "params": {}, "nodeType": "common" } ], "edges": [ { "source": "node_A", "targets": [{ "target": "node_B", "edgeType": "serial", "condition": "" }] }, { "source": "node_B", "targets": [ { "target": "node_C", "edgeType": "condition", "condition": "true" }, { "target": null, "edgeType": "condition", "condition": "false" } ] } ] }

后端拿到这个 JSON 后,从startNodeIds开始,递归解析edges,遇到condition边就生成IF表达式,遇到serial边就生成THEN,遇到parallel边就生成WHEN。最终生成的 EL 可能是:

THEN(checkStock, IF(conditionNode, deductStock, DEFAULT));

这里的DEFAULT是 LiteFlow 的默认占位符,表示 false 分支什么都不做。

4. 后端把 JSON 转成 EL 表达式:递归解析与字符串拼接

4.1 解析入口:从 startNodeIds 开始递归

后端拿到 JSON 后,第一步是找到入口节点,然后递归解析。我一般会写一个ElGenerator类,核心方法就一个generate。

public class ElGenerator { public String generate(LiteFlowConfig config) { StringBuilder sb = new StringBuilder(); List<String> startIds = config.getStartNodeIds(); if (startIds.size() == 1) { // 单入口,直接解析 sb.append(parseNode(startIds.get(0), config)); } else { // 多入口,用 WHEN 包裹 sb.append("WHEN("); for (int i = 0; i < startIds.size(); i++) { if (i > 0) sb.append(", "); sb.append(parseNode(startIds.get(i), config)); } sb.append(")"); } return sb.toString(); } private String parseNode(String nodeId, LiteFlowConfig config) { // 查找节点 Node node = config.findNode(nodeId); if (node == null) return "DEFAULT"; // 查找出边 EdgeGroup edgeGroup = config.findEdges(nodeId); if (edgeGroup == null || edgeGroup.getTargets().isEmpty()) { // 叶子节点,直接返回组件 ID return node.getComponentId(); } List<Target> targets = edgeGroup.getTargets(); // 判断边类型 if (targets.size() == 1) { Target target = targets.get(0); if ("serial".equals(target.getEdgeType())) { // 串行:THEN(当前节点, 下一个节点) String next = parseNode(target.getTarget(), config); return "THEN(" + node.getComponentId() + ", " + next + ")"; } else if ("condition".equals(target.getEdgeType())) { // 条件:IF(条件, 真分支, 假分支) return parseCondition(node, targets, config); } } else { // 多出边,判断是并行还是分支 boolean allParallel = targets.stream() .allMatch(t -> "parallel".equals(t.getEdgeType())); if (allParallel) { // 并行:WHEN(分支1, 分支2, ...) StringBuilder sb = new StringBuilder("WHEN("); for (int i = 0; i < targets.size(); i++) { if (i > 0) sb.append(", "); sb.append(parseNode(targets.get(i).getTarget(), config)); } sb.append(")"); return sb.toString(); } else { // 条件分支 return parseCondition(node, targets, config); } } return node.getComponentId(); } private String parseCondition(Node node, List<Target> targets, LiteFlowConfig config) { Target trueTarget = targets.stream() .filter(t -> "true".equals(t.getCondition())) .findFirst() .orElse(null); Target falseTarget = targets.stream() .filter(t -> "false".equals(t.getCondition())) .findFirst() .orElse(null); String trueBranch = trueTarget != null && trueTarget.getTarget() != null ? parseNode(trueTarget.getTarget(), config) : "DEFAULT"; String falseBranch = falseTarget != null && falseTarget.getTarget() != null ? parseNode(falseTarget.getTarget(), config) : "DEFAULT"; return "IF(" + node.getComponentId() + ", " + trueBranch + ", " + falseBranch + ")"; } }

逻辑说明:generate是入口,处理单入口和多入口两种情况。parseNode是核心递归方法,先查找节点和出边,然后根据出边数量和类型决定生成THEN、WHEN还是IF。parseCondition专门处理条件分支,找到true和false两条边,分别递归解析。

参数说明:LiteFlowConfig是反序列化后的配置对象,包含nodes和edges。Node和EdgeGroup是内部类,分别表示节点和边组。DEFAULT是 LiteFlow 的默认占位符,表示空操作。

4.2 处理嵌套 THEN:避免生成过长的表达式

LiteFlow 的 EL 对嵌套深度没有硬性限制,但实际运行时,过长的表达式会影响解析性能。我一般会在生成时做一层「扁平化」:如果THEN里面嵌套THEN,就把它们合并成一个THEN。

private String flattenThen(String expression) { // 简单处理:如果 THEN 的第一个参数是 THEN,则合并 // 实际项目中建议用 AST 解析,这里用正则做演示 Pattern pattern = Pattern.compile("THEN\\(THEN\\(([^)]+)\\), (.+)\\)"); Matcher matcher = pattern.matcher(expression); if (matcher.matches()) { return "THEN(" + matcher.group(1) + ", " + matcher.group(2) + ")"; } return expression; }

逻辑说明:这段代码用正则匹配THEN(THEN(...), ...)的结构,把内层THEN的参数提出来,和外层合并。实际项目中,建议用 ANTLR 或 JavaCC 生成 AST 后再做扁平化,正则只能处理简单情况。

参数说明:expression是生成的 EL 字符串。pattern匹配嵌套THEN,matcher.group(1)是内层参数,matcher.group(2)是外层剩余参数。

4.3 校验与容错:生成前先跑一遍语法检查

生成的 EL 如果语法有问题,LiteFlow 启动时会报错,但报错信息往往不直观。我的做法是:在生成后,用一个简单的语法检查器先跑一遍,把常见错误提前暴露。

public class ElValidator { public static void validate(String el) { // 检查括号是否匹配 int balance = 0; for (char c : el.toCharArray()) { if (c == '(') balance++; if (c == ')') balance--; if (balance < 0) { throw new IllegalArgumentException("括号不匹配:" + el); } } if (balance != 0) { throw new IllegalArgumentException("括号不匹配:" + el); } // 检查是否包含空节点 if (el.contains("THEN()") || el.contains("WHEN()")) { throw new IllegalArgumentException("存在空节点:" + el); } // 检查 IF 是否三个参数 Pattern ifPattern = Pattern.compile("IF\\(([^,]+),([^,]+),([^)]+)\\)"); Matcher matcher = ifPattern.matcher(el); while (matcher.find()) { if (matcher.group(1).trim().isEmpty() || matcher.group(2).trim().isEmpty() || matcher.group(3).trim().isEmpty()) { throw new IllegalArgumentException("IF 参数不完整:" + el); } } } }

逻辑说明:validate做了三层检查。第一层检查括号匹配,防止生成时漏掉右括号。第二层检查空节点,防止THEN()这种无效表达式。第三层检查IF的三个参数是否完整,防止条件分支缺失。

参数说明:el是待校验的 EL 字符串。校验失败时抛出IllegalArgumentException,调用方捕获后返回给前端,提示用户修正画布。

5. 避坑与常见问题:血泪经验换来的五条排查清单

5.1 节点 ID 重复导致边连接错乱

现象:画布上拖了两个相同组件,生成的 JSON 里两个节点 ID 一样,后端解析时边连到了错误的节点上。

原因:LogicFlow 默认用时间戳生成节点 ID,但如果复制粘贴节点,ID 可能重复。或者前端手动设置 ID 时没有做唯一性校验。

解决:在node:add事件里强制重写 ID,用uuid或nanoid生成。同时在后端解析时,如果发现重复 ID,直接抛异常,不要静默处理。

lf.on('node:add', ({ data }) => { if (!data.id || nodeIdSet.has(data.id)) { data.id = `node_${nanoid(8)}`; } nodeIdSet.add(data.id); });

5.2 条件边的 true/false 标记丢失

现象:运营画了条件分支,但生成 JSON 时condition字段是空的,后端无法区分真假分支。

原因:LogicFlow 的边属性默认不包含condition,需要在edge:add时手动设置,或者在边的右键菜单里让用户选择。

解决:在edge:add事件里,如果源节点是条件节点,强制弹出选择框,让用户选true或false。同时在前端做校验,条件节点的出边必须有一条true和一条false。

lf.on('edge:add', ({ data }) => { const sourceNode = lf.getNodeModelById(data.sourceNodeId); if (sourceNode?.properties.nodeType === 'condition') { // 弹出选择框,设置 condition data.properties.condition = 'true'; // 或 'false' } });

5.3 并行分支的 WHEN 嵌套层级过深

现象:生成的 EL 里WHEN嵌套了五六层,LiteFlow 启动时报栈溢出。

原因:画布上并行分支没有做扁平化,每个并行节点都生成一层WHEN。

解决:在序列化时,如果发现连续的WHEN,合并成一个。后端生成 EL 时,也做一层扁平化处理。另外,建议在画布上限制并行分支的嵌套深度,超过三层就提示用户拆分链路。

5.4 前端参数表单与后端组件参数不匹配

现象:运营在页面上填了参数,生成的 JSON 里params字段有值,但后端组件执行时报「参数缺失」。

原因:前端表单的字段名和后端组件定义的参数名不一致,比如前端用orderId,后端用order_id。

解决:后端提供组件元数据时,明确每个参数的name、type、required。前端根据元数据动态生成表单,字段名直接用后端的name。不要在前端做任何字段名转换。

5.5 画布上的环导致递归死循环

现象:运营不小心把节点连成了一个环,点击「生成 JSON」时页面卡死。

原因:序列化函数没有做环检测,递归时无限循环。

解决:在serializeBranch里加visited集合,每次递归前检查节点是否已访问。如果已访问,抛出异常并提示用户「检测到循环引用,请检查连线」。同时,在画布上禁用「连接到已访问节点」的操作,从源头防止环的产生。

6. 进阶技巧:用版本快照和回滚兜住规则变更的风险

规则配置页面最怕的不是配错,而是配错了之后没法快速回滚。我在这套方案里加了一个「版本快照」机制:每次点击「发布」时,把当前的 JSON 和生成的 EL 一起存到数据库,同时记录操作人和时间戳。运营在页面上可以查看历史版本,一键回滚到任意版本。

实现上,前端在发布时调用一个POST /api/rule/publish接口,后端做三件事:第一,校验 EL 语法;第二,把 JSON 和 EL 存入rule_version表;第三,更新当前生效版本。回滚时,调用POST /api/rule/rollback,传入版本号,后端把对应版本的 EL 重新加载到 LiteFlow 里。

CREATE TABLE rule_version ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_id VARCHAR(64) NOT NULL, version INT NOT NULL, config_json TEXT NOT NULL, el_expression TEXT NOT NULL, operator VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_rule_version (rule_id, version) );

这张表的关键是uk_rule_version唯一索引,保证同一个规则下版本号不重复。config_json存前端序列化后的 JSON,el_expression存生成的 EL。回滚时,根据rule_id和version查出记录,把el_expression重新注册到 LiteFlow 的FlowBus里。

还有一个技巧是「灰度发布」:新版本先只对部分请求生效,观察一段时间没问题后再全量。实现方式是在 EL 外面包一层IF,条件是一个灰度开关,比如IF(graySwitch, newChain, oldChain)。这样即使新规则有问题,也能快速切回旧规则,不用重新发布。

我自己的习惯是:每次改规则之前,先手动创建一个快照,备注写清楚改了什么。改完之后,在测试环境跑一遍全量用例,确认没问题再发布到生产。这套流程看起来麻烦,但比起半夜被叫起来回滚,还是值得的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询