X6 分组(Group)实战指南:从父子嵌套到展开/折叠的完整实现
2026/9/17 7:22:48 网站建设 项目流程

X6 分组(Group)实战指南:从父子嵌套到展开/折叠的完整实现

【免费下载链接】X6🚀 JavaScript diagramming library that uses SVG and HTML for rendering.项目地址: https://gitcode.com/GitHub_Trending/x6/X6

导读

在 X6 图编辑应用中,分组是组织复杂图结构的基础能力——把若干节点收纳进一个父节点,形成可整体移动、可限制边界、可折叠收纳的层级结构。本文基于 X6 官方教程《Group》(见 site/docs/tutorial/intermediate/group.en.md),结合仓库内的完整示例源码与底层实现,系统讲解五类核心场景:如何通过父子关系分组节点、如何通过拖拽交互把节点嵌入分组、如何限制子节点移动范围、如何让父节点随子节点自动扩展/收缩、以及如何实现分组的展开与折叠。读完本文,你将能够在自己的 X6 应用中完整落地一套可交互、可折叠的分组方案。


一、分组节点:基于父子关系建模

1.1 父子关系 API 概览

X6 中分组不是一种独立的图元类型,而是通过**父子关系(Parent/Child Relationship)**在任意 Cell(节点或边)之间建立的层级结构。这套关系在模型层由Cell基类提供,核心方法定义在 src/model/cell.ts:

方法作用源码位置
addChild(child)将指定 Cell 添加为当前 Cell 的子级src/model/cell.ts#L1143
getParent()获取当前 Cell 的父级src/model/cell.ts#L927
getChildren()获取当前 Cell 的全部直接子级src/model/cell.ts#L948
getDescendants(options)递归获取所有后代(支持深度优先/广度优先)src/model/cell.ts#L1011
getAncestors(options)沿父链向上获取所有祖先src/model/cell.ts#L1001
isChildOf(cell)/isDescendantOf(cell)判断层级关系src/model/cell.ts#L969、src/model/cell.ts#L1042

从源码实现看,addChild在建立关系时会处理"旧父级"的移除与"新父级"的子列表维护(src/model/cell.ts#L1153-L1166),因此父子关系始终是一棵严格的树结构;getDescendants默认深度优先遍历,也可通过{ breadthFirst: true }切换为广度优先(src/model/cell.ts#L1014-L1036),折叠分组时需要递归操作后代,正是靠这套 API。

1.2 最小可运行示例:移动父节点带动子节点

下面这段代码来自官方示例 site/src/tutorial/intermediate/group/embed-edge/index.tsx,它演示了分组的两个核心行为:

import { Graph } from '@antv/x6' Graph.registerNode( 'custom-group-node', { inherit: 'rect', width: 100, height: 40, attrs: { body: { stroke: '#8f8f8f', strokeWidth: 1, fill: '#fff', rx: 6, ry: 6, }, }, }, true, ) const graph = new Graph({ container: this.container, background: { color: '#F2F7FA' }, }) const source = graph.addNode({ shape: 'custom-group-node', x: 60, y: 100, label: 'Child\n(inner)', zIndex: 2, }) const target = graph.addNode({ shape: 'custom-group-node', x: 420, y: 80, label: 'Child\n(outer)', zIndex: 2, }) const parent = graph.addNode({ shape: 'custom-group-node', x: 40, y: 40, width: 360, height: 160, zIndex: 1, label: 'Parent\n(try to move me)', }) // 通过父子关系建立分组 parent.addChild(source) parent.addChild(target) graph.addEdge({ source, target, vertices: [ { x: 120, y: 60 }, { x: 200, y: 100 }, ], attrs: { line: { stroke: '#8f8f8f', strokeWidth: 1 }, }, })

从这个示例可以得到两个关键结论:

  • 移动父节点会连带移动其所有子节点,即使子节点(如示例中的Child (outer))位于父节点边界之外——父子关系是逻辑上的归属,与几何上的包含与否无关;
  • 边的父级默认是其两端节点(terminals)的共同父级。示例中连接sourcetarget的边自动归属于parent,因此移动父节点时,边的顶点(vertices)也会随之移动,保证连线始终跟随。这是 X6 的默认行为,可以通过edgeparent属性显式覆盖。

关于父子关系的更多方法(如getParentgetChildrenisParentOf等)可参考 site/docs/api/model/cell.md 中的 Parent/Children Relationship 一节。


二、通过交互实现分组:拖拽嵌入(Embedding)

除了代码层面显式调用addChild,X6 还支持交互式分组:用户直接把一个节点拖进另一个节点内部,即自动建立父子关系。

2.1 启用embedding并实现findParent

在创建Graph实例时开启embedding配置,并实现findParent回调来判定"哪个节点可以作为父级"。官方示例位于 site/src/tutorial/basic/interacting/embedding/index.tsx:

const graph = new Graph({ container: this.container, background: { color: '#F2F7FA' }, embedding: { enabled: true, // 在拖拽过程中返回候选父节点 findParent({ node }) { const bbox = node.getBBox() return this.getNodes().filter((node) => { const data = node.getData<{ parent: boolean }>() if (data && data.parent) { const targetBBox = node.getBBox() // 用包围盒相交判断"是否拖进了候选父节点" return bbox.isIntersectWithRect(targetBBox) } return false }) }, }, }) graph.addNode({ shape: 'custom-node', x: 200, y: 80, width: 240, height: 160, zIndex: 1, label: 'Parent', data: { parent: true }, // 通过 data 标记这是一个可被嵌入的父容器 })

要点说明:

  • findParent返回的节点数组会作为当前拖拽节点的候选父级;示例用data.parent标记哪些节点允许成为父级,再用bbox.isIntersectWithRect(targetBBox)判断被拖节点是否与候选父节点相交(此处基于 src/geometry/rectangle.ts 提供的包围盒几何工具);
  • 当嵌入关系建立后,会触发node:change:parent事件。示例通过监听该事件把子节点标签从Child\n(unembed)改为Child\n(embed),用于即时反馈嵌入成功:
graph.on('node:change:parent', ({ node }) => { node.attr({ label: { text: 'Child\n(embed)', }, }) })
  • 完整的embedding配置项(如validatefindParent等)参见 site/docs/api/interacting/interacting.md 的 Embedding 一节。

2.2 拖拽嵌入的事件流

从仓库示例与 src/graph/events.ts 的交互事件定义可以推断,一次完整的拖拽嵌入会依次触发:

  1. node:embedding—— 拖拽进行中、正在判断可嵌入性时触发;
  2. node:embedded—— 拖拽结束、嵌入关系确立后触发;
  3. node:change:parent—— 父子关系变更事件。

在下一节"自动扩展父节点"的示例中,正是利用node:embedding读取按键状态、利用node:embedded复位状态,说明这几个事件在实践中非常有用。


三、限制子节点移动范围:translating.restrict

如果希望子节点只能在父节点范围内拖动,无需写任何移动逻辑,只需在创建Graph时配置translating.restrict。官方示例见 site/src/tutorial/intermediate/group/restrict/index.tsx:

const graph = new Graph({ container: this.container, background: { color: '#F2F7FA' }, translating: { // view 为当前正在平移的 Cell 视图 restrict(view) { if (view) { const cell = view.cell if (cell.isNode()) { const parent = cell.getParent() if (parent) { // 将父节点的包围盒作为限制区域 return parent.getBBox() } } } return null // 没有父节点时不限制 }, }, }) const child = graph.addNode({ shape: 'custom-group-node', x: 100, y: 60, label: 'Child', zIndex: 2, }) const parent = graph.addNode({ shape: 'custom-group-node', x: 40, y: 40, width: 240, height: 160, zIndex: 1, label: 'Parent\n(try to move me)', }) parent.addChild(child)

实现细节与底层依据:

  • restrict的类型定义为boolean | OptionItem<CellView | null, RectangleLike | number | null>(见 src/graph/options.ts#L247),即既可以直接传true(默认行为是限制在父节点内,见 src/graph/options.ts#L461-L462 中translating.restrict的默认值),也可以传一个函数,返回:
    • RectangleLike:子节点被限制在该矩形范围内;
    • number:作为内边距(padding),在父节点基础上向内收缩;
    • null:不限制。
  • 上面的示例返回parent.getBBox(),效果就是子节点无法被拖出父节点边界;
  • 该限制只作用于平移(translating),不影响节点创建、缩放等其他操作,配置结构见 src/graph/options.ts#L96-L106。

四、父节点自动扩展/收缩(Auto Expand & Shrink)

当子节点较多或需要"拖到哪、父容器跟到哪"时,可以监听node:change:position事件,实时重算父节点的位置与尺寸,使其始终完整包住所有子节点。官方示例见 site/src/tutorial/intermediate/group/expand-shrink/index.tsx,其核心思路如下。

4.1 记录父节点的"原点"状态

由于父节点本身也会被重新定位,示例在node:change:sizenode:change:position中先把父节点的原始尺寸与位置暂存到自定义属性originSize/originPosition中,供后续重算使用:

graph.on('node:change:size', ({ node, options }) => { // skipParentHandler 标记表示本次变更由父级调整逻辑自己发起,需跳过 if (options.skipParentHandler) { return } const children = node.getChildren() if (children && children.length) { node.prop('originSize', node.getSize()) } }) graph.on('node:change:position', ({ node, options }) => { if (options.skipParentHandler || ctrlPressed) { return } const children = node.getChildren() if (children && children.length) { node.prop('originPosition', node.getPosition()) } // ... 重算父节点包围盒(见下文) })

这里用到了两个防回环手段:

  • ctrlPressed:示例中按住Ctrl/Meta键拖拽时不做自动扩展(通过node:embedding/node:embedded事件维护该状态),避免交互与自动布局互相干扰;
  • options.skipParentHandler:父节点调整自身时传入的自定义选项,防止再次进入同一处理函数造成死循环。

4.2 重算父节点的包围盒

当某个有父节点的节点位置变化时,遍历父节点的所有子节点,用getBBox().inflate(this.embedPadding)(向外扩张 20px 内边距,示例中该值可通过右侧设置面板实时调整)算出最小外接矩形,然后一次性更新父节点的positionsize

graph.on('node:change:position', ({ node, options }) => { if (options.skipParentHandler || ctrlPressed) { return } const parent = node.getParent() if (parent && parent.isNode()) { // 读取或初始化 originSize / originPosition let originSize = parent.prop('originSize') if (originSize == null) { originSize = parent.getSize() parent.prop('originSize', originSize) } let originPosition = parent.prop('originPosition') if (originPosition == null) { originPosition = parent.getPosition() parent.prop('originPosition', originPosition) } let x = originPosition.x let y = originPosition.y let cornerX = originPosition.x + originSize.width let cornerY = originPosition.y + originSize.height let hasChange = false const children = parent.getChildren() if (children) { children.forEach((child) => { const bbox = child.getBBox().inflate(this.embedPadding) const corner = bbox.getCorner() if (bbox.x < x) { x = bbox.x hasChange = true } if (bbox.y < y) { y = bbox.y hasChange = true } if (corner.x > cornerX) { cornerX = corner.x hasChange = true } if (corner.y > cornerY) { cornerY = corner.y hasChange = true } }) } if (hasChange) { parent.prop( { position: { x, y }, size: { width: cornerX - x, height: cornerY - y }, }, // 关键:标记本次变更来自父级调整,避免递归触发 { skipParentHandler: true }, ) } } })

完整示例(含可调内边距的设置面板)位于 site/src/tutorial/intermediate/group/expand-shrink/index.tsx 与配套的 settings.tsx。

4.3 两种监听事件的取舍

  • node:change:position:子节点位置变化时同步扩张/收缩父节点,适合"子动父随"的容器型分组;
  • node:change:size:子节点尺寸变化时同步更新父节点记录的原点尺寸,保证重算基准不失效。

两者配合使用才能覆盖"拖动"与"缩放"两类操作,这也是示例同时监听两个事件的原因。


五、分组展开/折叠(Collapse & Expand)

最复杂的场景是让分组可以折叠:折叠后父节点缩小成一个条,并隐藏所有后代节点。实现分为两步:自定义折叠按钮节点+监听自定义事件控制显隐

5.1 定义自定义Group节点

首先继承Node派生一个Group类,维护collapsed状态与折叠前尺寸expandSize,并实现isCollapsed()toggleCollapse()两个方法。完整源码见 site/src/tutorial/intermediate/group/collapsable/shape.ts:

import { Node } from '@antv/x6' export class Group extends Node { private collapsed: boolean = false private expandSize: { width: number; height: number } protected postprocess() { this.toggleCollapse(false) } isCollapsed() { return this.collapsed } toggleCollapse(collapsed?: boolean) { const target = collapsed == null ? !this.collapsed : collapsed if (target) { // 折叠:按钮变为 "+" 号,并记住展开尺寸,缩到 100 x 32 this.attr('buttonSign', { d: 'M 1 5 9 5 M 5 1 5 9' }) this.expandSize = this.getSize() this.resize(100, 32) } else { // 展开:按钮变为 "-" 号,恢复到记忆的尺寸 this.attr('buttonSign', { d: 'M 2 5 8 5' }) if (this.expandSize) { this.resize(this.expandSize.width, this.expandSize.height) } } this.collapsed = target } }

然后通过Group.config声明节点的 markup 与 attrs:body(矩形背景)、label(文本)、以及左上角的buttonGroup(内含button矩形按钮与buttonSign符号路径)。关键点是给button的 attrs 配置event: 'node:collapse',让点击该矩形时触发名为node:collapse的自定义事件:

Group.config({ markup: [ { tagName: 'rect', selector: 'body' }, { tagName: 'text', selector: 'label' }, { tagName: 'g', selector: 'buttonGroup', children: [ { tagName: 'rect', selector: 'button', attrs: { 'pointer-events': 'visiblePainted' }, }, { tagName: 'path', selector: 'buttonSign', attrs: { fill: 'none', 'pointer-events': 'none' }, }, ], }, ], attrs: { body: { refWidth: '100%', refHeight: '100%', stroke: 'none', fill: '#fff', }, buttonGroup: { refX: 8, refY: 8 }, button: { height: 14, width: 16, rx: 2, ry: 2, fill: '#f5f5f5', stroke: '#ccc', cursor: 'pointer', event: 'node:collapse', // 自定义事件,点击按钮触发 }, buttonSign: { refX: 3, refY: 2, stroke: '#808080' }, label: { fontSize: 12, fill: '#fff', refX: 32, refY: 10 }, }, })

值得注意的实现细节:

  • button需要pointer-events: visiblePainted才能接收点击,而装饰性的buttonSign设置为pointer-events: none,避免符号路径抢走点击事件;
  • 按钮符号用 SVG path 的d属性控制:折叠时显示M 1 5 9 5 M 5 1 5 9(加号),展开时显示M 2 5 8 5(减号)。

5.2 监听node:collapse控制子节点显隐

接着在graph上监听node:collapse事件:切换折叠状态后,遍历该节点的所有后代并批量hide()/show()。文档示例:

graph.on('node:collapse', ({ node }: { node: Group }) => { node.toggleCollapse() const collapsed = node.isCollapsed() const cells = node.getDescendants() cells.forEach((node) => { if (collapsed) { node.hide() } else { node.show() } }) })

这里用到的getDescendants()正是 src/model/cell.ts#L1011 中的深度优先遍历实现——它会递归收集所有后代节点(而不只是直接子级),因此嵌套多层的分组也能被一次性全部隐藏。

5.3 嵌套分组的折叠:递归处理

官方示例 site/src/tutorial/intermediate/group/collapsable/index.tsx 中构造了三层嵌套分组(aaaaaa)以及挂在分组上的边,折叠处理也因此升级为递归版本,保证"折叠外层时,内层已折叠的分组不会被误展开":

graph.on('node:collapse', ({ node }: { node: Group }) => { node.toggleCollapse() const collapsed = node.isCollapsed() const collapse = (parent: Group) => { const cells = parent.getChildren() if (cells) { cells.forEach((cell) => { if (collapsed) { cell.hide() } else { cell.show() } // 若子级仍是分组且未被折叠,则继续递归处理 if (cell instanceof Group) { if (!cell.isCollapsed()) { collapse(cell) } } }) } } collapse(node) })

示例场景还验证了另一个细节:边也可以作为分组节点的一部分被隐藏/显示。代码中通过aa.addChild(createEdge('edge2', 'aa', 'aaa', [...]))把边加进了分组aa,折叠aa时这条边同样会被隐藏,展开时恢复。


六、常见问题与最佳实践小结

  • 子节点在父节点外怎么办?分组关系与几何位置无关,子节点可以完全位于父节点边界之外(见第一节示例中的Child (outer));若希望子节点始终在父内,结合第三节的translating.restrict限制拖拽范围,再配合第四节的自动扩展即可实现"既限制又跟随"的容器效果。
  • 移动父节点,边是否跟随?默认边归属于两端节点的共同父级,父节点移动时边的顶点一起移动;如需独立控制,可在创建边时显式指定parent
  • 折叠后如何恢复展开?toggleCollapse在折叠前用expandSize记住原尺寸,展开时恢复;若折叠状态由外部数据初始化,可通过postprocess()钩子在节点实例化后调用一次toggleCollapse(false)同步按钮符号与尺寸(见 shape.ts)。
  • 事件循环风险:node:change:position/node:change:size中调整父节点时,务必通过自定义选项(如示例的skipParentHandler)标记"本次变更由自己发起",否则会形成"变更 → 触发事件 → 再次变更"的无限循环。
  • 性能提示:折叠/展开涉及对全部后代调用hide()/show(),当分组层级很深、节点很多时,建议结合getDescendants({ deep: false })仅处理直接子级,或按需限制递归深度(见 src/model/cell.ts#L1011-L1040 的deep选项)。

七、延伸阅读

  • 官方教程原文:group.en.md
  • 父子关系完整 API:cell.md(Parent/Children Relationship 一节)
  • 交互配置(embedding、translating 等):interacting.md
  • 全部示例源码:
    • 嵌入与跟随:embed-edge/index.tsx
    • 拖拽嵌入:embedding/index.tsx
    • 限制移动:restrict/index.tsx
    • 自动扩展:expand-shrink/index.tsx
    • 折叠分组:collapsable/index.tsx 与 shape.ts
  • 底层实现:
    • 父子关系与后代遍历:src/model/cell.ts
    • translating.restrict配置类型与默认值:src/graph/options.ts
    • 交互事件定义:src/graph/events.ts

【免费下载链接】X6🚀 JavaScript diagramming library that uses SVG and HTML for rendering.项目地址: https://gitcode.com/GitHub_Trending/x6/X6

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询