给规则引擎加一个拖拽画布: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 改父级
画布上拖一个节点,可能是两件完全不同的事:
- 只是挪个位置------改坐标,纯布局,不动语义;
- 想把它塞进另一个组 (改父级)------这是语义变更,要改
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的操作,于是纯函数化顺理成章; - 表单页和画布页共享模型和控件,行为一致是免费得到的。
几条可以直接抄走的经验:
- 画布永不作为真相源。 用
project(tree, layout) → {nodes, edges}单向投影,回写只走 store action。 - 一张画布一条规则。 别把整份规则集画上去,规则列表 + 单规则画布的心智负担小得多。
- 坐标存独立 store,按 ruleId 分区。 绝不写进导出的 JSON。
- 拖拽要区分「改坐标」和「改父级」,靠落点命中决定;前者不进历史,后者进。
- 放置校验抽成纯函数,表单和画布共用,一套语义两套 UI。
- 能复用表单控件就复用,画布只补结构编辑能力。
表单和画布不是二选一,它们只是同一套规则模型的两种视图。业务人员想要图,就给他图;想精确填值,就给他表单。模型只有一个,视图可以有无数个。
项目:电网告警规则编辑器(Vue 3 + TS + Vite + Pinia + Element Plus + @vue-flow/core) 上一篇:《从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比》