引言
在数据可视化与低代码平台蓬勃发展的今天,如何让业务人员通过图形化界面构建逻辑表达式,已成为提升开发效率的关键课题。本文将深入探讨如何基于 AntV X6、Vue3 与 TDesign 技术栈,实现一套**可视化表达式编辑器**,支持四则运算、Math 函数与分支语句的图灵完备图形编程系统。
> 项目地址:https://github.com/yourname/x6-expression-editor
> 在线 Demo:https://x6-expression.vercel.app
一、需求拆解与架构设计
1.1 核心需求分析
我们的目标是构建一个**双向可逆**的系统:
TypeScript
// 正向:图形 → 表达式
Graph → "Math.sin((x + 90) * Math.PI / 180) * 2"
// 逆向:表达式 → 图形
"Math.pow(a, 2) + Math.cos(b)" → 还原节点与连线
功能边界:
-
✅ 支持:四则运算、Math 对象方法、if-else/switch 分支
-
❌ 排除:循环控制、对象/类、异步操作
1.2 技术选型考量
| 技术栈 | 选择理由 |
|---|---|
| AntV X6 | 强大的图编辑能力,支持自定义节点与交互 |
| Vue 3 | Composition API 便于逻辑复用,响应式驱动节点内容 |
| Design Vue Next | 企业级组件库,表单控件开箱即用 |
| Babylon.js | 预留 3D 可视化扩展能力 |
二、核心架构:四层设计模式
我们采用**分层解耦**架构,确保系统可维护与可扩展:
┌─────────────────────────────────────────┐
│ 应用层 (Vue Components) │
│ ExpressionGraph.vue / 右键菜单 │
├─────────────────────────────────────────┤
│ 转换层 (Serializer) │
│ 序列化/反序列化 · 表达式生成与解析 │
├─────────────────────────────────────────┤
│ 逻辑层 (Node Registry) │
│ 节点定义 · 代码生成规则 · 类型系统 │
├─────────────────────────────────────────┤
│ 渲染层 (X6 Renderer) │
│ 节点外观 · 端口配置 · 交互行为 │
└─────────────────────────────────────────┘
2.1 节点注册表:系统的"中枢神经系统"
所有节点类型统一在 `nodeRegistry.js` 中定义,这是整个系统的核心:
TypeScript
// nodeRegistry.js
export const nodeRegistry = {
// 输入节点:表达式的起点
input: {
name: '输入值',
ports: [{ id: 'out', group: 'out' }],
hasContent: false,
// 代码生成:直接返回上游传递的变量名
generateCode: (params, ports) => ports.out,
},
// 加法节点:展示参数化配置
add: {
name: '加法',
ports: [
{ id: 'in', group: 'in' }, // 左侧输入
{ id: 'out', group: 'out' } // 右侧输出
],
hasContent: true,
defaultParams: { addend: 0 }, // TDesign 输入框绑定值
generateCode: (params, ports) => {
// 生成带括号的表达式,确保运算优先级
return `(${ports.in} + ${params.addend})`;
},
},
// 正弦节点:展示枚举参数
sin: {
name: '正弦',
ports: [{ id: 'in', group: 'in' }, { id: 'out', group: 'out' }],
hasContent: true,
defaultParams: { useDegree: true },
generateCode: (params, ports) => {
// 动态转换角度/弧度
const value = params.useDegree
? `${ports.in} * Math.PI / 180`
: ports.in;
return `Math.sin(${value})`;
},
},
// if-else 节点:展示多端口输入
ifelse: {
name: '条件判断',
ports: [
{ id: 'condition', group: 'in' },
{ id: 'trueVal', group: 'in' },
{ id: 'falseVal', group: 'in' },
{ id: 'out', group: 'out' }
],
hasContent: false,
generateCode: (params, ports) => {
// 生成三元表达式
return `(${ports.condition} ? ${ports.trueVal} : ${ports.falseVal})`;
},
},
};
// 动态获取节点列表(用于右键菜单)
export const getNodeTypeList = () =>
Object.entries(nodeRegistry).map(([type, config]) => ({ type, name: config.name }));
设计亮点:
- 策略模式:每个节点自带 `generateCode`,将逻辑内聚
- 参数驱动:通过 `defaultParams` 定义节点私有状态
- 反射机制:`getNodeTypeList` 支持动态扩展
三、深度实现:从 0 到 1 编码指南
3.1 X6 节点注册:外观与交互
TypeScript
// x6ShapeRegister.js
import { Graph, Shape } from '@antv/x6';
import { nodeRegistry } from './nodeRegistry';
export function registerX6Shapes() {
Object.entries(nodeRegistry).forEach(([type, config]) => {
Shape.Rect.define({
shape: `node-${type}`,
width: 160,
height: config.hasContent ? 80 : 40,
// 使用 markup 定义节点 DOM 结构
markup: [
{ tagName: 'rect', selector: 'body' }, // 背景
{ tagName: 'rect', selector: 'header' }, // 标题栏
{ tagName: 'text', selector: 'title' }, // 标题文字
{ tagName: 'g', selector: 'content' }, // 动态内容容器
],
attrs: {
body: {
width: 160,
height: 'calc(h)',
fill: '#fff',
stroke: '#ddd',
strokeWidth: 1,
rx: 4, // 圆角
},
header: {
width: 160,
height: 40,
fill: '#5B8FF9',
stroke: 'none',
},
title: {
ref: 'header',
refX: 10,
refY: 20,
textAnchor: 'start',
fontSize: 14,
fill: '#fff',
fontWeight: 'bold',
text: config.name,
},
},
// 端口配置:输入在左,输出在右
ports: {
groups: {
in: {
position: { name: 'left' },
attrs: {
circle: {
r: 5,
magnet: true, // 可连接
stroke: '#5B8FF9',
fill: '#fff',
strokeWidth: 2,
},
},
},
out: {
position: { name: 'right' },
attrs: {
circle: {
r: 5,
magnet: true,
stroke: '#52C41A',
fill: '#fff',
strokeWidth: 2,
},
},
},
},
items: config.ports,
},
});
});
}
3.2 表达式生成:拓扑排序算法
核心挑战:图结构可能包含分支与合并,必须按依赖顺序生成代码。
TypeScript
// expressionGenerator.js
import { nodeRegistry } from './nodeRegistry';
export function generateExpression(graph) {
const outputNode = graph.getNodes().find(n => n.getData().type === 'output');
if (!outputNode) throw new Error('必须包含输出节点');
// 深度优先遍历,自动处理依赖顺序
return generateNodeExpression(outputNode, graph, new Set());
}
function generateNodeExpression(node, graph, visited) {
const nodeId = node.id;
if (visited.has(nodeId)) {
throw new Error(`检测到循环依赖,节点 ID: ${nodeId}`);
}
visited.add(nodeId);
const nodeData = node.getData();
const config = nodeRegistry[nodeData.type];
// 递归收集所有输入端口的表达式
const portInputs = {};
const inputPorts = config.ports.filter(p => p.group === 'in');
inputPorts.forEach(port => {
const inEdge = graph.getEdges().find(edge =>
edge.getTargetCellId() === nodeId &&
edge.getTargetPortId() === port.id
);
if (inEdge) {
const sourceNode = graph.getCellById(inEdge.getSourceCellId());
portInputs[port.id] = generateNodeExpression(sourceNode, graph, visited);
} else {
portInputs[port.id] = '0'; // 未连接时默认值
}
});
visited.delete(nodeId);
return config.generateCode(nodeData.params || {}, portInputs);
}
算法解析:
-
Set 去重:`visited` 集合检测循环依赖
-
后序遍历:先处理子节点,再处理当前节点
-
端口映射:通过 `portInputs` 对象传递上游结果
3.3 表达式解析:反向还原图形
TypeScript
// expressionParser.js
import { nodeRegistry } from './nodeRegistry';
export function parseExpressionToGraph(expression) {
// 使用 Babel Parser 将表达式转为 AST
const ast = parseToAST(expression);
const nodes = [];
const edges = [];
let idCounter = 0;
// 创建输出节点(固定位置)
const outputNode = createNode('output', 600, 200, ++idCounter);
nodes.push(outputNode);
// AST 递归遍历
traverseAST(ast, nodes, edges, idCounter, outputNode.id);
return { nodes: nodes.map(n => n.serialize()), edges };
}
// 简化版:实际使用 @babel/parser
function parseToAST(expr) {
// 示例:"(x + 90) * Math.PI / 180" → AST
// 本文限于篇幅,建议使用 babel/parser
}
function traverseAST(node, nodes, edges, idCounter, parentId) {
if (node.type === 'CallExpression' && node.callee.property.name === 'sin') {
const sinNode = createNode('sin', 400, 200, ++idCounter, {
useDegree: true
});
nodes.push(sinNode);
edges.push(createEdge(sinNode.id, 'out', parentId, 'in'));
// 递归处理参数
traverseAST(node.arguments[0], nodes, edges, idCounter, sinNode.id);
}
// 处理更多节点类型...
}
四、Vue3 集成:动态内容渲染
4.1 主组件架构
TypeScript
<template>
<div class="graph-workspace">
<!-- 工具栏 -->
<header class="toolbar">
<t-button @click="generateCode" theme="primary">生成表达式</t-button>
<t-button @click="saveGraph">💾 保存</t-button>
<t-button @click="loadGraph">📂 加载</t-button>
<t-input v-model="expression" style="width: 400px" placeholder="表达式将显示在这里" />
</header>
<!-- 图形容器 -->
<div ref="graphRef" class="graph-canvas" @contextmenu.prevent />
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="menuVisible"
:position="menuPosition"
:node-types="nodeTypeList"
@add-node="handleAddNode"
/>
<!-- 表达式预览 -->
<t-dialog v-model:visible="previewVisible" header="生成的表达式">
<pre>{{ expression }}</pre>
<t-button @click="testExpression">🧪 测试执行</t-button>
</t-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { Graph } from '@antv/x6';
import { MessagePlugin } from 'tdesign-vue-next';
import { registerX6Shapes } from './core/x6ShapeRegister';
import { generateExpression } from './core/expressionGenerator';
import { parseExpressionToGraph } from './core/expressionParser';
import { getNodeTypeList } from './core/nodeRegistry';
import ContextMenu from './components/ContextMenu.vue';
const graphRef = ref(null);
const graph = ref(null);
const menuVisible = ref(false);
const menuPosition = ref({ x: 0, y: 0 });
const nodeTypeList = getNodeTypeList();
const expression = ref('');
const previewVisible = ref(false);
onMounted(async () => {
registerX6Shapes();
graph.value = new Graph({
container: graphRef.value,
width: '100%',
height: '100%',
background: { color: '#fafafa' },
grid: { size: 20, type: 'dot' },
snapline: true, // 对齐线
keyboard: true, // 快捷键
clipboard: true,
selecting: {
enabled: true,
rubberband: true,
},
});
// 初始化默认节点
initDefaultNodes();
// 事件监听
graph.value.on('blank:contextmenu', handleContextMenu);
graph.value.on('blank:click', () => menuVisible.value = false);
// 节点双击编辑参数
graph.value.on('node:dblclick', ({ node }) => {
const type = node.getData().type;
if (nodeRegistry[type].hasContent) {
openNodeEditor(node);
}
});
});
function initDefaultNodes() {
// 输入节点(只允许一个)
graph.value.addNode({
shape: 'node-input',
x: 50,
y: 200,
data: { type: 'input', params: {} },
});
// 输出节点(只允许一个)
graph.value.addNode({
shape: 'node-output',
x: 650,
y: 200,
data: { type: 'output', params: {} },
});
}
function handleContextMenu({ e }) {
menuPosition.value = { x: e.clientX, y: e.clientY };
menuVisible.value = true;
}
async function handleAddNode(type) {
const { x, y } = menuPosition.value;
const node = graph.value.addNode({
shape: `node-${type}`,
x: x - 80, // 居中偏移
y: y - 20,
data: {
type,
params: { ...nodeRegistry[type].defaultParams }
},
});
// 动态渲染 Vue 组件到节点内部
await nextTick();
renderNodeContent(node, graph.value);
menuVisible.value = false;
}
function generateCode() {
try {
expression.value = generateExpression(graph.value);
previewVisible.value = true;
} catch (error) {
MessagePlugin.error(`表达式生成失败: ${error.message}`);
}
}
function testExpression() {
const func = new Function('x', `return ${expression.value}`);
const result = func(90); // 测试输入
MessagePlugin.success(`计算结果: ${result}`);
}
// 保存到 localStorage(可替换为后端 API)
function saveGraph() {
const data = graph.value.toJSON();
localStorage.setItem('expression-graph', JSON.stringify(data));
MessagePlugin.success('图形已保存');
}
function loadGraph() {
const saved = localStorage.getItem('expression-graph');
if (saved) {
graph.value.fromJSON(JSON.parse(saved));
MessagePlugin.success('图形已加载');
}
}
</script>
<style scoped>
.graph-workspace {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
height: 60px;
padding: 0 20px;
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-bottom: 1px solid #e7e7e7;
}
.graph-canvas {
flex: 1;
overflow: hidden;
}
</style>
4.2 动态内容挂载技巧
利用 Vue 3 的 `createApp` 将表单控件渲染到 X6 节点内部:
TypeScript
// utils/renderContent.js
import { createApp, ref, watch } from 'vue';
import { Input, RadioGroup, Radio } from 'tdesign-vue-next';
export function renderNodeContent(node, graph) {
const type = node.getData().type;
const contentGroup = node.findOne('g[selector="content"]');
// 创建 SVG foreignObject 承载 HTML
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
foreignObject.setAttribute('width', '160');
foreignObject.setAttribute('height', '40');
const div = document.createElement('div');
foreignObject.appendChild(div);
contentGroup.appendChild(foreignObject);
if (type === 'add') {
const app = createApp({
components: { TInput: Input },
setup() {
const value = ref(node.getData().params?.addend || 0);
// 响应式更新图数据
watch(value, (newVal) => {
node.setData({ params: { addend: Number(newVal) } });
node.prop('size/height', config.hasContent ? 80 : 40);
});
return () => (
<t-input
v-model={value.value}
type="number"
placeholder="输入加数"
style="width: 140px; margin: 10px;"
/>
);
},
});
app.mount(div);
}
// 其他节点类型...
}
五、高级功能与最佳实践
5.1 类型安全与验证
为端口添加类型系统,防止非法连接:
TypeScript
// 在 nodeRegistry 中扩展
add: {
// ...
ports: [
{ id: 'in', group: 'in', type: 'number' },
{ id: 'out', group: 'out', type: 'number' }
],
}
// 在 X6 中验证
graph.value.on('edge:connected', ({ edge }) => {
const sourcePort = edge.getSourcePort();
const targetPort = edge.getTargetPort();
if (sourcePort.type !== targetPort.type) {
edge.remove();
MessagePlugin.error('端口类型不匹配!');
}
});
5.2 性能优化
-
虚拟滚动:节点数 > 500 时,使用 `visibility` API 按需渲染内容
-
表达式缓存:为每个节点增加 `computedExpression` 缓存,避免重复计算
-
Web Worker:复杂表达式解析在 Worker 线程执行
5.3 扩展更多节点
TypeScript
// 新增乘方节点
pow: {
name: '乘方',
ports: [{ id: 'base', group: 'in' }, { id: 'exp', group: 'in' }, { id: 'out', group: 'out' }],
hasContent: false,
generateCode: (params, ports) =>
`Math.pow(${ports.base}, ${ports.exp})`,
},
// 新增 switch 节点(需特殊处理)
switch: {
name: '分支选择',
ports: [
{ id: 'value', group: 'in' },
{ id: 'case1', group: 'in' },
{ id: 'result1', group: 'in' },
// ... 动态添加 case
{ id: 'default', group: 'in' },
{ id: 'out', group: 'out' }
],
generateCode: (params, ports) => {
return `(function(v) {
switch(v) {
case ${ports.case1}: return ${ports.result1};
default: return ${ports.default};
}
})(${ports.value})`;
},
}
六、测试与调试
6.1 单元测试表达式生成
TypeScript
// test/expression.test.js
import { describe, it, expect } from 'vitest';
import { generateExpression } from '../core/expressionGenerator';
describe('表达式生成', () => {
it('应正确生成加法表达式', () => {
const graph = createMockGraph([
{ id: 'input', type: 'input', x: 0, y: 0 },
{ id: 'add', type: 'add', params: { addend: 10 }, x: 100, y: 0 },
{ id: 'output', type: 'output', x: 200, y: 0 },
], [
{ source: 'input', target: 'add', sourcePort: 'out', targetPort: 'in' },
{ source: 'add', target: 'output', sourcePort: 'out', targetPort: 'in' },
]);
expect(generateExpression(graph)).toBe('(x + 10)');
});
});
6.2 可视化调试工具
在工具栏添加**执行追踪**模式,高亮当前计算节点:
TypeScript
graph.value.on('node:click', ({ node }) => {
const expr = generateNodeExpression(node, graph.value);
eval(`console.log('节点 ${node.id} 结果:', ${expr})`);
});
七、总结与展望
本文详细介绍了如何构建一个双向可逆的可视化表达式系统,核心要点包括:
-
集中式节点注册:通过 `nodeRegistry` 统一管理所有节点元数据
-
递归代码生成:基于 DFS 遍历依赖图,生成合法表达式
-
Vue 动态挂载:利用 `createApp` 实现节点内部表单交互
-
模块化架构:四层设计确保系统可测试、可扩展
未来演进方向:
-
支持 Lambda 表达式与函数定义节点
-
集成 Monaco Editor实现表达式文本与图形联动编辑
-
添加 错误提示(红色波浪线)到节点端口
-
支持 子图嵌套,实现复杂逻辑封装
附录:快速开始
TypeScript
# 克隆项目
git clone https://github.com/yourname/x6-expression-editor.git
# 安装依赖
npm install @antv/x6 @antv/x6-vue-shape vue@next tdesign-vue-next
# 启动开发服务器
npm run dev