一、问题背景与痛点拆解
很多做后台管理系统的开发者,应该都遇到过树状组件的坑——尤其是用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状态完全同步”,具体来说要做好三件事:
第一,自己维护一套完整的数据源,记录每个节点的key、title、children等信息,包括已经加载过的子节点。
第二,记录每个节点的展开状态,当节点被展开或者收起时,同步更新到自己维护的状态里。
第三,异步加载子节点后,正确更新自己维护的数据源,并且把更新后的数据源传给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来监听treeData和expandedKeys的变化:
// 监听状态变化,保存到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组件。
如果你遇到类似的问题,可以按照这个思路来调整自己的代码,先检查自己的数据源是不是完整,展开状态是不是同步,然后再检查更新状态的方式是不是符合不可变原则。只要把这几个点都做好,就能完美解决状态丢失的问题。
评论
围绕“Ant Design Tree组件异步加载节点保持展开状态的数据一致性修复”参与讨论