最近做了一个关系图:节点是业务卡片,节点之间用带圆角的直角线连接,整张图可以拖动、缩放和一键适应视图。部分节点还能展开、收起,卡片里的文字发生变化后,连线要跟着移动。
这听起来很像流程图编辑器,但需求里没有画布编辑、拖拽连线、框选、撤销重做,也不需要自动布局。真正需要解决的是三个问题:
- 保留 HTML 渲染节点的自由度,同时用 SVG 画线;
- DOM 尺寸和位置变化后,线必须及时重算;
- 图被
transform: scale()缩放后,DOM 坐标和 SVG 坐标不能错位。
如果直接引入完整的图编辑框架,当然能做,但也会带来一套新的节点模型、事件模型和样式体系。对于只读关系展示,这些能力大部分用不上。最后我把功能拆成了两个独立组件:
RelationGraph:负责节点布局、接入点定位和 SVG 连线;PanZoom:只负责内容的平移、缩放和适应视图。
两个组件互相不知道对方的业务,也不依赖具体卡片结构。下面从坐标系开始拆。
先把渲染方案选对:DOM 负责节点,SVG 负责线
纯 SVG 可以同时画节点和线,但复杂卡片放进 SVG 后,文本排版、表单控件、插槽和现有组件都不好复用。纯 DOM 则很难画出可交互、带圆角的连接线。
更合适的组合是:
RelationGraph(position: relative)
├── NodeContainer(普通 DOM,负责节点布局)
│ ├── 第一列节点
│ ├── 第二列节点
│ └── 第三列节点
└── EdgeSvg(absolute; inset: 0,覆盖整个关系图)
节点使用正常的 HTML 和 Vue 插槽渲染,SVG 只接收一组起止坐标:
export interface RelationGraphLine {
x1: number
y1: number
x2: number
y2: number
lineColor?: string
}
这里有一个很重要的约束:x1/y1/x2/y2 必须全部位于 RelationGraph 自己的局部坐标系中。只要这个约束成立,节点怎么渲染、外层怎么缩放,SVG 都只管按坐标画线。
数据描述关系,插槽决定节点长什么样
关系图本身不应该认识“订单”“审批”之类的业务概念。它只需要一个递归结构:
export interface RelationGraphData<T> {
key: string
data?: T
children?: RelationGraphData<T>[]
hidden?: boolean
fromPortClass?: string
portClass?: string
lineColor?: string
}
几个字段的职责要分清:
key:节点唯一标识,同时用于找到对应 DOM;children:描述父子关系;fromPortClass:线从父节点的哪个接入点出发;portClass:线连接到当前节点的哪个接入点;hidden:控制节点及对应连线是否显示;data:业务数据,关系图不读取它,只透传给插槽。
使用时,关系图只提供布局和连线,节点内容全部交给调用方:
<RelationGraph :data="graphData">
<template #default="node">
<article class="card">
<RelationGraphPort class="input-port" />
<h3>{{ node.data?.title }}</h3>
<p>{{ node.data?.description }}</p>
<RelationGraphPort class="output-port" />
</article>
</template>
</RelationGraph>
一条边可以这样描述:
const graphData = reactive<RelationGraphData<CardData>[]>([
{
key: 'root',
data: { title: '提交申请', description: '流程起点' },
children: [
{
key: 'review',
fromPortClass: 'output-port',
portClass: 'input-port',
data: { title: '审核', description: '检查提交内容' },
},
],
},
])
这套模型故意没有把节点宽高、绝对坐标放进数据里。节点尺寸由真实 DOM 决定,关系图在布局完成后读取结果。对于内容高度不固定的业务卡片,这比维护两套尺寸数据可靠得多。
递归数据不等于递归嵌套布局
数据是树,但目标布局是“每一层一列”。同一深度的节点进入同一列,而不是把子节点 DOM 塞进父节点内部。
例如:
A
├── B
│ ├── D
│ └── E
└── C
渲染结果应该是:
第一列 第二列 第三列
A B D
C E
实现时,每轮先渲染当前数组,再把所有子节点展平,交给下一轮:
const RenderNodes = (
{ data }: { data?: RelationGraphData<unknown>[] },
{ slots }: { slots: { default?: (item: RelationGraphData<unknown>) => VNode } },
) => (
<>
{!!data?.length && (
<div class="relation-graph__column">
{data.map(item => (
<div
key={item.key}
class={[
'relation-graph__node',
`node-${item.key}`,
{ 'relation-graph__node--hidden': item.hidden },
]}
>
{slots.default?.(item)}
</div>
))}
</div>
)}
{!!data?.some(item => item.children?.length) && (
<RenderNodes
data={data.flatMap(item => item.children ?? [])}
v-slots={{ default: slots.default }}
/>
)}
</>
)
列容器横向排列,每一列内部纵向排列:
.relation-graph__nodes {
display: flex;
align-items: center;
column-gap: 72px;
}
.relation-graph__column {
display: flex;
flex-direction: column;
row-gap: 72px;
}
这不是自动布局算法。它适合层级明确、从左到右展示的树状关系。如果节点需要绕障碍物、任意摆放或自动减少交叉线,就应该引入专门的布局算法,比如 Dagre 或 ELK,而不是继续堆 CSS。
接入点不是数据坐标,而是真实 DOM
连线不能只连卡片中心。一个节点可能有多个语义出口:点击“金额”旁边的点,只展开金额相关的分支;点击卡片右侧的点,展开普通子节点。
因此我把接入点做成一个极小的 DOM 组件:
<template>
<span class="relation-graph-port" />
</template>
<style scoped>
.relation-graph-port {
position: relative;
z-index: 3;
display: block;
width: 12px;
height: 12px;
border: 4px solid var(--surface-color);
border-radius: 50%;
background: var(--brand-color);
}
</style>
调用方用 class 标识端口,关系数据只保存 class 名。计算连线时,先找到节点,再在节点内部寻找指定端口:
function findPort(
graph: HTMLElement,
nodeKey: string,
portClass?: string,
): HTMLElement | null {
const node = graph.querySelector<HTMLElement>(`.node-${nodeKey}`)
if (!node || !portClass) return node
return node.querySelector<HTMLElement>(`.${portClass}`) ?? node
}
找不到端口时回退到节点本身。这样简单卡片可以直接连接节点中心,需要精确控制时再声明 Port。
生产代码里不要直接把未经处理的任意字符串拼进 CSS 选择器。示例为了突出坐标逻辑做了简化;如果 key 或端口名来自外部输入,可以使用稳定的内部 ID、data-* 属性,并对选择器值调用 CSS.escape()。
getBoundingClientRect() 不能直接当 SVG 坐标
第一次实现最容易写成这样:
const fromRect = fromElement.getBoundingClientRect()
lines.push({
x1: fromRect.left + fromRect.width / 2,
y1: fromRect.top + fromRect.height / 2,
// ...
})
没有缩放时可能看起来正常,一旦外层执行 transform: scale(1.5),线就会错位。
原因很明确:
getBoundingClientRect()返回视口坐标;- 返回的宽高已经包含 CSS transform 的缩放;
- SVG path 使用的是关系图内部的局部坐标。
把“缩放后的视口坐标”直接写进“缩放前的局部坐标系”,必然错位。
正确做法是同时读取父容器和目标元素的矩形,再把差值除以实际缩放比例:
interface ElementBox {
x: number
y: number
width: number
height: number
}
function getRelativePosition(
parent: HTMLElement,
target: HTMLElement,
): ElementBox {
const parentRect = parent.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const scaleX = parent.offsetWidth
? parentRect.width / parent.offsetWidth
: 1
const scaleY = parent.offsetHeight
? parentRect.height / parent.offsetHeight
: 1
return {
x: (targetRect.left - parentRect.left) / scaleX
- parent.clientLeft
+ parent.scrollLeft,
y: (targetRect.top - parentRect.top) / scaleY
- parent.clientTop
+ parent.scrollTop,
width: targetRect.width / scaleX,
height: targetRect.height / scaleY,
}
}
这里的比例来自:
parentRect.width / parent.offsetWidth
parentRect.width 是变换后的视觉宽度,offsetWidth 是布局宽度。两者相除,就得到当前 X 方向的实际缩放比例。Y 方向同理。
最终取接入点中心:
const from = getRelativePosition(graph, fromPort)
const to = getRelativePosition(graph, toPort)
const line: RelationGraphLine = {
x1: from.x + from.width / 2,
y1: from.y + from.height / 2,
x2: to.x + to.width / 2,
y2: to.y + to.height / 2,
}
还有一种方案:直接读取 PanZoom 当前的 scale,手动反算坐标。我没有这么做,因为这会让关系图依赖 PanZoom。现在的实现只观察真实 DOM 变换,即使将来外层换成别的缩放容器,关系图仍然能工作。
从直角折线到带圆角的 Z 形线
已知起点和终点后,最简单的 Z 形直角线只需要四个点:
const middleX = (line.x1 + line.x2) / 2
const points = [
{ x: line.x1, y: line.y1 },
{ x: middleX, y: line.y1 },
{ x: middleX, y: line.y2 },
{ x: line.x2, y: line.y2 },
]
直接生成 path:
M 起点
L 第一个拐点
L 第二个拐点
L 终点
能用,但拐角太硬。要做圆角,不能让直线一直画到拐点,而是在拐点前停下,再用二次贝塞尔曲线绕过去。
假设连续三个点是 previous -> corner -> next:
- 从
corner朝previous移动一小段,得到cornerStart; - 从
corner朝next移动一小段,得到cornerEnd; - 直线画到
cornerStart; - 以原始
corner为控制点,画Q corner cornerEnd。
先写两个向量工具:
interface Point {
x: number
y: number
}
function distance(from: Point, to: Point): number {
return Math.hypot(to.x - from.x, to.y - from.y)
}
function moveTowards(from: Point, to: Point, length: number): Point {
const segmentLength = distance(from, to)
if (segmentLength === 0) return from
const ratio = length / segmentLength
return {
x: from.x + (to.x - from.x) * ratio,
y: from.y + (to.y - from.y) * ratio,
}
}
完整路径算法如下:
const CORNER_RADIUS = 8
function getRoundedOrthogonalPath(line: RelationGraphLine): string {
const middleX = (line.x1 + line.x2) / 2
const points: Point[] = [
{ x: line.x1, y: line.y1 },
{ x: middleX, y: line.y1 },
{ x: middleX, y: line.y2 },
{ x: line.x2, y: line.y2 },
]
let path = `M ${points[0].x} ${points[0].y}`
for (let index = 1; index < points.length - 1; index += 1) {
const previous = points[index - 1]
const corner = points[index]
const next = points[index + 1]
const radius = Math.min(
CORNER_RADIUS,
distance(previous, corner) / 2,
distance(corner, next) / 2,
)
const cornerStart = moveTowards(corner, previous, radius)
const cornerEnd = moveTowards(corner, next, radius)
path += ` L ${cornerStart.x} ${cornerStart.y}`
path += ` Q ${corner.x} ${corner.y} ${cornerEnd.x} ${cornerEnd.y}`
}
const end = points[points.length - 1]
return `${path} L ${end.x} ${end.y}`
}
radius 不能永远等于 8。两段线很短时,圆角半径如果超过线段长度的一半,曲线会越过相邻端点。这里取三个值的最小值,把半径限制在线段可容纳的范围内。
路径会同时用于视觉线和命中线,因此最好先计算一次:
const renderedLines = computed(() => props.lines.map((line, index) => ({
key: `${line.x1}-${line.y1}-${line.x2}-${line.y2}-${index}`,
line,
path: getRoundedOrthogonalPath(line),
})))
不要在模板中的两个 <path> 上分别调用路径函数。函数虽然不重,但重复计算没有意义,也让模板难读。
细线难以悬浮:把命中区域和视觉区域分开
一条 1px 的线几乎无法稳定悬浮。直接把线加粗又会破坏视觉。
SVG 的处理方式很实用:同一路径画两遍。底层 path 透明但很宽,只负责鼠标命中;上层 path 保持细线,只负责显示。
<g
v-for="item in renderedLines"
:key="item.key"
class="edge-group"
>
<path
class="edge-hit-area"
:d="item.path"
fill="none"
stroke="transparent"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
class="edge-path"
:d="item.path"
fill="none"
:style="{ stroke: item.line.lineColor }"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
.edge-hit-area {
pointer-events: stroke;
stroke-width: 12px;
}
.edge-path {
pointer-events: none;
stroke: var(--edge-color);
stroke-width: 1;
transition: filter 200ms ease, stroke-width 200ms ease;
}
.edge-group:hover .edge-path {
filter: brightness(75%);
stroke-width: 3;
}
SVG 根节点可以设置 pointer-events: none,再只给透明命中线开启 pointer-events: stroke。这样 SVG 覆盖整个关系图也不会挡住节点按钮。
图层也要一次定清楚:边为 1,节点为 2,Port 为 3。不要为了压住 SVG 给按钮写 z-index: 9999;极端层级只会让组件放进弹窗、浮层后更难维护。
只 watch(data),监听不到插槽内容变高
关系数据变化时重算连线是必要的:
watch(
() => props.data,
async () => {
await nextTick()
updateLines()
},
{ deep: true, immediate: true },
)
但这远远不够。下面这些变化都可能不修改关系数据:
- 卡片异步加载了一段文字;
- 标签换行导致节点变高;
- 插槽里切换了一个区域;
- 字体、窗口或父容器尺寸改变;
- 节点执行显隐动画。
这类变化必须从 DOM 层监听。
ResizeObserver 负责几何变化
关系图可以观察自身和每一列节点容器:
const observedElements = shallowRef<HTMLElement[]>([])
function collectObservedElements(): void {
const graph = graphRef.value
if (!graph) {
observedElements.value = []
return
}
observedElements.value = [
graph,
...graph.querySelectorAll<HTMLElement>('.relation-graph__column'),
]
}
useResizeObserver(observedElements, scheduleLinesUpdate)
为什么观察“列”而不是给每个卡片都创建一个 Observer?因为列的宽高已经能反映内部节点尺寸变化,观察目标更少。这个选择依赖当前列布局;如果节点是绝对定位,列尺寸不会随节点变化,就要改为观察节点本身。
MutationObserver 负责 DOM 结构变化
ResizeObserver 不负责告诉我们“新增了一列”。当插槽内容或节点树发生增删时,需要重新收集观察目标:
useMutationObserver(
graphRef,
async () => {
await nextTick()
collectObservedElements()
scheduleLinesUpdate()
},
{
childList: true,
characterData: true,
subtree: true,
},
)
两者职责不同:
ResizeObserver:几何尺寸变了;MutationObserver:DOM 内容或结构变了;watch(data):Vue 数据关系变了。
requestAnimationFrame 合并同一帧更新
一次 DOM 更新可能同时触发 watch、MutationObserver 和多个 ResizeObserver 回调。如果每次都读取所有节点的 getBoundingClientRect(),会制造重复布局读取。
用一个 animation frame 合并:
let updateFrame: number | undefined
function scheduleLinesUpdate(): void {
if (updateFrame !== undefined) {
cancelAnimationFrame(updateFrame)
}
updateFrame = requestAnimationFrame(() => {
updateFrame = undefined
lines.value = collectLines()
})
}
onScopeDispose(() => {
if (updateFrame !== undefined) {
cancelAnimationFrame(updateFrame)
}
})
这不是传统的固定时间防抖。它只保证同一渲染帧最多计算一次,既不会平白增加 100ms、300ms 的延迟,也能避免连续触发布局读取。
PanZoom 的核心只有一个 transform
关系图完成后,平移缩放组件反而应该保持简单。它维护三个状态:
interface PanZoomTransform {
x: number
y: number
scale: number
}
const transform = reactive<PanZoomTransform>({
x: 0,
y: 0,
scale: 1,
})
内容始终以左上角为变换原点:
const contentStyle = computed(() => ({
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
}))
.pan-zoom__content {
position: absolute;
top: 0;
left: 0;
width: max-content;
height: max-content;
transform-origin: 0 0;
will-change: transform;
}
transform-origin: 0 0 很关键。后面的缩放公式都假设世界坐标原点位于内容左上角。如果使用默认中心点,平移量还会额外受到 transform origin 影响。
拖拽不要累加 movementX,保存起点更稳
按下鼠标时保存“指针起点”和“变换起点”:
interface PanStart {
pointer: { x: number; y: number }
transform: PanZoomTransform
}
function startPan(event: PointerEvent): void {
panStart = {
pointer: { x: event.clientX, y: event.clientY },
transform: { ...transform },
}
}
移动时用当前位置减起点:
function movePan(event: PointerEvent): void {
if (!panStart) return
applyTransform({
x: panStart.transform.x + event.clientX - panStart.pointer.x,
y: panStart.transform.y + event.clientY - panStart.pointer.y,
scale: panStart.transform.scale,
})
}
这样不会不断累加浮点误差,也不依赖 movementX/movementY 在不同设备上的具体行为。
组件使用 Pointer Events,而不是分别写 mouse 和 touch 事件。setPointerCapture(pointerId) 可以保证指针拖出容器后仍然收到后续事件:
viewport.setPointerCapture(event.pointerId)
同时给视口设置:
.pan-zoom {
overflow: hidden;
cursor: grab;
touch-action: none;
}
touch-action: none 用来阻止浏览器把触摸手势抢去做页面滚动或系统缩放。
缩放必须围绕鼠标位置,而不是容器左上角
如果缩放时只修改 scale,内容会一直围绕原点变化,用户鼠标下的节点会“逃走”。正确的体验是:缩放前后,鼠标指向的世界坐标保持在原地。
设鼠标在视口内的位置为 viewportPosition。先把它反算成世界坐标:
const worldX = (viewportPosition.x - transform.x) / transform.scale
const worldY = (viewportPosition.y - transform.y) / transform.scale
得到新缩放值后,反推新的平移量:
function zoomAt(
nextScale: number,
viewportPosition: { x: number; y: number },
): void {
const scale = clampScale(nextScale)
if (scale === transform.scale) return
const worldX = (viewportPosition.x - transform.x) / transform.scale
const worldY = (viewportPosition.y - transform.y) / transform.scale
applyTransform({
x: viewportPosition.x - worldX * scale,
y: viewportPosition.y - worldY * scale,
scale,
})
}
这个公式是整个 PanZoom 最重要的一段。它同样可以用于双指缩放:把双指中点当作 viewportPosition,把两指距离变化换算成新 scale。
滚轮缩放时,要先把 clientX/clientY 转成视口局部坐标:
function handleWheel(event: WheelEvent): void {
const viewport = viewportRef.value
if (!viewport) return
event.preventDefault()
const rect = viewport.getBoundingClientRect()
const scaleFactor = Math.exp(-event.deltaY * 0.0015)
zoomAt(transform.scale * scaleFactor, {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
})
}
滚轮监听必须使用 { passive: false },否则浏览器不允许在回调中 preventDefault():
useEventListener(viewportRef, 'wheel', handleWheel, { passive: false })
实际项目中还要处理 WheelEvent.deltaMode。按像素、按行、按页滚动时,deltaY 的量纲不同,先归一化再计算缩放因子,触控板和鼠标滚轮的手感才不会差太多。
适应视图不是简单的 scale(1)
“适应视图”需要同时满足:
- 内容完整放进视口;
- 四周留出 padding;
- 不超过允许的缩放范围;
- 默认不把小内容放大到超过 100%。
function fitView(): void {
const viewport = viewportRef.value
const content = contentRef.value
if (!viewport || !content) return
if (!content.offsetWidth || !content.offsetHeight) return
const padding = Math.max(0, props.fitPadding)
const availableWidth = Math.max(0, viewport.clientWidth - padding * 2)
const availableHeight = Math.max(0, viewport.clientHeight - padding * 2)
const scale = clampScale(Math.min(
1,
availableWidth / content.offsetWidth,
availableHeight / content.offsetHeight,
))
center(scale)
}
function center(scale: number): void {
const viewport = viewportRef.value
const content = contentRef.value
if (!viewport || !content) return
applyTransform({
x: (viewport.clientWidth - content.offsetWidth * scale) / 2,
y: (viewport.clientHeight - content.offsetHeight * scale) / 2,
scale,
})
}
初始化不能只依赖 onMounted。组件挂载时,外层容器可能还没有尺寸,插槽内容也可能尚未完成布局。可以同时在 mounted 和 ResizeObserver 中尝试初始化,并用标记保证只执行一次:
const hasInitialized = shallowRef(false)
function initializeView(): void {
if (hasInitialized.value) return
if (!hasValidViewportAndContentSize()) return
hasInitialized.value = true
fitView()
}
useResizeObserver([viewportRef, contentRef], initializeView)
onMounted(async () => {
await nextTick()
initializeView()
})
要特别区分“首次适应视图”和“持续自动适应”。如果每次内容尺寸变化都执行 fitView(),用户刚拖到想看的位置,异步内容一加载,画布又会跳回中心。更合理的默认行为是只在首次获得有效尺寸时适应一次,后续是否重新适应交给调用方决定。
不要让卡片按钮触发画布拖拽
节点里通常会有按钮、输入框或展开开关。用户点击按钮时,PanZoom 不应该开始拖动画布。
我使用一个可配置的忽略选择器:
<button data-pan-zoom-ignore>展开分支</button>
function shouldIgnorePointer(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
try {
return Boolean(target.closest('[data-pan-zoom-ignore]'))
}
catch {
return false
}
}
判断要用 closest(),不能只检查 event.target.matches()。按钮内部可能还有图标或文字节点,用户实际点击的 target 不一定是按钮本身。
控制栏也放在 transform 内容之外,否则控制按钮会和关系图一起被放大、缩小。它只属于视口 UI,不属于世界坐标。
节点收起时,布局是否重排要先决定
隐藏节点有两种完全不同的产品语义:
display: none:节点退出布局,后面的节点立即补位;visibility + opacity:节点保留布局,只做视觉隐藏。
我选择第二种:
.relation-graph__node {
visibility: visible;
opacity: 1;
scale: 1;
transition: opacity 200ms ease, scale 200ms ease, visibility 0s linear;
}
.relation-graph__node--hidden {
visibility: hidden;
opacity: 0;
scale: 0.96;
pointer-events: none;
transition:
opacity 200ms ease,
scale 200ms ease,
visibility 0s linear 200ms;
}
保留布局的好处是展开、收起时其他节点不会整体跳动,用户更容易保持空间记忆。代价是隐藏分支仍然占位置。如果产品要求收起后压缩空白,就应该用 display: none 或从当前层数据中过滤隐藏节点,并接受整张图重新布局。
连线计算时直接跳过隐藏节点即可:
if (child.hidden) continue
生产组件还应补一段 prefers-reduced-motion,让选择减少动态效果的用户关闭缩放淡出动画。
两个组件组合起来
下面是一份脱敏后的最小使用示例。业务卡片仍然是普通 DOM,关系图负责连线,PanZoom 负责观察和操作整个关系图:
<template>
<div class="graph-page">
<PanZoom :fit-padding="32" :min-scale="0.25" :max-scale="2.5">
<RelationGraph :data="graphData">
<template #default="node">
<article class="card" data-pan-zoom-ignore>
<RelationGraphPort
v-if="node.portClass"
:class="node.portClass"
class="card__input"
/>
<strong>{{ node.data?.title }}</strong>
<p>{{ node.data?.description }}</p>
<RelationGraphPort
v-if="node.children?.length"
class="output-port card__output"
/>
</article>
</template>
</RelationGraph>
</PanZoom>
</div>
</template>
<script setup lang="ts">
interface CardData {
title: string
description: string
}
const graphData = reactive<RelationGraphData<CardData>[]>([
{
key: 'request',
data: {
title: '提交申请',
description: '创建一条待处理记录',
},
children: [
{
key: 'review',
fromPortClass: 'output-port',
portClass: 'input-port',
data: {
title: '审核',
description: '校验申请内容',
},
children: [
{
key: 'approved',
fromPortClass: 'output-port',
portClass: 'input-port',
lineColor: '#2ba471',
data: {
title: '通过',
description: '进入后续处理',
},
},
{
key: 'rejected',
fromPortClass: 'output-port',
portClass: 'input-port',
lineColor: '#d54941',
data: {
title: '驳回',
description: '退回申请人修改',
},
},
],
},
],
},
])
</script>
<style scoped>
.graph-page {
height: 600px;
}
.card {
position: relative;
width: 220px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
.card__input,
.card__output {
position: absolute;
top: 50%;
}
.card__input {
left: 0;
transform: translate(-50%, -50%);
}
.card__output {
right: 0;
transform: translate(50%, -50%);
}
</style>
示例里的颜色是演示值。真实项目最好使用自己的设计 token,不要把颜色、圆角和间距散落在组件内部。
这套实现的边界
这套方案适合:
- 几十到几百个节点的关系展示;
- 树状或分层结构;
- 节点内容复杂,需要继续使用 HTML 和 Vue 组件;
- 需要平移、缩放、展开收起,但不需要编辑画布。
它不适合:
- 上千节点的高密度图;
- 任意拖动单个节点并持久化坐标;
- 自动避障、端口吸附、拖拽创建连线;
- 框选、多选、复制粘贴、撤销重做;
- 复杂有向图自动布局。
节点数量变大后,当前实现有两个明显成本:
- 重算连线时会查询 DOM,并读取多个
getBoundingClientRect(); - 任何列尺寸变化都会重新计算全部边。
继续优化可以沿着几个明确方向走:
- 渲染节点时注册 DOM 引用,用
Map<key, HTMLElement>替代重复的querySelector; - 建立“节点到边”的索引,只重算受影响的边;
- 将路径生成抽成策略接口,支持 Z 形、L 形和曲线路径;
- 大规模场景使用虚拟化、Canvas 或专门的图引擎;
- 需要自动布局时,引入 Dagre/ELK,只让它负责坐标计算,不必放弃现有 DOM 节点。
最后留下三条结论
这次实现里,真正容易踩坑的不是 Vue,也不是 SVG path 语法,而是三套状态之间的边界:
第一,关系数据只描述“谁连接谁”,节点尺寸交给 DOM,线条坐标统一换算到关系图局部坐标。
第二,getBoundingClientRect() 是视口坐标,而且包含 transform。外层存在缩放时,必须消除缩放比例后才能交给内部 SVG。
第三,PanZoom 只维护 x/y/scale,缩放时保证鼠标下的世界坐标不变。关系图不读取 PanZoom 状态,两者通过真实 DOM 自然组合。
把这三条边界守住,剩下的递归渲染、圆角折线、命中区域、Observer 和动画,都只是可以逐步替换的实现细节。组件也不会因为某张业务卡片换了结构,就被迫跟着重写。
Comments | 0条评论