引言

在數據可視化與低代碼平台蓬勃發展的今天,如何讓業務人員通過圖形化界面構建邏輯表達式,已成為提升開發效率的關鍵課題。本文將深入探討如何基於 AntV X6、Vue3 與 TDesign 技術棧,實現一套**可視化表達式編輯器**,支持四則運算、Math 函數與分支語句的圖靈完備圖形編程系統。

> 項目地址:https://github.com/yourname/x6-expression-editor  
> 在線 Demo:https://x6-expression.vercel.app

---

一、需求拆解與架構設計

1.1 核心需求分析

我們的目標是構建一個**雙向可逆**的系統:

// 正向:圖形 → 表達式
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` 中定義,這是整個系統的核心:

// 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 節點註冊:外觀與交互

// 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 表達式生成:拓撲排序算法

核心挑戰:圖結構可能包含分支與合併,必須按依賴順序生成代碼。

// 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 表達式解析:反向還原圖形

// 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 主組件架構

<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 節點內部:

// 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 類型安全與驗證

為端口添加類型系統,防止非法連接:

// 在 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 擴展更多節點

// 新增乘方節點
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 單元測試表達式生成

// 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 可視化調試工具

在工具欄添加**執行追蹤**模式,高亮當前計算節點:

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實現表達式文本與圖形聯動編輯
- 添加 錯誤提示(紅色波浪線)到節點端口
- 支持 子圖嵌套,實現複雜邏輯封裝

附錄:快速開始

# 克隆項目
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