Tree树形组件开发实战与性能优化经验分享

长孙怡博 组件 阅读 1,421
赞 15 收藏
二维码
手机扫码查看
反馈

树形组件展开后滚动位置错乱,折腾半天才搞定

上周在搞一个权限管理页面,用到了树形结构展示角色和菜单的关联关系。本来以为直接套个现成的 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-forindex:key,否则展开时也会触发不必要的 DOM 重建。

还有一个小尾巴没解决

现在大部分情况都稳了,但有个边缘 case:如果用户快速连续点开多个深层节点,偶尔还是会轻微跳一下。我猜是因为 React 批量更新还没完成,浏览器提前计算了滚动位置。暂时没深究,因为发生频率很低,而且不影响核心功能。先放着,等哪天闲了再看。

毕竟,开发不是追求完美,而是“能跑就行,用户不骂就赢”(狗头)。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法在不加外层容器的情况下避免页面抖动?或者更优雅的临时 ID 管理方式?我都想听听。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论