给规则引擎加一个拖拽画布:Vue Flow 单向投影 + 布局语义分离

给规则引擎加一个拖拽画布:Vue Flow 单向投影 + 布局语义分离

这是《从零做一个可视化规则引擎》的续篇。上一篇讲表单式条件树,这篇讲怎么在不动既有代码的前提下,加一个拖拽式画布编辑器。含 9 张实现截图。

上一篇里,我们把规则的编写权从开发手里交还给了业务人员------但交付形态是表单:一层层点「+ 条件」、在下拉里选字段选操作符。能用,但不够直觉。

真实场景是这样的:一个老电工师傅看「严重告警立即升级」这条规则,他脑子里浮现的是一张图------最上面一个「全部满足」,下面分出三条线,两条并到一个「任一满足」里,第三条挂个「取反」。表单的缩进列表对他来说,是把图拍扁了。

所以这一篇讲的是:怎么给同一套规则模型,再套一层拖拽画布。

效果长这样:

左边是规则列表,中间是当前规则的条件画布,右边是选中节点的属性面板。拖拽组件面板里的「AND 组 / OR 组 / NOT 组 / 叶子条件」到画布上,规则就长出来了。

技术栈不变(Vue 3 + TS + Vite + Pinia + Element Plus),画布库选了 @vue-flow/core。

这篇文章不讲 Vue Flow 的 API 怎么用(文档够全了),只讲把它接进一个已有规则引擎时,我真正卡住的地方和最后的选择。


目录

  • 一、第一个决策:画布到底是真相,还是投影?
  • 二、一张画布只画一条规则
  • [三、投影函数:树 → 节点和边](#三、投影函数:树 → 节点和边 "#%E4%B8%89%E6%8A%95%E5%BD%B1%E5%87%BD%E6%95%B0%E6%A0%91--%E8%8A%82%E7%82%B9%E5%92%8C%E8%BE%B9")
  • [四、坐标不能进 JSON:布局与语义分离](#四、坐标不能进 JSON:布局与语义分离 "#%E5%9B%9B%E5%9D%90%E6%A0%87%E4%B8%8D%E8%83%BD%E8%BF%9B-json%E5%B8%83%E5%B1%80%E4%B8%8E%E8%AF%AD%E4%B9%89%E5%88%86%E7%A6%BB")
  • [五、拖拽的两种含义:改坐标 vs 改父级](#五、拖拽的两种含义:改坐标 vs 改父级 "#%E4%BA%94%E6%8B%96%E6%8B%BD%E7%9A%84%E4%B8%A4%E7%A7%8D%E5%90%AB%E4%B9%89%E6%94%B9%E5%9D%90%E6%A0%87-vs-%E6%94%B9%E7%88%B6%E7%BA%A7")
  • 六、放置校验做成纯函数
  • [七、NOT 组的自动包裹,画布也要有](#七、NOT 组的自动包裹,画布也要有 "#%E4%B8%83not-%E7%BB%84%E7%9A%84%E8%87%AA%E5%8A%A8%E5%8C%85%E8%A3%B9%E7%94%BB%E5%B8%83%E4%B9%9F%E8%A6%81%E6%9C%89")
  • 八、复用表单组件,而不是重写一套
  • 九、几处工程细节
  • 十、小结

一、第一个决策:画布到底是真相,还是投影?

这是整个改造里最重要的一步,选错了后面全在填坑。

画布编辑器有个天然的诱惑:既然节点能拖、能连线,那画布上的图本身就是真相,规则就等于这张图,需要 JSON 的时候从图序列化出来就行。

听起来很顺,但对这个项目是灾难。因为:

  • 表单页已经存在,它读写的是 RuleSet 树模型;
  • 本地求值、远程对比、JSON 预览,全都基于那棵树;
  • 如果画布自成一套真相,就等于有了两个真相源,任何一处改动都可能让两边对不上。

所以我们定死了:

ruleSet 永远是唯一的真相。画布只是它的一张投影。

整条数据流是单向的:

text 复制代码
   ruleSet(唯一真相)
       │  project(rule, layout) → { nodes, edges }
       ▼
   Vue Flow 画布(只负责显示)
       │  拖拽 → store action → 改 ruleSet
       ▼
   ruleSet 变化 → 重新 project

这个模式我叫它单向投影 + 命令式写回。关键在于「投影是纯函数」------给它一棵树和一份坐标,它吐出节点和边,没有任何副作用。这样画布就是可丢弃的:把它整个删掉,规则数据毫发无损。

一旦接受了这个设定,后面所有问题都有了统一的答案:画布上发生的任何事,最终都要翻译成一个对 ruleSet 的操作。


二、一张画布只画一条规则

第一版我犯了个错:把整份 RuleSet.rules(可能十几条规则)全投影到同一张画布上。

结果很尴尬------满屏节点,规则之间还要用「规则容器节点」隔开,用户拖一个节点之前得先想清楚「我这是在改哪条规则」。而且规则级字段(名字、优先级、是否启用)藏在画布的某个容器节点里,不点它还改不了。

推倒重来,改成 Master-Detail :左边列表选规则,中间画布只画当前这一条。

规则的身份由「列表 + 规则头」表达,画布上干脆不渲染规则容器节点了------条件树的根组直接挂在画布中央。

这样有两个好处:一是画布永远清爽,二是彻底消除了「拖规则节点」的语义歧义(规则不是能拖来拖去的节点,它是容器)。

代价是:规则级字段必须由规则头完备提供,不能依赖画布选中。我们把名称、优先级、启用开关、命中事件全放进规则头,复用表单页已有的 RuleEventEditor。

当前选中哪条规则,存在一个画布专用的小 store 里:

ts 复制代码
// stores/canvasWorkspace.ts
// 进入画布时:优先恢复上次选中的规则 id(∩ 仍存在的),否则取第一条
function resolveActiveRuleId(ruleSet, remembered) {
  if (remembered && ruleSet.rules.some(r => r.id === remembered)) return remembered
  return ruleSet.rules[0]?.id ?? null
}

这个 activeRuleId 用 sessionStorage 记住,键里带 ruleSet.id。刷新页面回到同一条规则,切到表单页再切回来也不丢。


三、投影函数:树 → 节点和边

投影函数是画布的心脏,写在 utils/canvasFlow.ts,纯函数,好测。

它接收一条规则和一份坐标表,递归走条件树,产出 Vue Flow 需要的 { nodes, edges }:

ts 复制代码
export function projectRuleToFlow(rule, layout) {
  const nodes = []
  const edges = []

  // 条件树的根组直接作为画布根,不再包 rule: 容器节点
  pushGroupNode(root, nodes, edges, layout)
  return { nodes, edges }
}

function pushGroupNode(group, nodes, edges, layout) {
  nodes.push({
    id: group.id,
    type: 'group',           // 对应 FlowGroupNode 组件
    position: layout[group.id] ?? autoPosition(group),
    data: { logic: group.logic, label: LOGIC_LABEL[group.logic] },
  })

  for (const child of group.children) {
    const isGroup = 'logic' in child
    nodes.push({ id: child.id, type: isGroup ? 'group' : 'leaf', ... })
    edges.push({ id: `${group.id}->${child.id}`, source: group.id, target: child.id })
    if (isGroup) pushGroupNode(child, nodes, edges, layout)
  }
}

几个要点:

① 边的方向就是父子关系。 用户不能 自己连线------nodes-connectable 直接关掉。所有结构变更都必须走「拖放到组上」这条路径,从根上杜绝画出非法图(比如一个节点挂两个爹)。

② 颜色即语义。 FlowGroupNode 按 logic 换色:AND 靛蓝、OR 琥珀、NOT 玫瑰红,一眼能认出分组类型。

根节点是 AND 全部 ,左分一条 alarmLevel equal,中间 OR 任一 挂着 alarmValue greaterThan 和 duration greaterThanInclusive,右边 NOT 取反 下面挂 isTestDevice equal:

(把根组选中后,它就是开头那张图里中央的树------注意它和下面这份 JSON 是严格一一对应的。)

jsonc 复制代码
{
  "all": [
    { "fact": "alarmLevel", "operator": "equal", "value": "严重" },
    { "any": [
        { "fact": "alarmValue", "operator": "greaterThan", "value": 50 },
        { "fact": "duration",   "operator": "greaterThanInclusive", "value": 60 }
    ]},
    { "not": { "fact": "isTestDevice", "operator": "equal", "value": true } }
  ]
}

③ 组件用 markRaw 包。 Vue Flow 的 nodeTypes 如果传响应式对象,会触发无限重渲染警告:

ts 复制代码
const nodeTypes = { group: markRaw(FlowGroupNode), leaf: markRaw(FlowLeafNode) }

四、坐标不能进 JSON:布局与语义分离

这是第二条铁律:节点的 {x, y} 绝不能写进规则 JSON。

原因很直接------JSON 是要发给后端 DoorLS 执行的,是引擎契约。往里面塞画布坐标,一是污染契约,二是会导致「同样的规则,只因为用户拖了一下,导出文件就变了」,diff 里全是噪音。

所以搞了个独立的 canvasLayout store,只存坐标和视口,键按 ruleSetId : ruleId 分区:

ts 复制代码
// sessionStorage: grid-rule-editor:canvas-layout:<ruleSetId>:<ruleId>
const positionsByRule = ref<Record<string, Record<string, {x,y}>>>({})

分区这点很重要:规则 A 的坐标绝不能覆盖到规则 B 上。切换规则时各看各的,切回来位置还在。

导出 JSON 时坐标天然被排除,因为 toRuleSetJSON() 只认语义字段。而导入一份没有布局信息 的 JSON 时,投影函数发现 layout[id] 是空的,就退回 autoLayoutRule() 自动排:

ts 复制代码
// 按层排:每层间隔 110,兄弟间隔 200
function autoLayoutRule(rule) {
  const out = {}
  layoutSubtree(root, /* x */ 0, /* depth */ 0, out)
  return out
}

这样一个纯语义的 JSON 文件导进来,画布也能立刻显示出一棵整齐的树,用户接着拖就行。


五、拖拽的两种含义:改坐标 vs 改父级

画布上拖一个节点,可能是两件完全不同的事:

  1. 只是挪个位置------改坐标,纯布局,不动语义;
  2. 想把它塞进另一个组 (改父级)------这是语义变更,要改 ruleSet,要进撤销历史。

如果分不清,就会出现「用户随手拖了一下节点,结果结构悄悄变了」的恐怖 bug。

我们靠落点命中 来区分。onNodeDragStop 里用 elementFromPoint 找鼠标松手位置的元素:

ts 复制代码
function onNodeDragStop({ node, event }) {
  const el = document.elementFromPoint(event.clientX, event.clientY)
  const hostEl = el?.closest('.vue-flow__node-group')

  if (!hostEl || hostEl.dataset.id === node.id) {
    // 没落在任何组上(或落在自己身上)→ 当成纯挪位置
    layoutStore.setPosition(activeRuleId, node.id, node.position)
    return
  }

  // 落在某个组上了 → 尝试改父级
  const result = applyNodeReparentOrReorder(ruleSet, {
    nodeId: node.id,
    targetGroupId: hostEl.dataset.id,
  })
  if (result.ok) {
    commit(result.ruleSet)          // 进撤销历史
    result.message && toast(result.message)
  } else {
    toast(result.reason)            // 非法 → 回退位置
    reProject()                     // 让节点弹回原位
  }
}

也就是说:普通拖动默认只改布局,只有明确落到某个组的放置区里,才改结构。 这条规则让「误触改结构」几乎不可能发生。


六、放置校验做成纯函数

改父级这件事,规则其实不少,而且每一条都可能被违反:

  • 不能把节点拖到自己身上;
  • 不能把节点拖进自己的子树里(会造成环);
  • 不能跨规则拖(虽然画布只显示一条规则,但拖拽事件可能来自别处);
  • 根组不能被移走(它是树根,没有爹)。

把这些判断全塞进组件里,测试会痛不欲生。所以我们把它抽成纯函数 utils/canvasDrop.ts:

ts 复制代码
type DropResult =
  | { ok: true; ruleSet: RuleSet; message?: string }
  | { ok: false; reason: string }

export function applyNodeReparentOrReorder(rs, { nodeId, targetGroupId, index }): DropResult {
  const owner = findRuleOwningNode(rs, nodeId)
  if (!owner) return { ok: false, reason: '找不到节点所属规则' }

  const target = findNode(owner, targetGroupId)
  if (!target || !('logic' in target)) return { ok: false, reason: '目标不是条件组' }
  if (targetGroupId === nodeId) return { ok: false, reason: '不能把节点拖到自己身上' }
  if (containsId(findNode(owner, nodeId), targetGroupId))
    return { ok: false, reason: '不能把节点拖进自己的子树' }

  // ...摘除旧位置、插入新位置、返回新 ruleSet
}

同一个函数,表单页和画布页共用。 表单里「上移 / 下移 / 移入某组」按钮走的是它,画布拖放走的也是它------两套 UI 一套语义,永远不会出现「表单能做的画布做不到,或者两边规则不一样」。

一个小细节:Pinia 的 state 是 Proxy,直接 structuredClone 会炸。所以这里用 JSON.parse(JSON.stringify()) 深拷贝。规则集是纯数据,没函数没 Date,JSON 往返是安全的。


七、NOT 组的自动包裹,画布也要有

上一篇专门讲过 NOT 组的坑:JSON 里 not 是单操作数,只能有一个孩子。但用户往 NOT 组里拖第二个节点时,他不会去看什么 JSON 规范。

表单页的解法是 addChildSmart:往 NOT 组加第二个孩子时,自动包一层 AND,而不是报错。

画布拖放复用了同一套逻辑。当放置目标是 NOT 组、且它已经有一个孩子时:

ts 复制代码
if (target.logic === 'not' && target.children.length === 1) {
  // 把新节点和原孩子一起塞进一个新的 AND 组
  return { ok: true, ruleSet: wrapped, message: 'NOT 组已自动包裹 AND 子组以容纳新条件' }
}

界面上会弹一句提示,用户知道发生了什么,但操作是顺畅的------不会突然跳个红字「NOT 组只能有一个条件」把他劝退。


八、复用表单组件,而不是重写一套

右侧属性面板(选中的是叶子)长这样:

选中条件组时:

注意看叶子那张------fact、path、操作符、值,这和表单页的 ConditionRow 长得一模一样。因为它就是 ConditionRow,只是换了个 layout="stack" 的竖排模式:

vue 复制代码
<!-- CanvasInspector.vue -->
<ConditionRow v-if="isLeaf" :condition="node" layout="stack" />
<el-select v-else v-model="logic" @change="setGroupLogic"> ... </el-select>

复用带来的收益是连锁的:操作符下拉、值形状校验、操作符注册表的兼容性提示......这些在表单页已经写好并测过的逻辑,画布一行都不用重写。

这也是「单向投影」架构的额外红利------只要两个视图读写的是同一个 ruleSet、复用同一批叶子控件,它们的行为就天然一致。

组件面板(拖拽源)本身很简单,四个卡片:

拖拽开始的时候往 dataTransfer 塞一个标识:

ts 复制代码
function onDragStart(e, item) {
  e.dataTransfer.setData('application/x-palette', item.kind)
  e.dataTransfer.effectAllowed = 'move'
}

画布 onDrop 读出来,交给 applyPaletteDrop() 纯函数处理,逻辑和上面的 reparent 同源。


九、几处工程细节

① 路由懒加载,表单页不背画布包袱。

ts 复制代码
{ path: '/rule-editor',        component: RuleEditorView },
{ path: '/rule-editor/canvas', component: () => import('./views/DragRuleEditorView.vue') },

Vue Flow 只被画布页引用,表单页永远不会被迫加载它那块 chunk。

② 两个页面共享同一个 ruleSet store。 顶栏明确写着「共享当前规则集」:

切页不重置 store,撤销历史也是跨页统一的------你在表单页撤了两步,切到画布页还能接着撤。这避免了「换个视图,编辑历史清零」的割裂感。

③ 撤销/重做沿用快照方案。 上一篇讲过用 structuredClone 快照而非命令模式,画布的结构变更(拖入、改父级、删除)都走同一套 commit,上限 50 步。纯挪坐标不进历史------布局不是语义,不该占用撤销栈。

④ 选中状态跨重投影保持。 每次 ruleSet 变了都要重新 project,节点对象是新的。如果直接绑 selected 状态,用户一改值选中就丢了。解法是投影时用稳定 id(条件自身的 id),并单独记住当前选中的 nodeId,投影后重新挂上。

⑤ 测试聚焦纯函数。 画布 E2E 很重,我们把重头放在 projectRuleToFlow / applyPaletteDrop / applyNodeReparentOrReorder 这些纯函数的单测上,组件层只测「放置回调有没有被正确触发」。表单页既有的测试全部保持绿灯,作为回归门禁。


十、小结

回头看,这次加画布最关键的不是 Vue Flow 用得多溜,而是一开始就把它定义成「投影」而不是「真相」。

一旦画布只是投影:

  • 数据流单向,永远不会有两个真相源打架;
  • 布局和语义天然要分离,坐标进不了 JSON;
  • 所有交互都要翻译成对 ruleSet 的操作,于是纯函数化顺理成章;
  • 表单页和画布页共享模型和控件,行为一致是免费得到的。

几条可以直接抄走的经验:

  1. 画布永不作为真相源。 用 project(tree, layout) → {nodes, edges} 单向投影,回写只走 store action。
  2. 一张画布一条规则。 别把整份规则集画上去,规则列表 + 单规则画布的心智负担小得多。
  3. 坐标存独立 store,按 ruleId 分区。 绝不写进导出的 JSON。
  4. 拖拽要区分「改坐标」和「改父级」,靠落点命中决定;前者不进历史,后者进。
  5. 放置校验抽成纯函数,表单和画布共用,一套语义两套 UI。
  6. 能复用表单控件就复用,画布只补结构编辑能力。

表单和画布不是二选一,它们只是同一套规则模型的两种视图。业务人员想要图,就给他图;想精确填值,就给他表单。模型只有一个,视图可以有无数个。


项目:电网告警规则编辑器(Vue 3 + TS + Vite + Pinia + Element Plus + @vue-flow/core) 上一篇:《从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比》

相关推荐
福兮说1 小时前
JS 正则的六个坑:带 g 的 test() 一真一假、空匹配死循环、replace 里的 $
开发语言·前端·javascript·正则表达式
溪语流沙1 小时前
Django + Vue电商项目第005讲:后端骨架|Django初始化、配置分层与DRF接入
vue.js·后端·python·django
miss2 小时前
从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比
前端·vue.js·typescript
小七在进步2 小时前
类和对象(四)
java·javascript·ajax
fastjson_2 小时前
帆软看板 - 问题收集
linux·前端·javascript
用户83134859306982 小时前
Vue3 v-bind 使用指南,从基础到高阶
前端·javascript·vue.js
用户298698530142 小时前
在 React 中使用 JavaScript 将 HTML 转换为 PDF
javascript·react.js·html
OpsEye2 小时前
企业如何统一管理多家大模型 API?
javascript·ai编程
java_nnnn3 小时前
JavaEE进阶-JavaScript初识
开发语言·前端·javascript·java-ee·ecmascript