目录
功能概述
业务需求
在天眼查企业信息栏目中,需要展示企业的股权结构关系图,以可视化的方式呈现:
- 企业之间的持股关系
- 持股比例信息
- 实际控制人信息
- 股权层级结构
功能示例:
功能特点
✅ 自动布局:使用 dagre 算法实现自动垂直布局
✅ 交互式展示:支持缩放、拖拽、视图适配
✅ 自定义节点样式:区分实际控制人、目标公司、普通公司
✅ 边标签显示:在连接线上显示持股比例
✅ 动画效果:连接线动画,提升用户体验
技术选型
核心技术栈
-
Vue Flow (@vue-flow/core)
选择理由:
- 专为 Vue 3 设计的流程图库
- 提供丰富的节点和边的自定义能力
- 支持多种布局算法集成
- 良好的 TypeScript 支持
- 活跃的社区维护
版本: @vue-flow/core@latest
-
Dagre (@dagrejs/dagre)
选择理由:
- 成熟的图形布局算法库
- 支持多种布局方向(TB/LR/BT/RL)
- 自动计算节点位置,避免重叠
- 支持有向无环图(DAG)布局
版本: @dagrejs/dagre@2.0.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)
详细实现
-
数据结构设计
节点数据结构
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>
关键实现点:
- 节点初始化事件监听
@nodes-initialized="initLayout"
- 视图配置
:default-viewport="{ zoom: 0.8 }" // 默认缩放 80%
:min-zoom="0.3" // 最小缩放 30%
:max-zoom="1.5" // 最大缩放 150%
- 提供良好的初始视图体验
- 限制缩放范围,避免过度缩放
- 自定义节点渲染
<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,
};
});
}
关键技术点:
-
节点尺寸获取
- 必须使用
findNode(node.id)获取 Vue Flow 内部节点实例 - 通过
graphNode.dimensions获取实际渲染尺寸 - 这是 dagre 正确计算布局的关键
- 必须使用
-
布局方向
TB(Top-Bottom): 从上到下,适合股权结构图LR(Left-Right): 从左到右BT(Bottom-Top): 从下到上RL(Right-Left): 从右到左
-
连接点位置
targetPosition: 目标节点的连接点位置sourcePosition: 源节点的连接点位置- 根据布局方向动态设置
-
边标签处理
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,
},
};
});
}
标签数据来源优先级:
- 目标节点的
shareholding对象中对应源节点的值 - 目标节点的
shareholding字符串值 - 源节点的
shareholding值 - 源节点的
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,
},
});
结果: 成功解决
技术要点总结
-
时机很重要
- 必须在 dagre 模块执行前设置 graphlib
- 使用
enforce: 'pre'确保插件优先执行
-
预构建文件处理
- Vite 预构建后的文件在
.vite/deps目录 - 需要在
transform钩子中处理这些文件 - 替换动态 require 为静态 import
- Vite 预构建后的文件在
-
模块解析顺序
- 在
optimizeDeps.include中,graphlib 必须在 dagre 之前 - 确保 graphlib 先被预构建
- 在
问题 2: 循环依赖错误
问题描述
Uncaught ReferenceError: Cannot access '__vite__cjsImport0__dagrejs_graphlib' before initialization
根本原因:
- 在 dagre 文件中导入 graphlib 可能导致循环依赖
- Vite 的模块初始化顺序问题
解决方案
- 调整预构建顺序
optimizeDeps: {
include: [
'@dagrejs/graphlib', // 先构建'@dagrejs/dagre', // 后构建 '@dagrejs/graphlib'
];
}
- 使用相对路径导入
// 使用预构建后的相对路径
import * as __graphlib__ from './@dagrejs_graphlib.js';
- 添加 resolveId 钩子
resolveId(id) {
if (id === '@dagrejs/graphlib') {
return null; // 让 Vite 使用默认解析
}
return null;
}
问题 3: 布局混乱问题
问题描述
- 节点全部挤在一起
- 层次不清晰
- 布局算法未生效
解决方案
- 确保节点尺寸正确
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
});
- 正确的初始化时机
// 必须在 nodes-initialized 事件中调用
@nodes-initialized="initLayout"
- 使用 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 表示程序崩溃
问题分析
可能原因:
- 内存 不足:构建大量模块时内存耗尽
- Node.js 版本问题:Node.js v23.11.1 可能存在兼容性问题,切换到 v18 尝试
- 插件处理问题:Vite 插件在处理某些文件时导致崩溃
- 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在运行时处理 - 开发环境:启用插件,处理预构建文件
- 优化插件代码,添加完善的错误处理
技术要点
-
生产构建与开发环境的区别
- 生产构建使用 Rollup,不进行预构建
- 开发环境使用 esbuild 预构建
- 插件需要在不同阶段处理
-
错误处理的重要性
- 插件中的错误可能导致整个构建失败
- 必须添加 try-catch 和空值检查
- 失败时返回 null,让 Vite 使用默认处理
-
内存 管理
- 大量模块转换可能导致内存问题
- 可以通过增加 Node.js 内存限制缓解,但更好的方法是优化插件逻辑
技术亮点
-
多层次的错误处理机制
三层防护:
- 代码层面:
dagre-fix.js预先设置 graphlib - 构建层面:Vite 插件处理预构建文件
- 运行时层面:备用初始化逻辑
-
智能的边标签处理
特点:
- 支持多种数据格式(对象、字符串)
- 自动从节点数据中提取标签
- 优先级处理机制
-
优雅的组件设计
设计原则:
- 单一职责:EquityStructure 专注于股权结构图
- 可复用性:通过 props 接收数据
- 可维护性:布局逻辑抽离到 Hook
-
用户体验优化
细节:
- 平滑的动画效果
- 自适应视图缩放
- 清晰的视觉层次(颜色区分)
- 悬浮提示信息
-
性能优化
策略:
- 使用 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.js | Vite 插件,处理预构建后的文件 |
| vite.config.js | Vite 配置,包含插件和优化配置 |
使用说明
-
安装依赖
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>
总结
技术收获
-
深入理解 Vite 构建机制
- 预构建(optimizeDeps)的工作原理
- 插件系统的使用
- CommonJS 与 ES 模块的兼容处理
-
图形布局算法实践
- Dagre 算法的使用
- 节点尺寸的重要性
- 布局方向的选择
-
Vue 3 最佳实践
- Composition API 的使用
- 自定义 Hooks 的设计
- 响应式数据的优化
-
问题解决能力
- 多层次的解决方案
- 渐进式的问题排查
- 文档化的思考过程
后续优化方向
-
性能优化
- 大数据量节点的虚拟滚动
- 布局计算的 Web Worker 化
- 节点渲染的懒加载
-
功能增强
- 节点点击事件处理
- 边的交互(高亮、选中)
- 导出图片功能
- 打印功能
-
用户体验
- 加载状态提示
- 空数据状态
- 错误处理提示
- 响应式适配(移动端)
附录
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'(右左)。