JavaScript is required

叶子节点展开按钮与子节点按需加载

这是一个 relation-graph 示例,展示叶子节点在尚无子节点时也可显示内置展开按钮。点击展开会触发一次性模拟异步加载,通过图实例 API 追加新节点与连线,并重跑树布局让新分支归位。

通过叶子节点展开按钮懒加载子节点

这个示例构建了什么

这个示例构建了一个从左向右展开、占满高度的树形图,其中混合了一条静态分支和一条高亮的懒加载分支。画布初始时包含一个已经填充完成的白色 b 子树,以及一个金色的 c 子树;后者的叶子占位节点 c1c2c3 虽然还没有子节点,但已经显示了展开按钮。

用户可以点击这些占位叶子节点上的内置展开按钮,看到持续一秒的 Loading... 覆盖层,然后观察到三个新生成的子节点出现在被展开节点下方。这个示例的重点不是通用的展开或收起行为,而是如何把一个空叶子节点变成一次性懒加载触发器,并在加载后重新执行布局,让新分支稳定地落到正确位置。

数据是如何组织的

数据直接在 initializeGraph() 中以内联方式组装为一个 RGJsonData 对象,其中包含 rootId: 'a'、一个扁平的 nodes 数组和一个扁平的 lines 数组。树的 b 侧一开始就被完整定义,而 c 侧则包含三个占位叶子节点,这些节点在 node.data 中携带了额外元数据。

在运行时加载发生之前,这里先做了两个轻量级的预处理决定。第一,节点 c1c2c3 被标记为 expandHolderPosition: 'right'expanded: false,这样它们即使没有预加载的后代节点,也能显示 relation-graph 内置的展开占位器。第二,每个占位节点都会在 data 中存储 isNeedLoadDataFromRemoteServerchildrenLoaded 标记,这样展开处理函数就能判断自己是应该拉取更多图数据,还是忽略重复请求。

运行时加载器不会重建整棵树。相反,loadChildNodesFromRemoteServer(...) 会根据被展开的节点 id 生成一个小型 RGJsonData 片段,并在一次 setTimeout(...) 之后返回三个子节点和三条连接线。在真实项目里,相同的数据形态也可以表示远程组织架构分支、按权限裁剪的文件夹树、商品分类钻取视图,或由 API 返回的依赖子图。

relation-graph 是如何使用的

index.tsxRGProvider 包裹整个示例,MyGraph.tsx 则通过 RGHooks.useGraphInstance() 获取实时图实例。图配置使用了从左侧生长的 tree 布局,设置了 treeNodeGapH: 100,将展开占位器保持在右侧,使用矩形节点、RGLineShape.StandardOrthogonal 线条形状,并通过 RGJunctionPoint.lr 锚定连线。这个组合会生成一个紧凑的正交层级结构,让新加入的后代节点从被展开的占位节点处向右延展。

实例 API 同时驱动初始化和运行时变更。组件挂载后会调用 setJsonData(...),随后执行 moveToCenter()zoomToFit(),让初始树形结构立即进入视口。在懒加载过程中,onNodeExpand 处理函数会调用 loading('Loading...'),通过 addNodes(...)addLines(...) 追加新数据,在异步回调完成后执行 doLayout(),最后再调用 clearLoading()。这个示例还启用了 reLayoutWhenExpandedOrCollapsed: true,因此即使异步分支增长是通过显式调用 doLayout() 处理的,展开状态变化本身仍然具备布局感知能力。

这个示例里没有自定义节点插槽、连线插槽、画布插槽、视口插槽,也没有编辑工具。视觉定制被刻意控制在很小范围内:SCSS 文件基本保留了空的包装选择器,只把 .rg-node-expand-button 改成 background-color: var(--rg-node-color),从而让内置展开控件继承各自节点的颜色。因此,金色的懒加载分支也会自动得到金色的展开按钮,而不需要依赖基于插槽的渲染。

关键交互

  • 点击 c1c2c3 上的内置展开按钮时,触发的是一次性懒加载流程,而不是仅仅展开预加载的后代节点。
  • 在模拟远程延迟期间,会显示 Loading... 覆盖层,让用户立刻知道这个分支正在等待数据返回。
  • 当异步回调返回时,图会在被展开的占位节点下方追加新的节点和连线,并重新计算布局,让新分支稳定落位。
  • 对同一个节点重复发起展开请求不会重复追加子节点,因为每个符合条件的占位节点都会在回调执行前把 childrenLoaded 切换为 true

关键代码片段

这段代码展示了核心的占位节点技巧:叶子节点即使还没有子节点,也可以先显示展开占位器,因为节点元数据已经将它们标记为可展开且尚未加载。

{ id: 'c', text: 'c-dynamic-childs', color: '#dcb106' },
// By setting expandHolderPosition property for node, nodes without children can also show an [Expand/Collapse] button
{ id: 'c1', text: 'c1-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } },
{ id: 'c2', text: 'c2-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } },
{ id: 'c3', text: 'c3-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } }

这段代码展示了如何通过 onNodeExpand 拦截展开动作,并将其转化为带保护条件的异步图变更流程。

const onNodeExpand = (nodeObject: RGNode, $event: RGUserEvent) => {
    if (!nodeObject.data?.isNeedLoadDataFromRemoteServer || nodeObject.data?.childrenLoaded) {
        return;
    }

    graphInstance.loading('Loading...');
    nodeObject.data.childrenLoaded = true;
    loadChildNodesFromRemoteServer(nodeObject, async(newData) => {
        // Instance obtained via Hook directly calls appendJsonData
        graphInstance.addNodes(newData.nodes);
        graphInstance.addLines(newData.lines);
        await graphInstance.doLayout();
        graphInstance.clearLoading();
    });
};

这段代码展示了新分支是如何以一个小型 RGJsonData 载荷生成出来的,而不是通过重建原始整棵树。

const _new_json_data: RGJsonData = {
    nodes: [
        { id: nodeObject.id + '-child-1', text: nodeObject.id + '-dynamic child node 1' },
        { id: nodeObject.id + '-child-2', text: nodeObject.id + '-dynamic child node 2' },
        { id: nodeObject.id + '-child-3', text: nodeObject.id + '-dynamic child node 3' }
    ],
    lines: [
        { id: nodeObject.id + '-dl1', from: nodeObject.id, to: nodeObject.id + '-child-1' },
        { id: nodeObject.id + '-dl2', from: nodeObject.id, to: nodeObject.id + '-child-2' },
        { id: nodeObject.id + '-dl3', from: nodeObject.id, to: nodeObject.id + '-child-3' }
    ]
};

这段代码展示了布局和图默认配置,这些配置让懒加载出来的分支读起来像是一棵从左到右的正交树。

const graphOptions: RGOptions = {
    layout: {
        layoutName: 'tree',
        from: 'left',
        treeNodeGapH: 100
    },
    reLayoutWhenExpandedOrCollapsed: true,
    defaultExpandHolderPosition: 'right',
    defaultNodeShape: RGNodeShape.rect,
    defaultLineShape: RGLineShape.StandardOrthogonal,
    defaultJunctionPoint: RGJunctionPoint.lr,
    defaultNodeColor: '#ffffff',
    defaultPolyLineRadius: 5,
};

这段代码展示了展开按钮是如何通过 CSS 继承机制改样式的,而不是通过自定义插槽或独立组件来实现。

.rg-node-peel {
    .rg-node {
        .rg-node-text {
        }
    }
    .rg-node-expand-button {
        background-color: var(--rg-node-color);
    }
}

这个示例的独特之处

对比数据把这个示例放在 expand-foreverinvestmentexpand-graduallytree-data 附近,但它所处的细分位置比这些示例都更聚焦。相比 expand-forever,它更强调在子节点尚不存在时先给叶子占位节点显示展开按钮,并且避免了递归重复加载、基于插槽的加载动画渲染以及额外控制 UI。相比 investment,它复用了相同的懒加载触发模式,但没有名片式节点插槽、连线百分比标注或按层级区分的布局逻辑。

另一个有价值的对比对象,是那些只揭示已经加载好数据的示例。expand-gradually 关注的是逐步展开预加载的后代节点,tree-data 则关注一开始就从嵌套数据中加载完整层级结构。而这个示例是从扁平的节点和连线开始,通过 addNodes(...)addLines(...) 追加全新的片段,然后在异步完成后显式调用 doLayout()

最突出的地方,在于它把占位叶子节点展开、一次性加载保护、追加后的显式重新布局,以及随节点颜色变化的内置展开按钮结合进了一个完全不使用插槽的小型查看器中。当需求明确是“在空叶子节点上显示展开按钮、按需获取子节点,并尽量保持接近 relation-graph 默认样式”时,它是一个很强的起点。

这种模式还适用于哪里

这种模式非常适合由远程数据驱动的层级视图,因为完整数据集可能过大、权限敏感,或者预加载代价过高。典型场景包括组织机构分支、账户归属树、服务依赖钻取、分类浏览器以及文件夹树,在这些场景里,用户通常一次只会展开少量分支。

它也可以作为更丰富的按需图产品的基础。团队可以在同一思路上继续扩展,例如接入真实 API 调用、为单个分支增加错误状态、通过给新追加节点赋予相同元数据来实现更深层的递归加载,或者加入缓存规则,用来决定一个已收起的分支是重新加载还是复用先前获取的子节点。