将 AntV X6 图编辑器转化为表达式引擎:Vue3 技术方案深度解析之Kimi

引言

在数据可视化与低代码平台蓬勃发展的今天,如何让业务人员通过图形化界面构建逻辑表达式,已成为提升开发效率的关键课题。本文将深入探讨如何基于 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})`);
});

七、总结与展望

本文详细介绍了如何构建一个双向可逆的可视化表达式系统,核心要点包括:

  1. 集中式节点注册:通过 `nodeRegistry` 统一管理所有节点元数据

  2. 递归代码生成:基于 DFS 遍历依赖图,生成合法表达式

  3. Vue 动态挂载:利用 `createApp` 实现节点内部表单交互

  4. 模块化架构:四层设计确保系统可测试、可扩展

未来演进方向:

  • 支持 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
相关推荐
ttod_qzstudio1 小时前
基于 AntV X6 的图形化数值计算表达式编辑器设计与实现之DeepSeek
编辑器·antvx6
my烂笔头1 小时前
vscode\cursor集成plantuml方法
ide·vscode·编辑器·uml
初夏睡觉1 小时前
Markdown编辑器如何使用(上)(详细)
编辑器
ttod_qzstudio1 小时前
从零到一构建可视化编程引擎:基于 AntV X6 + TDesign Vue Next 的完整实践之千问
编辑器·antvx6
沟通QQ:6882388611 小时前
基于Matlab的简单数字验证码识别系统:从图像降噪到字符识别的全流程解析及远程调试应用
编辑器
hashiqimiya15 小时前
unity配置外部编辑器rider
unity·编辑器·游戏引擎
山峰哥17 小时前
沉浸式翻译插件深度评测:打破语言壁垒的黑科技利器
数据结构·科技·算法·编辑器·办公
c***871918 小时前
Node.js使用教程
node.js·编辑器·vim
程小k1 天前
迷你编译器
c++·编辑器