一、问题背景与痛点拆解

很多做后台管理系统的开发者,应该都遇到过树状组件的坑——尤其是用Ant Design的Tree组件做异步加载节点的时候。比如你做一个部门树,点开一级部门(比如“技术部”)加载二级部门,再点开二级部门加载三级员工,正常操作下,展开的节点应该能一直保持状态才对。但实际用的时候经常出问题:比如你点了“技术部”展开,再点“产品部”加载它的子节点,刚展开的“技术部”居然自动收回去了?或者更离谱的,你刷新了一下页面,之前展开的节点全乱了,甚至有的节点明明已经加载过子节点,重新展开还要再请求一遍接口?

这种问题本质上是“数据状态和UI状态不同步”导致的,很多人刚开始用的时候,只会盯着Tree组件的属性看,却忽略了组件内部状态和自己维护的数据源之间的关系,最后改来改去越改越乱。

二、核心原理与错误场景分析

2.1 为什么会出现状态丢失?

要解决问题,得先搞清楚Tree组件的异步加载逻辑是怎么跑的。Ant Design的Tree组件,异步加载的核心是loadData属性,这个属性是一个函数,当用户点击节点前面的展开箭头时,组件会调用这个函数,把当前点击的节点对象传进去,你需要在这个函数里请求接口获取子节点数据,然后返回一个Promise,组件拿到子节点数据后,会自动把这些子节点加到当前节点的children属性里,最后更新UI。

但这里有个容易踩的坑:组件内部有一套自己的状态管理,用来记录哪些节点是展开的、哪些节点是选中的,而你自己维护的数据源(比如存在React的state里的treeData)是另一套状态。如果这两套状态没有做好同步,就会出现状态丢失的问题。

2.2 常见错误场景

我整理了大家最常犯的三种错误,你可以对照看看自己是不是也踩过: 第一种:每次异步加载子节点后,直接用新的子节点数组覆盖旧的treeData,而不是找到对应的父节点,把新的子节点数组赋值给父节点的children属性。比如你点击“技术部”加载到子节点“前端组”,结果直接把整个treeData换成了只有“前端组”的数组,原来的父节点“技术部”自然就没了,展开状态也就丢了。

第二种:刷新页面或者切换路由后,没有把之前已经加载过的子节点数据保存下来,也没有把之前展开的节点状态同步给组件。比如你之前展开了“技术部”和“前端组”,刷新页面后,组件不知道这些节点是展开的,也不知道“前端组”已经加载过子节点,所以会重新请求接口,展开状态也会丢失。

第三种:修改节点的treeData后,没有通知组件重新渲染,或者修改的方式不符合React的不可变原则,导致组件没有检测到状态变化,UI和数据不同步。比如你直接修改了treeData数组里某个节点的children属性,而不是生成一个新的数组,React的浅比较会认为状态没有变化,组件就不会更新。

三、完整解决方案与代码实现

3.1 核心思路

要解决这个问题,核心就是要做到“数据状态和UI状态完全同步”,具体来说要做好三件事: 第一,自己维护一套完整的数据源,记录每个节点的keytitlechildren等信息,包括已经加载过的子节点。 第二,记录每个节点的展开状态,当节点被展开或者收起时,同步更新到自己维护的状态里。 第三,异步加载子节点后,正确更新自己维护的数据源,并且把更新后的数据源传给Tree组件,同时把展开状态同步给组件。

3.2 具体实现步骤

我们用React来做完整的实现,首先明确技术栈:React 18 + Ant Design 5 + TypeScript。

首先,我们需要定义节点的类型,方便后续处理:

// 定义树节点的类型
type TreeNode = {
  key: string; // 节点的唯一标识,必须和接口返回的节点ID对应
  title: string; // 节点显示的文本
  children?: TreeNode[]; // 子节点数组,未加载时为undefined
  isLeaf?: boolean; // 是否是叶子节点,用来标记不需要再加载子节点
};

然后,我们需要维护两个状态:一个是树的数据源treeData,另一个是展开的节点expandedKeys数组:

import { Tree } from 'antd';
import type { TreeProps } from 'antd';
import { useState, useCallback } from 'react';

const AsyncTreeDemo = () => {
  // 维护树的数据源
  const [treeData, setTreeData] = useState<TreeNode[]>([]);
  // 维护展开的节点key数组
  const [expandedKeys, setExpandedKeys] = useState<string[]>([]);

  // 初始化树的根节点,实际项目中根节点可能是从接口获取的
  const initRootNode = useCallback(() => {
    setTreeData([
      {
        key: 'root',
        title: '技术部',
        // 根节点不是叶子节点,需要加载子节点
        isLeaf: false,
      },
      {
        key: 'root2',
        title: '产品部',
        isLeaf: false,
      },
    ]);
  }, []);

  // 组件挂载时初始化根节点
  useState(() => {
    initRootNode();
  }, []);

接下来,实现异步加载子节点的函数loadData。这个函数是核心,我们需要在这个函数里完成三个操作:请求接口获取子节点、更新数据源、同步展开状态。

  // 异步加载子节点的函数
  const loadData: TreeProps['loadData'] = useCallback(async (node) => {
    // node是当前点击的节点对象,包含key、title等信息
    const currentKey = node.key as string;

    // 模拟接口请求,实际项目中替换成真实的接口调用
    const fetchChildren = async (parentKey: string): Promise<TreeNode[]> => {
      // 比如请求 /api/tree/children?parentId=parentKey
      console.log(`请求父节点${parentKey}的子节点`);
      // 模拟返回的子节点数据
      if (parentKey === 'root') {
        return [
          { key: 'root-1', title: '前端组', isLeaf: false },
          { key: 'root-2', title: '后端组', isLeaf: false },
        ];
      } else if (parentKey === 'root-1') {
        return [
          { key: 'root-1-1', title: '张三', isLeaf: true },
          { key: 'root-1-2', title: '李四', isLeaf: true },
        ];
      } else if (parentKey === 'root-2') {
        return [
          { key: 'root-2-1', title: '王五', isLeaf: true },
          { key: 'root-2-2', title: '赵六', isLeaf: true },
        ];
      } else if (parentKey === 'root2') {
        return [
          { key: 'root2-1', title: '产品一组', isLeaf: true },
          { key: 'root2-2', title: '产品二组', isLeaf: true },
        ];
      }
      return [];
    };

    // 获取子节点数据
    const children = await fetchChildren(currentKey);

    // 递归更新treeData,找到当前节点,给它赋值children
    const updateTreeData = (list: TreeNode[], key: string, newChildren: TreeNode[]): TreeNode[] => {
      return list.map((item) => {
        if (item.key === key) {
          // 找到当前节点,更新children
          return { ...item, children: newChildren };
        }
        // 如果当前节点有子节点,递归处理
        if (item.children) {
          return { ...item, children: updateTreeData(item.children, key, newChildren) };
        }
        // 其他节点直接返回
        return item;
      });
    };

    // 更新treeData状态
    setTreeData((prev) => updateTreeData(prev, currentKey, children));

    // 把当前节点的key加入expandedKeys,同步展开状态
    setExpandedKeys((prev) => [...prev, currentKey]);
  }, []);

然后,我们需要处理Tree组件的onExpand事件,当用户点击节点的展开箭头时,更新expandedKeys状态:

  // 处理展开事件
  const onExpand: TreeProps['onExpand'] = useCallback((newExpandedKeys) => {
    setExpandedKeys(newExpandedKeys as string[]);
  }, []);

最后,把所有状态和属性传给Tree组件:

  return (
    <div style={{ padding: 20 }}>
      <Tree
        treeData={treeData}
        loadData={loadData}
        expandedKeys={expandedKeys}
        onExpand={onExpand}
        // 可选:如果需要显示勾选框,可以加checkable属性
        // checkable
      />
    </div>
  );
};

export default AsyncTreeDemo;

这个实现已经解决了基本的状态丢失问题,你可以测试一下:点击“技术部”展开,加载子节点,再点击“产品部”加载子节点,“技术部”不会自动收起;点击“前端组”加载员工,所有展开状态都会保持。

3.3 进阶优化:刷新后恢复展开状态

上面的实现已经解决了当前操作下的状态保持问题,但如果刷新页面,之前的展开状态和加载过的子节点都会丢失。我们可以用本地存储(localStorage)来保存状态,刷新后恢复。

首先,我们需要修改状态的初始化逻辑,从localStorage读取之前保存的状态:

  // 初始化时从localStorage读取保存的状态
  const initState = useCallback(() => {
    const savedTreeData = localStorage.getItem('treeData');
    const savedExpandedKeys = localStorage.getItem('expandedKeys');

    if (savedTreeData && savedExpandedKeys) {
      // 解析保存的状态
      const parsedTreeData = JSON.parse(savedTreeData) as TreeNode[];
      const parsedExpandedKeys = JSON.parse(savedExpandedKeys) as string[];

      setTreeData(parsedTreeData);
      setExpandedKeys(parsedExpandedKeys);
    } else {
      // 没有保存的状态,初始化根节点
      initRootNode();
    }
  }, [initRootNode]);

  useState(() => {
    initState();
  }, []);

然后,我们需要在状态变化时,把新的状态保存到localStorage。这里可以用useEffect来监听treeDataexpandedKeys的变化:

  // 监听状态变化,保存到localStorage
  useEffect(() => {
    localStorage.setItem('treeData', JSON.stringify(treeData));
    localStorage.setItem('expandedKeys', JSON.stringify(expandedKeys));
  }, [treeData, expandedKeys]);

现在测试一下:刷新页面后,之前展开的节点会自动恢复,之前加载过的子节点也不会重新请求接口。

四、应用场景与优缺点分析

4.1 主要应用场景

这个解决方案适用于所有需要异步加载树状数据的场景,最常见的有:

  • 后台管理系统的部门组织架构树、菜单树、权限树;
  • 电商系统的商品分类树、品牌树;
  • 文档管理系统的文件夹树、文件目录树;
  • 项目管理系统的任务分解树、工作流树。

4.2 方案优缺点

优点: 第一,状态完全同步,不会出现展开状态丢失、重复请求接口的问题; 第二,支持刷新后恢复状态,用户体验更好; 第三,代码逻辑清晰,容易维护和扩展; 第四,符合React的不可变原则,组件性能更好。

缺点: 第一,本地存储的容量有限,如果树的节点非常多(比如超过1000个),保存到localStorage可能会占用过多空间,甚至导致性能问题; 第二,本地存储是和浏览器绑定的,用户换浏览器或者清除缓存后,保存的状态会丢失; 第三,如果树的结构非常复杂,递归更新treeData的性能可能会有问题,需要做性能优化(比如用key-value的结构存储节点,而不是递归遍历数组)。

五、注意事项

在使用这个方案的时候,有几个细节需要特别注意: 第一,节点的key必须是唯一的,而且要和接口返回的节点ID对应,否则会出现状态混乱的问题; 第二,loadData函数必须返回一个Promise,否则组件会认为加载失败,不会更新子节点; 第三,更新treeData的时候,必须遵守React的不可变原则,不能直接修改原来的数组或者对象,要生成新的数组和对象,否则组件不会重新渲染; 第四,如果树的节点非常多,建议对loadData函数和updateTreeData函数做防抖处理,避免频繁请求接口和更新状态; 第五,本地存储的状态要做过期处理,避免保存的状态和服务器端的最新数据不一致(比如服务器端删除了某个节点,本地保存的状态里还有这个节点)。

六、总结

Ant Design Tree组件的异步加载状态丢失问题,本质上是状态管理的问题,只要做好“数据源和UI状态的同步”,就能解决。这个方案的核心思路是自己维护一套完整的数据源和展开状态,然后在异步加载子节点、展开节点、状态变化的时候,同步更新这两套状态,最后把状态传给Tree组件。

如果你遇到类似的问题,可以按照这个思路来调整自己的代码,先检查自己的数据源是不是完整,展开状态是不是同步,然后再检查更新状态的方式是不是符合不可变原则。只要把这几个点都做好,就能完美解决状态丢失的问题。