Vue Flow 流程图详细开发示例

415 阅读11分钟

目录

  1. 功能概述
  2. 技术选型
  3. 架构设计
  4. 详细实现
  5. 核心问题与解决方案
  6. 技术亮点
  7. 代码结构
  8. 使用说明

功能概述

业务需求

在天眼查企业信息栏目中,需要展示企业的股权结构关系图,以可视化的方式呈现:

  • 企业之间的持股关系
  • 持股比例信息
  • 实际控制人信息
  • 股权层级结构

功能示例: image.png

功能特点

✅ 自动布局:使用 dagre 算法实现自动垂直布局

✅ 交互式展示:支持缩放、拖拽、视图适配

✅ 自定义节点样式:区分实际控制人、目标公司、普通公司

✅ 边标签显示:在连接线上显示持股比例

✅ 动画效果:连接线动画,提升用户体验

技术选型

核心技术栈

  1. Vue Flow (@vue-flow/core)

选择理由:

  • 专为 Vue 3 设计的流程图库
  • 提供丰富的节点和边的自定义能力
  • 支持多种布局算法集成
  • 良好的 TypeScript 支持
  • 活跃的社区维护

版本: @vue-flow/core@latest

  1. Dagre (@dagrejs/dagre)

选择理由:

  • 成熟的图形布局算法库
  • 支持多种布局方向(TB/LR/BT/RL)
  • 自动计算节点位置,避免重叠
  • 支持有向无环图(DAG)布局

版本: @dagrejs/dagre@2.0.1

  1. Graphlib (@dagrejs/graphlib)

选择理由:

  • Dagre 的依赖库,提供图数据结构
  • 提供图的基本操作 API

版本: @dagrejs/graphlib@latest

技术选型对比

方案优点缺点选择
Vue Flow + Dagre成熟稳定、自动布局、Vue 3 原生支持需要处理 CommonJS 兼容问题✅ 已选择
自定义 Canvas 绘制完全可控、性能好开发成本高、布局算法需自研
D3.js功能强大、灵活学习曲线陡、与 Vue 集成复杂
ECharts Graph图表库成熟布局算法有限、定制化程度低

架构设计

示例组件层次结构

OwnerProfile (index.vue)
  └── EquityStructure.vue (股权结构图组件)
      ├── VueFlow (核心容器)
      │   ├── Background (背景网格)
      │   ├── CustomNode (自定义节点)
      │   └── CustomEdge (自定义边)
      └── useVueFlowLayout (布局算法 Hook)
          └── dagre (布局引擎)

数据流

业务数据 (equityStructureData)
    ↓
常量定义 (constants/index.ts)
    ↓
节点和边数据 (nodes, edges)
    ↓
Vue Flow 渲染
    ↓
Dagre 布局计算
    ↓
更新节点位置
    ↓
视图适配 (fitView)

详细实现

  1. 数据结构设计

节点数据结构

interface Node {
    id: string; // 节点唯一标识position: { x: number; y: number }; // 初始位置(布局前为 {x: 0, y: 0}) data: {
        name: string; // 公司/人名type?: 'controller' | 'company' | 'target'; // 节点类型
        shareholding?: string | Record<string, string>; // 持股比例
        role?: string; // 角色信息(如执行事务合伙人)
        sources?: number[]; // 来源节点 ID 列表
    };
}

边数据结构

interface Edge {
    id: string; // 边唯一标识(格式:source-target) source: string; // 源节点 IDtarget: string; // 目标节点 ID
    label?: string; // 边标签(持股比例) type?: 'smoothstep' | 'straight' | 'step'; // 边类型
    animated?: boolean; // 是否动画
    style?: CSSProperties; // 样式
    labelStyle?: CSSProperties; // 标签样式
}

3. ### 组件实现

EquityStructure.vue 核心逻辑

模板结构:

<template>
    <div class="equity-structure-container">
        <VueFlow :default-viewport="{ zoom: 0.8 }" :min-zoom="0.3" :max-zoom="1.5" :nodes="nodes" :edges="edges" @nodes-initialized="initLayout">
            <Background pattern-color="#e5e7eb" :gap="16" />
            <template #node-default="{ data }">
                    <!-- 自定义节点渲染 -->
            </template>
        </VueFlow>
    </div>
</template>

关键实现点:

  1. 节点初始化事件监听
@nodes-initialized="initLayout"
  1. 视图配置
:default-viewport="{ zoom: 0.8 }"  // 默认缩放 80%
:min-zoom="0.3"                     // 最小缩放 30%
:max-zoom="1.5"                     // 最大缩放 150% 
  • 提供良好的初始视图体验
  • 限制缩放范围,避免过度缩放
  1. 自定义节点渲染
<template #node-default="{ data }">
    <div :class="['custom-node', data.type]">
        <!-- 实际控制人节点 -->
        <div v-if="data.type === 'controller'" class="controller-box">
            <el-popover>
                <!-- 悬浮提示信息 -->
            </el-popover>
        </div>
        <!-- 普通公司节点 -->
        <div v-else class="company-box">
            <div class="company-name">{{ data.name }}</div>
            <div v-if="data.shareholding" class="shareholding-info">
                {{ data.shareholding }}
            </div>
        </div>
    </div>
</template>

. 布局算法实现

useVueFlowLayout.js

核心流程:

function layout(nodes, edges, direction) {
    // 1. 创建新的 dagre 图实例
    // const dagreGraph = new dagre.graphlib.Graph();

    // 2. 设置图的布局方向
    dagreGraph.setGraph({ rankdir: direction }); // 'TB' = 从上到下// 3. 添加节点(需要实际尺寸) for (const node of nodes) {
        const graphNode = findNode(node.id); // 从 Vue Flow 获取实际节点
        dagreGraph.setNode(node.id, {
            width: graphNode.dimensions.width || 150,
            height: graphNode.dimensions.height || 50,
        });
    }

    // 4. 添加边for (const edge of edges) {
        dagreGraph.setEdge(edge.source, edge.target);
    }

    // 5. 执行布局计算
    dagre.layout(dagreGraph);

    // 6. 更新节点位置return nodes.map(node => {
        const nodeWithPosition = dagreGraph.node(node.id);
        return {
            ...node,
            position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
            targetPosition: Position.Top, // 连接点位置sourcePosition: Position.Bottom,
        };
    });
}

关键技术点:

  1. 节点尺寸获取

    1. 必须使用 findNode(node.id) 获取 Vue Flow 内部节点实例
    2. 通过 graphNode.dimensions 获取实际渲染尺寸
    3. 这是 dagre 正确计算布局的关键
  2. 布局方向

    1. TB (Top-Bottom): 从上到下,适合股权结构图
    2. LR (Left-Right): 从左到右
    3. BT (Bottom-Top): 从下到上
    4. RL (Right-Left): 从右到左
  3. 连接点位置

    1. targetPosition: 目标节点的连接点位置
    2. sourcePosition: 源节点的连接点位置
    3. 根据布局方向动态设置
  1. 边标签处理

enrichEdgesWithLabels() 函数:

function enrichEdgesWithLabels() {
    // 1. 创建节点映射表const nodeMap = {};
    nodes.value.forEach(node => {
        nodeMap[node.id] = node;
    });

    // 2. 为每条边添加标签
    edges.value = edges.value.map(edge => {
        const sourceNode = nodeMap[edge.source];
        const targetNode = nodeMap[edge.target];
        let label = '';

        // 3. 从目标节点的 shareholding 中获取对应源节点的持股比例if (targetNode?.data?.shareholding) {
            if (typeof targetNode.data.shareholding === 'object') {
                // 对象形式:{ '1': '60%', '4': '20%' }
                label = targetNode.data.shareholding[edge.source] || '';
            } else {
                // 字符串形式:直接使用
                label = targetNode.data.shareholding;
            }
        }

        // 4. 返回增强后的边对象
        return {
            ...edge,
            label,
            type: 'smoothstep', // 平滑曲线animated: true, 
            style: { // 动画效果
                stroke: edge.target === '6' ? '#ef4444' : '#3b82f6', // 目标节点特殊颜色strokeWidth: edge.target === '6' ? 3 : 2,
            },
        };
    });
}

标签数据来源优先级:

  1. 目标节点的 shareholding 对象中对应源节点的值
  2. 目标节点的 shareholding 字符串值
  3. 源节点的 shareholding
  4. 源节点的 role

初始化流程

initLayout() 函数执行顺序:

function initLayout() {
    // 步骤 1: 先为边添加标签enrichEdgesWithLabels();

    // 步骤 2: 计算布局(dagre 需要节点已渲染) const layoutNodes = layout(nodes.value, edges.value, 'TB');
    nodes.value = layoutNodes;

    // 步骤 3: 等待 DOM 更新后适配视图nextTick(() => {
        fitView({ padding: 0.2, duration: 400 });
    });
}

关键时机:

  • nodes-initialized 事件触发时,节点已渲染完成
  • 此时可以获取节点实际尺寸
  • 使用 nextTick 确保 DOM 更新完成后再适配视图

备用初始化:

onMounted(() => {
    setTimeout(() => {
        // 如果 nodes-initialized 未触发,手动初始化if (nodes.value.length > 0 && nodes.value[0].position.x === 0) {
            initLayout();
        }
    }, 200);
});

核心问题与解决方案

问题 1: Dynamic require 错误

问题描述

Error: Dynamic require of "@dagrejs/graphlib" is not supported

根本原因:

  • @dagrejs/dagre 内部使用 require("@dagrejs/graphlib") 动态加载依赖
  • Vite 使用 ES 模块,不支持 CommonJS 的 require()
  • 预构建阶段无法解析动态 require

解决方案演进

方案 1: 手动设置 graphlib(初步尝试)

 // useVueFlowLayout.jsimport * as graphlib from '@dagrejs/graphlib';
import dagre from '@dagrejs/dagre';
dagre.graphlib = graphlib;

结果: 失败,时机太晚,dagre 模块已执行

方案 2: 在 main.js 中预先设置

 // main.jsimport * as graphlib from '@dagrejs/graphlib';
import dagre from '@dagrejs/dagre';
dagre.graphlib = graphlib;

结果: 失败,仍然有时机问题

方案 3: 创建 dagre-fix.js 包装器(最终方案)

 // src/utils/dagre-fix.js
import * as graphlib from '@dagrejs/graphlib';
import dagreLib from '@dagrejs/dagre';

// 立即设置,在 dagre 模块执行前
if (dagreLib && !dagreLib.graphlib) {
    dagreLib.graphlib = graphlib;
}

export default dagreLib;
export { graphlib };
 // main.js(必须在最前面)
import '@/utils/dagre-fix';

结果: 部分解决,但预构建后的文件仍有问题

方案 4: Vite 插件处理预构建文件(最终方案)

创建 dagreGraphlibPlugin.js

export function dagreGraphlibPlugin() {
    return {
        name: 'dagre-graphlib-plugin',
        enforce: 'pre',
        transform(code, id) {
                // 处理预构建后的 dagre 文件if (id.includes('node_modules/.vite/deps') && id.includes('dagre') && !id.includes('graphlib')) {
                // 1. 添加 graphlib 导入const importStatement = `import * as __graphlib__ from './@dagrejs_graphlib.js';\n`;

                // 2. 找到所有 import 语句的结束位置const importRegex = /^import\s+.*?from\s+['"].*?['"];?\s*\n/gm;
                let lastImportEnd = 0;
                let match;
                while ((match = importRegex.exec(code)) !== null) {
                        lastImportEnd = match.index + match[0].length;
                }

                // 3. 在最后一个 import 之后添加 graphlib 导入let modifiedCode = code.slice(0, lastImportEnd) + importStatement + code.slice(lastImportEnd);

                // 4. 替换所有的 g("@dagrejs/graphlib") 为 __graphlib__
                modifiedCode = modifiedCode.replace(/g(['"]@dagrejs/graphlib['"])/g, '__graphlib__');
                modifiedCode = modifiedCode.replace(/__require(['"]@dagrejs/graphlib['"])/g, '__graphlib__');

                return { code: modifiedCode, map: null };
            }
            return null;
        },
    };
}

Vite 配置:

 // vite.config.js
import { dagreGraphlibPlugin } from './vite-plugins/dagreGraphlibPlugin';

export default defineConfig({
        plugins: [
            dagreGraphlibPlugin(), // 必须在最前面// ... 其他插件
        ],
        optimizeDeps: {
            include: [
                '@dagrejs/graphlib', // 确保 graphlib 先于 dagre'@dagrejs/dagre',
            ],
            force: true,
        },
});

结果: 成功解决

技术要点总结

  1. 时机很重要

    1. 必须在 dagre 模块执行前设置 graphlib
    2. 使用 enforce: 'pre' 确保插件优先执行
  2. 预构建文件处理

    1. Vite 预构建后的文件在 .vite/deps 目录
    2. 需要在 transform 钩子中处理这些文件
    3. 替换动态 require 为静态 import
  3. 模块解析顺序

    1. optimizeDeps.include 中,graphlib 必须在 dagre 之前
    2. 确保 graphlib 先被预构建

问题 2: 循环依赖错误

问题描述

Uncaught ReferenceError: Cannot access '__vite__cjsImport0__dagrejs_graphlib' before initialization

根本原因:

  • 在 dagre 文件中导入 graphlib 可能导致循环依赖
  • Vite 的模块初始化顺序问题

解决方案

  1. 调整预构建顺序
optimizeDeps: {
    include: [
        '@dagrejs/graphlib', // 先构建'@dagrejs/dagre', // 后构建 '@dagrejs/graphlib'
    ];
}
  1. 使用相对路径导入
 // 使用预构建后的相对路径
import * as __graphlib__ from './@dagrejs_graphlib.js';
  1. 添加 resolveId 钩子
resolveId(id) {
  if (id === '@dagrejs/graphlib') {
    return null;  // 让 Vite 使用默认解析
  }
  return null;
}

问题 3: 布局混乱问题

问题描述

  • 节点全部挤在一起
  • 层次不清晰
  • 布局算法未生效

解决方案

  1. 确保节点尺寸正确
const graphNode = findNode(node.id);
if (!graphNode) {
  console.error(`Node with id ${node.id} not found`);
  continue;
}
dagreGraph.setNode(node.id, {
  width: graphNode.dimensions.width || 150,
  height: graphNode.dimensions.height || 50
});
  1. 正确的初始化时机
 // 必须在 nodes-initialized 事件中调用
@nodes-initialized="initLayout"
  1. 使用 nextTick 确保 DOM 更新
nextTick(() => {
    fitView({ padding: 0.2, duration: 400 });
});

问题 4: 自定义布局算法尝试

背景

由于 dagre 的动态 require 问题,尝试实现自定义布局算法(备用方案)。

实现思路:

  • 使用广度优先搜索(BFS)遍历图
  • 按层级分配节点位置
  • 计算每层节点的水平分布

放弃原因:

  • 布局质量不如 dagre
  • dagre 灵活度更高,支持多参数、多种布局风格
  • 最终通过解决 dagre 问题,回归使用 dagre

问题 1: 生产构建崩溃(退出码 3221225477)

问题描述

vite v4.4.11 building for production...
✓ 6709 modules transformed.
ELIFECYCLE  Command failed with exit code 3221225477.

错误特征:

  • 模块转换阶段正常完成(6709 modules transformed)
  • 崩溃发生在 Rollup 打包阶段
  • Windows 错误码 3221225477 表示程序崩溃

问题分析

可能原因:

  1. 内存 不足:构建大量模块时内存耗尽
  2. Node.js 版本问题:Node.js v23.11.1 可能存在兼容性问题,切换到 v18 尝试
  3. 插件处理问题:Vite 插件在处理某些文件时导致崩溃
  4. Rollup 配置问题compact: true 选项可能导致问题

解决方案

方案 1: 禁用 compact 选项

 // vite.config.jsrollupOptions: {
    output: {
        // compact: true, // 临时禁用,可能导致构建崩溃
    }
}

结果: 无效,问题仍然存在

方案 2: 优化插件代码

  • 添加更多安全检查
  • 优化文件匹配逻辑
  • 添加错误处理,避免插件崩溃
 // vite-plugins/dagreGraphlibPlugin.jstransform(code, id) {
  // 快速跳过:非 JS 文件
  if (!id || !code || typeof code !== 'string' || !id.endsWith('.js')) {
    return null;
  }

  try {
    // ... 处理逻辑
  } catch (error) {
    // 如果处理失败,返回 null,让 Vite 使用默认处理console.warn(`[dagre-graphlib-plugin] Transform error for ${id}:`, error.message);
    return null;
  }
}

结果: 部分改善,但构建仍然失败

方案 3: 条件启用插件

 // vite.config.jsconst isProduction = mode.mode === 'production';

plugins: [
    // 生产构建时禁用插件,依赖 dagre-fix.js 处理运行时问题
    ...(isProduction ? [] : [dagreGraphlibPlugin()]),
    vue(),
    // ...
];

结果: 构建成功,但开发环境运行失败,仍需插件

方案 4: 优化 optimizeDeps 配置

 // vite.config.js// 开发环境的依赖优化配置(生产构建时不使用)
...(isProduction
  ? {}
  : {
      optimizeDeps: {
        include: [
          // ... 依赖列表'@dagrejs/graphlib',
          '@dagrejs/dagre',
        ],
        force: true, // 强制重新构建依赖
      },
    }),

结果: 成功,避免生产构建时使用 optimizeDeps

最终方案:

  • 生产构建:禁用插件,依赖 dagre-fix.js 在运行时处理
  • 开发环境:启用插件,处理预构建文件
  • 优化插件代码,添加完善的错误处理

技术要点

  1. 生产构建与开发环境的区别

    1. 生产构建使用 Rollup,不进行预构建
    2. 开发环境使用 esbuild 预构建
    3. 插件需要在不同阶段处理
  2. 错误处理的重要性

    1. 插件中的错误可能导致整个构建失败
    2. 必须添加 try-catch 和空值检查
    3. 失败时返回 null,让 Vite 使用默认处理
  3. 内存 管理

    1. 大量模块转换可能导致内存问题
    2. 可以通过增加 Node.js 内存限制缓解,但更好的方法是优化插件逻辑

技术亮点

  1. 多层次的错误处理机制

三层防护:

  1. 代码层面dagre-fix.js 预先设置 graphlib
  2. 构建层面:Vite 插件处理预构建文件
  3. 运行时层面:备用初始化逻辑
  1. 智能的边标签处理

特点:

  • 支持多种数据格式(对象、字符串)
  • 自动从节点数据中提取标签
  • 优先级处理机制
  1. 优雅的组件设计

设计原则:

  • 单一职责:EquityStructure 专注于股权结构图
  • 可复用性:通过 props 接收数据
  • 可维护性:布局逻辑抽离到 Hook
  1. 用户体验优化

细节:

  • 平滑的动画效果
  • 自适应视图缩放
  • 清晰的视觉层次(颜色区分)
  • 悬浮提示信息
  1. 性能优化

策略:

  • 使用 Vue 3 Composition API
  • 响应式数据最小化
  • 布局计算只在必要时执行
  • 视图适配使用动画,避免突兀

代码结构

projects/
├── src/
│   ├── views/
│   │   └── partner/
│   │       └── owner-profile/
│   │           ├── index.vue                    # 主页面
│   │           ├── components/
│   │           │   └── EquityStructure.vue      # 股权结构图组件
│   │           └── constants/
│   │               └── index.ts                 # 数据常量
│   ├── hooks/
│   │   └── useVueFlowLayout.js                  # 布局算法 Hook
│   ├── utils/
│   │   └── dagre-fix.js                         # Dagre 修复工具
│   └── main.js                                  # 入口文件(导入 dagre-fix)
├── vite-plugins/
│   ├── dagreGraphlibPlugin.js                  # Vite 插件(处理预构建文件)
│   └── dagreEsbuildPlugin.js                   # Esbuild 插件(占位)
└── vite.config.js                              # Vite 配置

文件职责说明

文件职责
EquityStructure.vue股权结构图组件,负责 UI 渲染和交互
useVueFlowLayout.js布局算法 Hook,封装 dagre 布局逻辑
constants/index.ts节点和边的初始数据定义
dagre-fix.js修复 dagre 的动态 require 问题
dagreGraphlibPlugin.jsVite 插件,处理预构建后的文件
vite.config.jsVite 配置,包含插件和优化配置

使用说明

  1. 安装依赖

pnpm add @vue-flow/core @vue-flow/background
pnpm add @dagrejs/dagre @dagrejs/graphlib

11. ### 在页面中使用

<template>
    <el-card>
        <template #header>
            <div class="title">股权结构图</div>
        </template>
        <EquityStructure :data="equityStructureData" />
    </el-card>
</template>

<script setup>
import EquityStructure from './components/EquityStructure.vue';

const equityStructureData = ref({});
</script>

12. ### 数据结构要求

节点数据:

{
  id: '1',
  position: { x: 0, y: 0 },
  data: {
    name: '公司名称',
    type: 'company',  // 'controller' | 'company' | 'target' shareholding: '60%',  // 或 { '1': '60%', '2': '40%' } role: '执行事务合伙人'
  }
}

边数据:

{
  id: '1-2',
  source: '1',
  target: '2'
}

13. ### 自定义配置

修改布局方向:

 // useVueFlowLayout.js
const layoutNodes = layout(nodes.value, edges.value, 'LR'); // 支持横向布局

修改节点样式:

<!-- EquityStructure.vue -->
<style scoped>
.company-box {
    border-color: #your-color;
    /* 自定义样式 */
}
</style>

总结

技术收获

  1. 深入理解 Vite 构建机制

    1. 预构建(optimizeDeps)的工作原理
    2. 插件系统的使用
    3. CommonJS 与 ES 模块的兼容处理
  2. 图形布局算法实践

    1. Dagre 算法的使用
    2. 节点尺寸的重要性
    3. 布局方向的选择
  3. Vue 3 最佳实践

    1. Composition API 的使用
    2. 自定义 Hooks 的设计
    3. 响应式数据的优化
  4. 问题解决能力

    1. 多层次的解决方案
    2. 渐进式的问题排查
    3. 文档化的思考过程

后续优化方向

  1. 性能优化

    1. 大数据量节点的虚拟滚动
    2. 布局计算的 Web Worker 化
    3. 节点渲染的懒加载
  2. 功能增强

    1. 节点点击事件处理
    2. 边的交互(高亮、选中)
    3. 导出图片功能
    4. 打印功能
  3. 用户体验

    1. 加载状态提示
    2. 空数据状态
    3. 错误处理提示
    4. 响应式适配(移动端)

附录

A. 相关链接

B. 常见问题

Q: 为什么节点位置都是 (0, 0)?

A: 确保在 nodes-initialized 事件中调用 initLayout(),此时节点已渲染完成。

Q: 布局计算后节点重叠?

A: 检查节点尺寸是否正确获取,dagre 需要准确的 width 和 height。

Q: 动态 require 错误仍然存在?

A: 清除缓存 rm -rf node_modules/.vite,确保插件配置正确,graphlib 在 dagre 之前。

Q: 如何修改布局方向?

A: 在 layout() 函数调用时修改第三个参数:'TB'(上下)、'LR'(左右)、'BT'(下上)、'RL'(右左)。