Tree树形组件开发实战与性能优化技巧
我的写法,亲测靠谱
Tree 组件在后台管理系统里太常见了,权限配置、组织架构、文件目录……几乎每个项目都得搞一个。我一开始图快,直接拿 UI 库的 Tree 组件往里塞数据,结果后面改需求时差点被自己写的代码整崩溃。折腾了几个项目后,现在我写 Tree 有一套自己的套路,虽然不完美,但至少能扛住产品经理反复横跳的需求。
先说核心:**别把 Tree 当成一次性组件用**。很多人(包括早期的我)觉得“反正就展示一下”,数据结构随便套,回调函数随便写,结果一要加个“全选反选”“异步加载子节点”“拖拽排序”,就得重写大半逻辑。我现在写 Tree,第一步就是把数据结构和交互逻辑拆干净。
比如,我一般会先把原始数据转成标准格式,不管后端给的是啥奇葩结构:
function normalizeTreeData(rawData) {
return rawData.map(item => ({
key: item.id,
title: item.name,
children: item.children ? normalizeTreeData(item.children) : [],
// 保留原始字段,方便后续操作
raw: item
}));
}
这样处理后,后续所有操作(展开、选中、搜索)都基于这个标准化结构,不会因为后端字段变动而翻车。而且 raw 字段能随时取到原始数据,提交时也不用再转换一遍。
这几种错误写法,别再踩坑了
我在 review 代码时经常看到下面这些写法,表面看能跑,实际埋雷:
- 直接在 render 里递归生成节点:性能差到爆,尤其数据量大时,每次 setState 都会重新渲染整棵树。UI 库的 Tree 组件内部已经做了虚拟滚动或 diff 优化,你再手写递归等于白给。
- 用 index 当 key:树形结构经常动态增删节点,用 index 做 key 会导致 React 复用错误的 DOM 节点,状态错乱。必须用唯一 ID,比如
item.id。 - 选中状态存在组件 state 里,但没考虑父子联动:比如点了父节点,子节点没自动选中;或者子节点全取消了,父节点还是勾选状态。这种半成品交互最让测试抓狂。
最惨的一次是我同事写的 Tree,选中逻辑全靠遍历原始数据找 id,每次点击都要 O(n) 遍历,1000+ 节点时卡到鼠标飘移。后来我改成用 Map 存储选中状态,O(1) 查找,瞬间流畅。
// 初始化选中状态
const buildCheckedMap = (treeData) => {
const map = new Map();
const traverse = (nodes) => {
nodes.forEach(node => {
map.set(node.key, false);
if (node.children) traverse(node.children);
});
};
traverse(treeData);
return map;
};
// 使用时
const [checkedMap, setCheckedMap] = useState(buildCheckedMap(normalizedData));
// 点击节点
const handleCheck = (key, checked) => {
const newMap = new Map(checkedMap);
newMap.set(key, checked);
// 这里还要处理父子联动,省略细节
setCheckedMap(newMap);
};
实际项目中的坑
Tree 看似简单,真上生产环境全是细节。分享几个我踩过的坑:
异步加载子节点时,别忘了 loading 状态。用户点开一个节点,如果没反馈,大概率会狂点,导致重复请求。我现在的做法是:在节点数据里加个 loading 字段,点开时设为 true,请求回来再设为 false。UI 库一般支持自定义节点,加个 loading icon 就行。
const loadNodeChildren = async (node) => {
// 先更新本地状态,显示 loading
updateNode(node.key, { loading: true });
try {
const res = await fetch(https://jztheme.com/api/children/${node.raw.id});
const children = await res.json();
// 更新节点数据,关闭 loading
updateNode(node.key, {
children: normalizeTreeData(children),
loading: false
});
} catch (err) {
updateNode(node.key, { loading: false });
message.error('加载失败');
}
};
搜索功能别用高亮整个树。很多人一做搜索,就把整棵树遍历一遍,匹配关键词就高亮。但树可能有几千节点,浏览器直接卡死。我的做法是:只展开匹配路径的祖先节点,其他节点保持折叠。这样既定位到目标,又避免渲染压力。
还有个小细节:默认展开层级别设太高。有些产品要求“首次加载展开前两层”,但如果第二层有 100 个节点,每个又有 50 个子节点,首屏直接渲染 5000+ DOM,页面卡成 PPT。现在我会限制最大展开数量,超过就提示“内容过多,请搜索”。
核心代码就这几行
其实 Tree 的核心逻辑就三块:数据标准化、状态管理、交互处理。下面是我现在项目里用的简化版骨架:
function MyTree({ rawData }) {
const [treeData, setTreeData] = useState(() => normalizeTreeData(rawData));
const [expandedKeys, setExpandedKeys] = useState([]);
const [checkedMap, setCheckedMap] = useState(new Map());
const handleExpand = (keys) => {
setExpandedKeys(keys);
};
const handleCheck = (checkedKeys) => {
// 这里处理半选状态、父子联动等
const newCheckedMap = calculateCheckedMap(treeData, checkedKeys);
setCheckedMap(newCheckedMap);
};
const handleLoadData = (node) => {
return loadNodeChildren(node, treeData, setTreeData);
};
return (
<AntdTree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={handleExpand}
checkable
checkedKeys={Array.from(checkedMap.entries())
.filter(([_, checked]) => checked)
.map(([key]) => key)}
onCheck={handleCheck}
loadData={handleLoadData}
/>
);
}
注意 calculateCheckedMap 和 loadNodeChildren 是我封装的工具函数,专门处理复杂逻辑。这样主组件很干净,逻辑也容易测试。
踩坑提醒:这三点一定注意
- 性能监控:Tree 节点超过 500 一定要做虚拟滚动。Ant Design 的 Tree 不支持,得换 rc-tree 或自己实现。别等上线后用户投诉才处理。
- 键盘导航:无障碍需求越来越多,Tree 必须支持方向键切换焦点。很多团队忽略这点,结果验收时被打回来重做。
- 数据一致性:异步加载后,如果用户操作了未加载的子节点(比如通过搜索跳转),要确保数据合并正确。我之前就遇到过“加载两次相同子节点”的 bug,因为没判断节点是否已存在。
最后说句实在话:Tree 组件没有银弹。我现在的方案也不是最优的,但至少经过三个项目验证,能快速响应需求变更。如果你有更好的思路,比如用 Zustand 管理 Tree 状态,或者用 Web Worker 处理大数据量,欢迎评论区交流。以上是我踩坑后的总结,希望对你有帮助。

暂无评论