一、项目里遇到的真实痛点:为什么这部分需要做专属方案?

做前端开发的同学,估计都碰过树图的需求,比如做企业的组织架构、设备的层级管理、产品的分类展示,都要用到树状结构的图表。常规的做法要么是一次性把所有数据加载出来,渲染成树;要么是点到哪个节点,才去请求对应的子节点。但实际用的时候,总会出各种问题:比如用户快速点了三个节点,后台收到三个请求,等加载回来,节点乱成一团;或者同一个节点点好几次,请求发了三次,浪费性能;还有收缩父节点后,子节点的展开状态没同步,下次展开的时候又要重新加载,或者出现子节点明明在缓存里却没显示的情况。这些问题在小项目里无所谓,但到了有几十上百个节点的中大型项目,就成了影响用户体验的大问题,所以我们需要一套专属的方案,把展开收起和异步加载无缝衔接起来。

1.1 常规树图的坑

之前我们做过一个医院的设备管理系统,设备有科室、子科室、仪器三个层级,总共有上千台设备,要是一次性把所有数据加载出来,页面要等十几秒才能打开,用户肯定受不了。后来改成了点科室才加载子科室,结果又出了问题:点了内科,等了2秒才出来子科室;再点外科,又发一个请求,要是用户点的快,两个请求回来的顺序乱了,外科的子科室却先显示出来,内科的反而要等,状态完全乱了。还有用户收缩了内科,再展开的时候,本来已经加载好的子科室,居然又发了一次请求,重复请求浪费带宽,还卡页面。这些坑,就是我们要解决的核心问题。

二、核心思路拆解:怎么把展开收起和异步加载无缝串起来?

其实思路不难,就是把三个环节盯紧:请求不能乱,数据要存好,父子状态要同步。

2.1 请求时序控制:避免重复和无效请求

很多时候请求乱,是因为用户点的太快,同一个节点还没加载完,又点了一次。我们可以用一个“标记位”,比如记一下当前有没有节点正在请求,要是用户点的节点和正在请求的一样,就不发新请求,等这次请求完了再处理后续的点击。比如用户点A节点,正在请求,这时候再点A,就直接忽略,避免重复请求,也避免请求返回的顺序乱掉。

2.2 节点状态缓存:减少重复请求

把已经加载过的节点数据存在一个“缓存池”里,key是节点的唯一id,value里存子节点的数据、有没有加载过的标记。比如A节点的子节点已经加载过,下次再点A,直接从缓存里拿子节点,不用再发请求,速度快还省资源。就像你去过的地方,下次再去就不用再找路了。

2.3 父子节点状态同步:解决状态混乱

父子节点的状态要联动,比如父节点展开,子节点的缓存如果有,就直接显示;父节点收缩,子节点的展开状态要清空,或者把“加载过”的标记暂时去掉,下次展开父节点,再重新检查缓存。这样就不会出现收缩后子节点还在的情况,也不会乱。

三、完整示例代码:基于React + G6的实现

这里用最常用的React + G6来写示例,直接复制就能跑,代码里加了详细注释,对应上面的思路:

// 技术栈:React + G6 5.x
import { useRef, useEffect, useState } from 'react';
import { TreeGraph } from '@antv/g6';

// 全局缓存:存节点的子数据、是否加载过,key是节点唯一id
const nodeCache = new Map();
// 正在请求的节点id:用来控制请求时序,避免重复请求
window.pendingNodeId = null;

const OrgTree = () => {
  const graphRef = useRef(null);
  const containerRef = useRef(null);

  // 初始化树图,绑定展开收缩事件
  useEffect(() => {
    if (!containerRef.current) return;
    // 创建树图实例
    const graph = new TreeGraph({
      container: containerRef.current,
      width: 1000,
      height: 800,
      // 自带的展开收起交互,不用自己写点击事件
      modes: { default: ['collapse-expand'] },
      // 布局:从左到右的紧凑布局,适合组织架构
      layout: { type: 'compactBox', direction: 'LR' },
      // 节点样式,随便改,符合业务就行
      defaultNode: { style: { fill: '#40a9ff', stroke: '#096dd9', radius: 8 } },
      defaultEdge: { style: { lineWidth: 2, stroke: '#ccc' } },
    });
    graphRef.current = graph;

    // 监听【节点展开】事件,处理加载逻辑
    graph.on('node:expand', async (e) => {
      const nodeId = e.item.get('id');
      // 1. 请求时序控制:如果这个节点正在请求,直接 return,不发新请求
      if (window.pendingNodeId === nodeId) return;
      // 2. 先查缓存:有没有已经加载过的子节点
      const cachedNode = nodeCache.get(nodeId);
      if (cachedNode?.children) {
        // 有缓存,直接渲染子节点,不用再请求
        graph.updateChildNodes(nodeId, cachedNode.children);
        return;
      }
      // 3. 没缓存,发异步请求,设置pending标记
      window.pendingNodeId = nodeId;
      try {
        // 模拟接口,换成自己的后端地址就行
        const res = await fetch(`/api/node/getChildren?nodeId=${nodeId}`);
        const children = await res.json();
        // 4. 缓存子节点,同时标记已经加载过
        nodeCache.set(nodeId, { children, isLoaded: true });
        // 5. 渲染子节点,完成展开逻辑
        graph.updateChildNodes(nodeId, children);
      } catch (err) {
        console.error('加载子节点失败,请重试', err);
        // 实际项目里可以加错误提示,比如显示“加载失败”加重试按钮
      } finally {
        // 不管成功失败,清除pending标记,允许下次请求
        window.pendingNodeId = null;
      }
    });

    // 监听【节点收缩】事件,同步父子状态
    graph.on('node:collapse', (e) => {
      const nodeId = e.item.get('id');
      // 收缩父节点时,把子节点的“加载过”标记去掉,下次展开重新检查
      const cachedNode = nodeCache.get(nodeId);
      if (cachedNode) delete cachedNode.isLoaded;
      nodeCache.set(nodeId, cachedNode);
    });

    // 加载根节点数据,树图的第一个节点就是根
    const loadRootNode = async () => {
      const res = await fetch('/api/node/getRoot');
      const rootNode = await res.json();
      graph.data(rootNode);
      graph.render();
    };
    loadRootNode();

    // 组件销毁时,清理树图实例,避免内存泄漏
    return () => graph.destroy();
  }, []);

  return <div ref={containerRef} style={{ width: '100%', height: '800px' }}></div>;
};

export default OrgTree;

这个示例里,把请求控制、缓存、状态同步都做了,跑起来就能用,要是有特殊需求,改改里面的逻辑就行。

四、应用场景与技术分析

4.1 适用场景

这套方案适合所有需要层级结构且数据量较大的树图需求,比如:

  1. 企业组织架构树:部门层级多,节点多,不需要一次性加载所有部门;
  2. 设备管理树:设备按科室、类型分类,层级深,节点数多;
  3. 产品分类树:商品分类上千,点到哪个分类加载哪个分类下的子分类,减少页面加载时间;
  4. 工单/任务树:多级任务结构,不同层级的任务数据分开加载,提升操作流畅度。

4.2 技术优缺点

优点

  1. 页面加载快:不用等所有数据加载,首次打开速度快,用户体验好;
  2. 性能优:缓存已经加载的节点,减少重复请求,降低服务器压力;
  3. 状态稳定:请求时序控制和状态同步,避免了之前遇到的状态混乱、重复请求的问题;
  4. 可扩展:只要改缓存和请求的逻辑,就能适配不同的树图业务。

缺点

  1. 代码量比常规树图多:需要自己写请求控制、缓存、事件监听的逻辑,不像普通树图几行代码就能实现;
  2. 需要处理边界情况:比如请求失败的提示、缓存的更新(后端数据变化时要清缓存)、用户快速点击的异常;
  3. 内存占用:缓存会占用一点内存,不过一般项目的节点数不会太多,影响很小。

4.3 注意事项

  1. 节点id必须唯一:不能出现两个节点id相同的情况,否则缓存会乱,比如两个部门都叫“技术部”,id都是1,缓存就会串数据;
  2. 请求异常处理:加错误提示和重试按钮,不然用户点了没反应不知道为什么;
  3. 缓存的清理:不用永久存缓存,页面刷新后清掉,或者设置过期时间,避免缓存太多占内存,后端数据更新时要主动清对应的缓存;
  4. 请求的取消:要是用户点了一个节点,还没加载完就切走了,要取消这个请求,避免浪费带宽,示例里没写这个,实际项目要加上。

五、总结

这套方案的核心就是把展开收起的每个动作,和异步加载的每个环节绑定起来,用标记位控制请求,用缓存存数据,用状态同步解决父子节点的混乱。不管是小项目还是中大型项目,这套方案都能解决树图交互里的常见问题,提升用户体验,而且代码是通用的,改改就能用到不同的业务里。