Tree树形组件开发实战与性能优化经验分享
树形组件展开后滚动位置错乱,折腾半天才搞定
上周在搞一个权限管理页面,用到了树形结构展示角色和菜单的关联关系。本来以为直接套个现成的 Tree 组件就完事了,结果用户反馈:点开某个深层级节点后,页面自动滚到顶部去了,体验极差。我一开始还纳闷,这咋回事?又没写什么滚动逻辑。
后来自己试了下,果然复现了。点一个嵌套很深的子节点,比如“系统设置 > 用户管理 > 角色分配 > 权限编辑”,Tree 展开后整个页面就“唰”一下跳到最上面。用户得重新往下拉,烦死了。
排查过程:以为是虚拟滚动的问题
第一反应是:是不是用了虚拟滚动导致的?因为我们这个 Tree 节点数量不少,为了性能加了虚拟滚动(virtual scroll)。于是我把虚拟滚动关了,结果问题还在!说明不是虚拟滚动的锅。
接着怀疑是不是 Tree 组件内部用了 focus() 或者 scrollIntoView() 之类的方法。翻了下用的开源 Tree 组件源码(别问,问就是 antd 的 Tree),发现它在展开节点时确实会调用 scrollIntoView,但只在设置了 autoExpandParent 或某些交互逻辑下触发。而我的代码里根本没开这些选项。
那就奇怪了。难道是我自己写的逻辑有问题?回头一看,我在点击节点时做了个操作:更新当前选中的节点 ID,并触发一次状态更新。但这也太普通了,不至于引起滚动啊。
真相竟然是 key 的问题
折腾了半天,突然想到:会不会是 React 重新渲染时,DOM 被替换了,导致浏览器“丢失”了当前滚动位置?
赶紧检查 Tree 每个节点的 key。果然!我之前偷懒,直接用数组索引当 key:
{node.children.map((child, index) => (
<TreeNode key={index} {...child} />
))}
这就出大问题了。当树展开或收起时,节点顺序变了,但 key 还是 0、1、2……React 就以为这些是“新”的元素,把旧 DOM 干掉重建。而一旦 DOM 被移除再插入,浏览器就会重置滚动位置——尤其是当这个 Tree 在一个可滚动容器里时,整个容器高度突变,页面自然就跳了。
这里我踩了个大坑:**永远不要用数组索引用作动态列表的 key**,尤其是在树形结构这种频繁增删节点的场景里。
解决方案:给每个节点唯一且稳定的 key
改起来其实很简单:确保每个节点有全局唯一的 ID。我们的数据是从后端来的,每个节点本来就有 id 字段,直接拿来当 key 就行。
{node.children.map(child => (
<TreeNode key={child.id} {...child} />
))}
改完一试,问题没了!点开任意层级,页面稳如老狗,滚动位置丝毫不动。
不过等等——还有个小问题:如果后端返回的数据里有些节点没有 id 怎么办?比如临时添加的草稿节点。这时候就得自己生成一个临时 ID,但要保证在整个树的生命周期内唯一。我用了个简单的方案:用路径拼接。
const generateKey = (node, parentPath = '') => {
const path = parentPath ? ${parentPath}-${node.title} : node.title;
return path; // 简化版,实际项目建议用更健壮的方式
};
虽然用 title 拼接有点 hack,但在临时节点不多的情况下够用。当然,更好的做法是在创建临时节点时就赋予一个 UUID,比如用 crypto.randomUUID()(现代浏览器支持)或者简单计数器。
额外优化:防止 Tree 容器高度突变
解决了 key 的问题后,滚动跳转没了,但展开/收起时 Tree 容器高度还是会剧烈变化,导致页面其他内容“抖动”。用户体验还是不够丝滑。
于是我又加了个小技巧:给 Tree 外层包一个固定高度的容器,并开启内部滚动。这样 Tree 自身的高度变化就不会影响页面整体布局。
<div style={{ height: '400px', overflowY: 'auto' }}>
<MyTree data={treeData} />
</div>
配合 CSS 的 overflow-y: auto,现在无论怎么展开收起,外面的页面都不会抖了。用户专注在 Tree 区域内操作,体验好多了。
当然,如果你的 Tree 需要自适应高度(比如占满剩余空间),可以用 Flex 布局:
.tree-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* 关键!让子元素能正确滚动 */
}
.tree-wrapper {
flex: 1;
overflow-y: auto;
}
<div class="tree-container">
<header>标题</header>
<div class="tree-wrapper">
<MyTree data={treeData} />
</div>
</div>
这里注意 min-height: 0,不然在某些 Flex 容器里子元素的 overflow 会失效,这也是个经典坑。
核心代码就这几行
最终 Tree 节点的渲染逻辑长这样:
function renderTreeNode(node) {
return (
<TreeNode
key={node.id || node.tempId || generateTempId(node)} // 优先用真实 id
title={node.title}
isLeaf={node.isLeaf}
>
{node.children?.map(child => renderTreeNode(child))}
</TreeNode>
);
}
// 临时 ID 生成(仅用于没有 id 的情况)
let tempIdCounter = 0;
function generateTempId(node) {
if (!node._tempId) {
node._tempId = temp-${tempIdCounter++};
}
return node._tempId;
}
关键点就两个:
- 必须用稳定且唯一的 key,不能是索引
- Tree 外层要有独立滚动容器,避免影响页面整体布局
对了,如果你用的是像 Element Plus 或 Naive UI 这类 Vue 组件库,原理也一样——别用 v-for 的 index 当 :key,否则展开时也会触发不必要的 DOM 重建。
还有一个小尾巴没解决
现在大部分情况都稳了,但有个边缘 case:如果用户快速连续点开多个深层节点,偶尔还是会轻微跳一下。我猜是因为 React 批量更新还没完成,浏览器提前计算了滚动位置。暂时没深究,因为发生频率很低,而且不影响核心功能。先放着,等哪天闲了再看。
毕竟,开发不是追求完美,而是“能跑就行,用户不骂就赢”(狗头)。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法在不加外层容器的情况下避免页面抖动?或者更优雅的临时 ID 管理方式?我都想听听。

暂无评论