WSの小屋

最近做了一个关系图:节点是业务卡片,节点之间用带圆角的直角线连接,整张图可以拖动、缩放和一键适应视图。部分节点还能展开、收起,卡片里的文字发生变化后,连线要跟着移动。

这听起来很像流程图编辑器,但需求里没有画布编辑、拖拽连线、框选、撤销重做,也不需要自动布局。真正需要解决的是三个问题:

  1. 保留 HTML 渲染节点的自由度,同时用 SVG 画线;
  2. DOM 尺寸和位置变化后,线必须及时重算;
  3. 图被 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

  1. cornerprevious 移动一小段,得到 cornerStart
  2. cornernext 移动一小段,得到 cornerEnd
  3. 直线画到 cornerStart
  4. 以原始 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,不属于世界坐标。

节点收起时,布局是否重排要先决定

隐藏节点有两种完全不同的产品语义:

  1. display: none:节点退出布局,后面的节点立即补位;
  2. 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 组件;
  • 需要平移、缩放、展开收起,但不需要编辑画布。

它不适合:

  • 上千节点的高密度图;
  • 任意拖动单个节点并持久化坐标;
  • 自动避障、端口吸附、拖拽创建连线;
  • 框选、多选、复制粘贴、撤销重做;
  • 复杂有向图自动布局。

节点数量变大后,当前实现有两个明显成本:

  1. 重算连线时会查询 DOM,并读取多个 getBoundingClientRect()
  2. 任何列尺寸变化都会重新计算全部边。

继续优化可以沿着几个明确方向走:

  • 渲染节点时注册 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条评论