
简介这份资源面向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、IFLiteFlow 的 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 - BB的true分支连Cfalse分支不连。序列化后的 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(); ListString 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(); } ListTarget 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, ListTarget 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匹配嵌套THENmatcher.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存前端序列化后的 JSONel_expression存生成的 EL。回滚时根据rule_id和version查出记录把el_expression重新注册到 LiteFlow 的FlowBus里。还有一个技巧是「灰度发布」新版本先只对部分请求生效观察一段时间没问题后再全量。实现方式是在 EL 外面包一层IF条件是一个灰度开关比如IF(graySwitch, newChain, oldChain)。这样即使新规则有问题也能快速切回旧规则不用重新发布。我自己的习惯是每次改规则之前先手动创建一个快照备注写清楚改了什么。改完之后在测试环境跑一遍全量用例确认没问题再发布到生产。这套流程看起来麻烦但比起半夜被叫起来回滚还是值得的。希望帮到你。本文还有配套的精品资源点击获取