Tree树形组件开发实战与性能优化技巧

Code°承锐 组件 阅读 2,173
赞 10 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

Tree 组件在后台管理系统里太常见了,权限配置、组织架构、文件目录……几乎每个项目都得搞一个。我一开始图快,直接拿 UI 库的 Tree 组件往里塞数据,结果后面改需求时差点被自己写的代码整崩溃。折腾了几个项目后,现在我写 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}
    />
  );
}

注意 calculateCheckedMaploadNodeChildren 是我封装的工具函数,专门处理复杂逻辑。这样主组件很干净,逻辑也容易测试。

踩坑提醒:这三点一定注意

  • 性能监控:Tree 节点超过 500 一定要做虚拟滚动。Ant Design 的 Tree 不支持,得换 rc-tree 或自己实现。别等上线后用户投诉才处理。
  • 键盘导航:无障碍需求越来越多,Tree 必须支持方向键切换焦点。很多团队忽略这点,结果验收时被打回来重做。
  • 数据一致性:异步加载后,如果用户操作了未加载的子节点(比如通过搜索跳转),要确保数据合并正确。我之前就遇到过“加载两次相同子节点”的 bug,因为没判断节点是否已存在。

最后说句实在话:Tree 组件没有银弹。我现在的方案也不是最优的,但至少经过三个项目验证,能快速响应需求变更。如果你有更好的思路,比如用 Zustand 管理 Tree 状态,或者用 Web Worker 处理大数据量,欢迎评论区交流。以上是我踩坑后的总结,希望对你有帮助。

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

暂无评论