基于拓扑排序的画布自动布局:用最长上游路径决定节点列级
在流程编排、审批流、任务依赖图等场景中,节点通常通过有向边表达"谁先于谁"或"谁依赖谁"。节点数量增加后,完全依靠手动拖拽容易出现错位、重叠和连线混乱,因此可以根据图结构自动生成从左到右的分层布局。
这套实现可以准确概括为:
基于 Kahn 算法式拓扑遍历的最长路径分层,再将层级转换为画布坐标。
拓扑遍历解决"先处理哪个节点",最长路径计算解决"节点应该位于哪一列",坐标计算则负责确定节点在画布中的具体位置。
一、把画布抽象成有向图
在代码中,可以把画布建模成一个有向图:
- 节点
node是图中的顶点; - 边
edge表示从一个节点指向另一个节点; predecessors保存每个节点的前驱,即上游节点;successors保存每个节点的后继,即下游节点。
例如,下面的图表示两个分支最终汇聚到同一个节点:
text
root ──> prepare ──> merge
└──> validate ────┘
其中,root 是没有前驱的源节点,merge 有两个前驱节点。
代码中多次使用 String(node.id),这是必要的防御性处理。节点 ID 可能来自接口、本地状态或组件参数,统一转换为字符串,可以避免数字 1 和字符串 '1' 被误认为两个不同节点。
二、使用拓扑遍历计算层级
核心函数位于 layout.js:
js
function calculateRanks(nodes, predecessors, successors) {
const ranks = new Map()
const remainingPredecessors = new Map()
const queue = []
nodes.forEach((node) => {
const id = String(node.id)
const count = predecessors.get(id).length
remainingPredecessors.set(id, count)
// 没有上游的节点从第 0 列开始
if (count === 0) {
ranks.set(id, 0)
queue.push(id)
}
})
for (let index = 0; index < queue.length; index += 1) {
const sourceId = queue[index]
const sourceRank = ranks.get(sourceId) || 0
successors.get(sourceId).forEach((targetId) => {
// 目标节点取所有上游路径中的最大层级 + 1
ranks.set(
targetId,
Math.max(ranks.get(targetId) || 0, sourceRank + 1)
)
const remaining = remainingPredecessors.get(targetId) - 1
remainingPredecessors.set(targetId, remaining)
if (remaining === 0) {
queue.push(targetId)
}
})
}
// 环形结构无法计算最长路径,基础版统一降级到第 0 列
nodes.forEach((node) => {
const id = String(node.id)
if (!ranks.has(id)) ranks.set(id, 0)
})
return ranks
}
1. 源节点进入第 0 列
初始化时,代码统计每个节点的前驱数量,并将其保存到 remainingPredecessors:
js
const count = predecessors.get(id).length
remainingPredecessors.set(id, count)
这个 Map 表示每个节点还有多少个上游节点未处理。
没有前驱的节点是源节点,可以直接设置为第 0 列,并加入队列:
js
if (count === 0) {
ranks.set(id, 0)
queue.push(id)
}
2. 队列保证依赖顺序
代码按照队列顺序处理节点。处理某个节点时,会遍历它的所有后继,并将后继节点的剩余前驱数量减一:
js
const remaining = remainingPredecessors.get(targetId) - 1
remainingPredecessors.set(targetId, remaining)
只有当目标节点的所有前驱都处理完成时,才将它加入队列:
js
if (remaining === 0) {
queue.push(targetId)
}
这种从入度为 0 的节点开始、处理后减少后继节点剩余入度的过程,属于 Kahn 算法式的拓扑遍历。对于无环图,它能够保证上游节点先于下游节点处理。
三、最长上游路径决定列级
整套实现最关键的代码是:
js
ranks.set(
targetId,
Math.max(ranks.get(targetId) || 0, sourceRank + 1)
)
可以理解为:
js
target.rank = Math.max(
target.rank,
source.rank + 1
)
它表示目标节点的层级必须取所有上游路径候选值中的最大值。
假设存在以下两条路径:
text
root → A → C
root → B → D → C
则:
text
rank(root) = 0
rank(A) = 1
rank(B) = 1
rank(D) = 2
处理 A → C 时,得到候选值 2;处理 D → C 时,得到候选值 3。最终结果为:
text
rank(C) = max(2, 3) = 3
因此,rank 可以理解为从任意源节点到当前节点的最长路径边数。
如果只采用第一次访问到的层级,C 可能被错误地放到第 2 列,较长的依赖链就会被压缩。使用 Math.max 可以避免这个问题。

这几个概念需要区分:
| 概念 | 作用 |
|---|---|
| 拓扑遍历 | 保证上游节点先于下游节点处理 |
| 最长路径动态更新 | 计算节点所在层级 |
rank 分组 |
将同一层节点放入同一列 |
| 坐标计算 | 将列级和行级转换为 x、y |
所以,这并不是单纯的"拓扑排序布局",而是:
text
Kahn 算法式拓扑遍历 + 最长路径分层 + 基础坐标排布
四、有向环是当前实现的边界
这套算法理想上要求输入是 DAG,即有向无环图。
例如:
text
A → B → C → A
环内节点不会出现"所有前驱都已处理完成"的时刻,因此不会进入队列。当前实现最后的兜底逻辑:
js
nodes.forEach((node) => {
const id = String(node.id)
if (!ranks.has(id)) ranks.set(id, 0)
})
只能避免布局结果完全缺失,并没有真正解决环检测问题。环内节点会被统一降级到第 0 列,可能与真正的源节点混在一起。
如果需要更严格的处理,可以在遍历过程中统计实际出队并处理的节点数量,例如维护 processedCount。如果处理数量小于节点总数,就可以判断图中存在有向环,进而向用户返回明确提示,而不是静默降级。
五、将 rank 转换成画布坐标
得到每个节点的层级后,calculateCanvasAutoLayout 会先按 rank 分组,再计算具体位置:
js
export function calculateCanvasAutoLayout(nodes = [], edges = [], options = {}) {
const config = {
columnGap: 150,
rowGap: 64,
originX: 80,
originY: 80,
...options,
}
const { predecessors, successors } = buildGraph(nodes, edges)
const ranks = calculateRanks(nodes, predecessors, successors)
const columns = new Map()
nodes.forEach((node) => {
const rank = ranks.get(String(node.id)) || 0
if (!columns.has(rank)) {
columns.set(rank, [])
}
columns.get(rank).push(node)
})
const positions = []
let columnX = config.originX
for (let rank = 0; rank <= Math.max(...columns.keys()); rank += 1) {
const column = columns.get(rank) || []
let rowY = config.originY
let columnWidth = 0
column.forEach((node) => {
const size = getNodeSize(node)
positions.push({
id: String(node.id),
position: {
x: columnX,
y: rowY,
},
})
rowY += size.height + config.rowGap
columnWidth = Math.max(columnWidth, size.width)
})
columnX += columnWidth + config.columnGap
}
return positions
}
1. 按层级分列
columns 的结构可以理解为:
text
Map<rank, nodes[]>
例如:
text
rank 0 → root
rank 1 → level-1-a、level-1-b
rank 2 → level-2
这里完成了从图算法层级到视觉列级的映射:
text
rank 0 → 第 0 列
rank 1 → 第 1 列
rank 2 → 第 2 列
2. 列级决定横坐标
初始横坐标由 originX 决定。每完成一列,横坐标增加当前列的最大宽度和列间距:
js
columnX += columnWidth + config.columnGap
因此,rank 并不直接等于固定的 x 值。由于每列节点的宽度可能不同,实际横坐标必须结合节点尺寸计算。
3. 同列节点纵向排列
同一列中的节点从 originY 开始依次向下排列:
js
rowY += size.height + config.rowGap
最终可以得到类似结果:
js
[
{ id: 'root', position: { x: 80, y: 80 } },
{ id: 'level-1-a', position: { x: 496, y: 80 } },
{ id: 'level-1-b', position: { x: 496, y: 344 } },
{ id: 'level-2', position: { x: 912, y: 80 } },
]
具体的 x 值取决于前面各列的实际最大宽度和配置间距,因此示例坐标只是可能的输出,不是固定结果。
4. 为什么要计算列宽
同一列中可能有宽度不同的节点。使用列内最大宽度,可以让下一列从当前列的右边界之后开始,降低横向重叠的可能性:
js
columnWidth = Math.max(columnWidth, size.width)
不过,这只是基础的列间距处理,并不等于完整的碰撞检测,也不能自动减少所有边交叉。

六、Hook 负责业务编排
布局函数只负责根据节点和边计算位置,不直接处理 Toast、Store 或视口调整。这些业务动作由 useCanvasAutoLayout 负责:
js
export function useCanvasAutoLayout({
nodes,
edges,
applyNodePositions,
fitView,
}) {
async function arrangeCanvas() {
if (!nodes.value.length) {
globalToast.warning('画布中暂无可整理的节点')
return false
}
const positions = calculateCanvasAutoLayout(
nodes.value,
edges.value
)
const changed = applyNodePositions(positions)
if (!changed) return false
await nextTick()
await fitView?.()
globalToast.success(`已整理 ${positions.length} 个节点`)
return true
}
return { arrangeCanvas }
}
它主要完成以下工作:
- 检查画布是否为空;
- 调用纯布局函数;
- 将坐标提交给 Store,并判断是否发生变化;
- 等待 Vue 响应式更新完成;
- 调整画布视口并提示用户。
nextTick 用于等待下一轮 DOM 更新完成,再执行 fitView,从而避免视口适配时仍使用旧的节点位置。Vue 官方文档也将 nextTick 定义为等待下一轮 DOM 更新刷新的工具。
七、Store 一次性提交全部位置
Store 中的 applyNodePositions 负责批量更新节点位置:
js
applyNodePositions(items = []) {
const positions = new Map(
items.map((item) => [
String(item.id),
item.position,
])
)
let changed = false
this.nodes = this.nodes.map((node) => {
const position = positions.get(String(node.id))
if (!position) return node
const nextPosition = {
x: Math.round(Number(position.x) || 0),
y: Math.round(Number(position.y) || 0),
}
if (
node.position?.x === nextPosition.x &&
node.position?.y === nextPosition.y
) {
return node
}
changed = true
return {
...node,
position: nextPosition,
}
})
if (!changed) return false
// 一次保存;是否产生历史记录取决于 queuePersist 的具体实现
this.queuePersist()
return true
}
这里的处理重点有四个:
- 使用
Map根据节点 ID 快速查找目标位置; - 只更新位置结果中包含的节点;
- 将坐标统一转换为数字并取整;
- 只有在确实发生变化时才调用
queuePersist()。
因此,自动布局会作为一次批量操作提交,而不是逐个节点修改并逐个保存:
text
布局算法输出位置数组
↓
Store 批量更新节点
↓
判断是否发生变化
↓
调用一次 queuePersist
如果 queuePersist 内部还负责生成历史记录,那么该操作也可以被归并为一次历史记录;仅根据当前展示的 applyNodePositions 代码,能够确定的是它只调用了一次持久化入口。
八、依赖注入与完整调用链
在 FlowCanvas.vue 中,Hook 通过参数接收画布业务能力:
js
const { arrangeCanvas } = useCanvasAutoLayout({
nodes,
edges,
applyNodePositions,
fitView: fitCanvasView,
})
Hook 不需要直接依赖具体 Store,只需要使用以下能力:
nodes:当前节点集合;edges:当前边集合;applyNodePositions:批量提交位置;fitView:调整画布视口。
完整调用链为:
text
点击整理
↓
arrangeCanvas()
↓
calculateCanvasAutoLayout()
↓
buildGraph()
↓
calculateRanks()
↓
按 rank 分列并计算坐标
↓
applyNodePositions()
↓
queuePersist()
↓
nextTick()
↓
fitView()

九、当前方案的适用范围与边界
当前实现已经完成:
- 基于前驱和后继关系的图构建;
- Kahn 算法式拓扑遍历;
- 基于最长上游路径的层级分配;
rank到列级的转换;- 列内纵向排列;
- 基于节点宽高的基础间距计算;
- Hook、Store、持久化和视口适配的业务串联。
但它还没有实现:
- 同列节点的智能排序;
- 边交叉最小化;
- 完整的节点碰撞检测;
- 跨多列边的虚拟节点或复杂路由;
- 环结构的明确检测和具体环路提示;
- 节点尺寸异步变化后的二次布局。
因此,这套实现更适合被定位为:
面向中小型流程图的轻量级自动布局基础实现。
如果后续需要更高的布局质量,可以继续增加同层排序、边交叉优化、碰撞检测和边路由,也可以引入专门的分层图布局引擎。Graphviz 的 dot 布局同样采用面向有向图的分层绘制思路,并提供方向、层间距等布局参数,但它还包含边交叉和边长度等更复杂的优化目标,因此不能简单等同于当前的基础列布局。
总结
这套自动布局的核心不是简单地依次遍历节点,而是将图算法与前端工程拆成几个明确步骤:
- 使用
predecessors和successors建立有向图; - 从入度为 0 的源节点开始进行 Kahn 算法式拓扑遍历;
- 使用
Math.max(target.rank, source.rank + 1)计算最长上游路径层级; - 将
rank转换为从左到右的列级; - 根据节点尺寸和间距计算具体坐标;
- 由 Hook 负责业务编排、视口适配和用户提示;
- 由 Store 批量提交节点位置,并调用持久化逻辑。
一句话概括:
拓扑遍历解决"先处理谁",最长路径分层解决"放在哪一列",坐标计算解决"具体放在哪里",Hook 和 Store 则负责把算法结果安全地应用到真实画布中。