引言
在數據可視化與低代碼平台蓬勃發展的今天,如何讓業務人員通過圖形化界面構建邏輯表達式,已成為提升開發效率的關鍵課題。本文將深入探討如何基於 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