可拖拽条件规则编辑器:从逻辑树 AST 到表达式生成

条件规则编辑器先用多选框收集条件,再把条件拖入 AND/OR 分组,最后输出一段可执行或可保存的表达式。整条链路已经具备小型编译器的结构。

可视化拖拽、嵌套逻辑和表达式优先级需要始终描述同一件事。递归逻辑树把交互到输出的这条编译链连接起来。

用 AST 表达规则

假设用户要配置:

1
(A AND B) OR (C AND (D OR E))

如果状态直接保存为字符串,拖拽一个条件、把一个分组嵌入另一个分组,都会变成脆弱的文本替换。更合适的数据结构是抽象语法树(AST):

1
2
3
4
5
6
7
8
9
10
11
12
13
type ConditionItem = {
key: string;
value: string;
current: string;
};

type LogicalNode = {
key: string;
parent: string;
logical: 'AND' | 'OR';
items: ConditionItem[];
children?: LogicalNode[];
};

每个逻辑组同时容纳两类成员:直接条件 items 和子逻辑组 children。logical 定义当前这一层如何把它们连接起来。

graph TD
  Root[OR]
  Root --> A[AND]
  Root --> B[AND]
  A --> C1[条件 A]
  A --> C2[条件 B]
  B --> C3[条件 C]
  B --> D[OR]
  D --> C4[条件 D]
  D --> C5[条件 E]

条件和值、逻辑运算符、父子关系都保存在结构里,不隐含在展示文本中。

表达式生成:一次后序遍历

逻辑树通过一次后序递归生成表达式:先生成子节点表达式,再在当前节点合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function buildExpression(node: LogicalNode): string {
let expression = '';

if (node.items.length > 0) {
expression = node.items.map((item) => item.value).join(` ${node.logical} `);
}

if (node.children?.length) {
const children = node.children.map(buildExpression);
if (expression) expression += ` ${node.logical} `;
expression += children.join(` ${node.logical} `);
}

return expression.includes(' AND ') || expression.includes(' OR ')
? `(${expression})`
: expression;
}

以 (A AND B) OR (C AND D) 为例,执行顺序是:

  1. 左子树生成 (A AND B);
  2. 右子树生成 (C AND D);
  3. 根节点以 OR 合并,得到 ((A AND B) OR (C AND D))。

括号略显冗余,但每层逻辑的优先级明确。规则编辑器优先保证语义正确,不追求最短字符串。

复杂度与树高

设整棵树包含 N 个逻辑组和条件项,最大嵌套层级为 H:

  • 每个节点只访问一次,遍历成本为 O(N);
  • 递归调用栈最深为 O(H);
  • 最终字符串的构造还会受到条件名称总长度影响。

通常规则树并不深,递归足够直观。但如果产品允许无限嵌套,应该限制最大层级,或者在序列化时改用显式栈,避免异常输入占满调用栈。

拖拽操作变换树

拖拽库告诉我们“什么被拖到了哪里”,业务树的更新由数据层处理。把条件拖入逻辑组时,数据层至少要完成三步:

1
2
3
从旧分组移除条件
→ 向目标分组加入条件并更新 current
→ 清理拖拽后留下的空分组

逻辑组拖入另一个逻辑组时,同样是「剪下子树 → 挂到新父节点 → 更新 parent → 清理空组」。这些操作可以复用一组递归函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addItem(tree: LogicalNode[], targetKey: string, item: ConditionItem) {
return tree.map((node) => {
if (node.key === targetKey) {
return {
...node,
items: [...node.items, { ...item, current: node.key }],
};
}

return node.children
? { ...node, children: addItem(node.children, targetKey, item) }
: node;
});
}

这个模式把拖拽交互翻译成可验证的数据变换。UI 组件可以换成任何 DnD 库,树变换规则仍然成立。

两个边界

单根约束

有些实现只在根逻辑组数量恰好为 1 时生成表达式:

1
2
3
4
5
if (logical && logical.length === 1) {
setExpression(buildExpression(logical[0]));
} else {
setExpression(undefined);
}

输出表达式的语义需要一个唯一根节点。多个根组可以表示,但当前模型没有定义它们之间默认是 AND 还是 OR。

这种约束需要在 UI 中显式呈现:禁止创建多个根组,或提供根级运算符,把多个根组包进一个虚拟根节点。

用字符串内容决定括号并不可靠

expression.includes(' AND ') 或 expression.includes(' OR ') 能在大多数情况下判断是否需要括号,但它把结构判断交给了字符串内容。若条件文本本身包含 AND 或 OR,例如某个业务条件名是 Brand OR Channel,就可能被误认为是一段复合逻辑。

括号可以根据 AST 结构判断:

1
2
3
4
5
6
7
8
9
function print(node: LogicalNode): string {
const parts = [
...node.items.map((item) => item.value),
...(node.children ?? []).map(print),
].filter(Boolean);

const expression = parts.join(` ${node.logical} `);
return parts.length > 1 ? `(${expression})` : expression;
}

进一步还可以区分运算符优先级,只有子表达式优先级低于父表达式时才加括号。但在规则编辑器中,保守地保留冗余括号通常更容易维护。

从展示树到持久化数据

数据流从条件表流向图节点 data:

1
2
3
4
5
条件表
→ 可拖拽 LogicalNode AST
→ buildExpression / print
→ AND/OR 映射为目标表达式运算符
→ 连同 AST、条件数据一起保存到图节点 data

结构化 AST 保留编辑能力,字符串表达式适合展示和执行。只存字符串,下一次打开编辑器时就得反向解析;只存树,又会让执行方承担序列化工作。两者一起保存,用少量存储空间换取编辑和消费两端的明确边界。

递归更新也要保持不可变

树更新函数经常写成 map/filter + 递归,看上去像不可变更新;但若递归过程中直接修改未命中节点的 children,React state 的引用语义仍可能被破坏。

更新时应始终返回新对象:

1
2
3
4
return {
...node,
children: node.children ? update(node.children) : undefined,
};

这样能避免旧状态被意外改写,也便于引入 memo、撤销快照或状态对比。

总结

可拖拽条件编辑器是一条小型编译链:

1
2
3
4
5
可视化交互
→ 递归逻辑树 AST
→ 树变换与结构约束
→ 表达式 printer
→ 可持久化、可执行的规则数据

结构作为事实、字符串作为输出时,拖拽、嵌套、撤销和重新编辑共享同一份真相来源。