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

📅 发布时间:2026/9/17 7:22:58
X6 分组(Group)实战指南:从父子嵌套到展开/折叠的完整实现
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#L1143getParent()获取当前 Cell 的父级src/model/cell.ts#L927getChildren()获取当前 Cell 的全部直接子级src/model/cell.ts#L948getDescendants(options)递归获取所有后代支持深度优先/广度优先src/model/cell.ts#L1011getAncestors(options)沿父链向上获取所有祖先src/model/cell.ts#L1001isChildOf(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的共同父级。示例中连接source与target的边自动归属于parent因此移动父节点时边的顶点vertices也会随之移动保证连线始终跟随。这是 X6 的默认行为可以通过edge的parent属性显式覆盖。关于父子关系的更多方法如getParent、getChildren、isParentOf等可参考 site/docs/api/model/cell.md 中的 Parent/Children Relationship 一节。二、通过交互实现分组拖拽嵌入Embedding除了代码层面显式调用addChildX6 还支持交互式分组用户直接把一个节点拖进另一个节点内部即自动建立父子关系。2.1 启用embedding并实现findParent在创建Graph实例时开启embedding配置并实现findParent回调来判定哪个节点可以作为父级。官方示例位于 site/src/tutorial/basic/interacting/embedding/index.tsxconst 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配置项如validate、findParent等参见 site/docs/api/interacting/interacting.md 的 Embedding 一节。2.2 拖拽嵌入的事件流从仓库示例与 src/graph/events.ts 的交互事件定义可以推断一次完整的拖拽嵌入会依次触发node:embedding—— 拖拽进行中、正在判断可嵌入性时触发node:embedded—— 拖拽结束、嵌入关系确立后触发node:change:parent—— 父子关系变更事件。在下一节自动扩展父节点的示例中正是利用node:embedding读取按键状态、利用node:embedded复位状态说明这几个事件在实践中非常有用。三、限制子节点移动范围translating.restrict如果希望子节点只能在父节点范围内拖动无需写任何移动逻辑只需在创建Graph时配置translating.restrict。官方示例见 site/src/tutorial/intermediate/group/restrict/index.tsxconst 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 | OptionItemCellView | 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:size和node: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 内边距示例中该值可通过右侧设置面板实时调整算出最小外接矩形然后一次性更新父节点的position与sizegraph.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.tsimport { 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 与 attrsbody矩形背景、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 中构造了三层嵌套分组a→aa→aaa以及挂在分组上的边折叠处理也因此升级为递归版本保证折叠外层时内层已折叠的分组不会被误展开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父子关系完整 APIcell.mdParent/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.tstranslating.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),仅供参考