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

曌煜 组件 阅读 1,456
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月接手一个老项目,里面有个 Tree 组件用来展示部门结构,数据量不大,也就 2000 多个节点。但每次点开页面,浏览器直接卡死 3 秒起步,滚动都卡成幻灯片。用户反馈“点一下展开要等半天”,我本地 dev 模式跑起来更是离谱——加载完直接风扇狂转。

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

最开始我以为是后端接口慢,结果 network 面板一看,API 返回才 200ms(fetch('https://jztheme.com/api/tree-data')),问题明显在前端渲染。打开 Performance 面板录了一次,好家伙,主线程被 JS 占满,光是创建 DOM 节点就花了 4.8 秒,FPS 掉到个位数。

找到瓶颈了!

我用 Chrome DevTools 的 Performance + React DevTools(项目是 React)一起分析。发现两个致命问题:

  • 全量渲染:不管用户看没看到,所有节点一次性 render 出来,2000 个 <div> 直接怼进 DOM
  • 重复计算:每次展开/折叠,整个树重新走一遍 render + diff,哪怕只改了一个子节点

更坑的是,这个 Tree 还用了递归组件写法,父组件传 children,子组件又递归调自己。React 的 reconciler 在这种深度嵌套下效率极低,尤其当节点带 checkbox、icon、操作按钮时,每个节点都是一个重型组件。

折腾了半天发现,根本不是算法问题,是渲染策略太 naive。

方案一:虚拟滚动?先别急

第一反应是上虚拟滚动(virtual scroll)。但 Tree 不是列表,它有层级缩进,高度不固定,而且用户可能展开任意层级。实现起来复杂度爆炸——既要算每个节点的 offset,又要处理动态高度,还得维护展开状态和滚动位置映射。试了 react-window 的 fixed size 版本,根本对不上;换成 dynamic size,性能反而更差(因为要频繁测量 DOM)。

最后放弃了。虚拟滚动适合列表,Tree 用它属于硬套,收益远小于成本。

方案二:懒渲染 + 状态缓存(亲测有效)

既然不能全量渲染,那就只渲染用户“看到”的部分。核心思路就两点:

  • 默认只渲染根节点,子节点在用户点击展开时才生成
  • 缓存已渲染的子树,避免重复创建

这里注意我踩过好几次坑:不要用 display: none 隐藏子节点!那样 DOM 还在,只是看不见,照样吃内存。必须用条件渲染,让 React 真正卸载组件。

关键代码来了。优化前的递归组件长这样(简化版):

// 优化前:暴力递归
function TreeNode({ node, level = 0 }) {
  const [expanded, setExpanded] = useState(false);
  
  return (
    <div>
      <div onClick={() => setExpanded(!expanded)}>
        {node.name}
      </div>
      {expanded && node.children && node.children.map(child => (
        <TreeNode key={child.id} node={child} level={level + 1} />
      ))}
    </div>
  );
}

问题很明显:每次 expanded 变化,所有子节点重新 mount/unmount,2000 个节点点几下就崩。

优化后,我引入了 useMemo 缓存子树,并且把子节点渲染逻辑拆出来:

// 优化后:懒渲染 + 缓存
function TreeNode({ node, level = 0 }) {
  const [expanded, setExpanded] = useState(false);
  
  // 缓存子节点的渲染结果,避免 expanded 变化时重建
  const renderedChildren = useMemo(() => {
    if (!node.children) return null;
    return node.children.map(child => (
      <TreeNode key={child.id} node={child} level={level + 1} />
    ));
  }, [node.children, level]); // 注意依赖项:只有 children 结构变才重算

  return (
    <div>
      <div onClick={() => setExpanded(!expanded)}>
        {node.name}
      </div>
      {expanded && renderedChildren}
    </div>
  );
}

但这样还不够!因为 node.children 是引用类型,如果每次 API 返回新数组(即使内容一样),useMemo 也会失效。所以我在数据预处理阶段做了 节点 ID 化 + 子节点引用复用

// 数据预处理:确保相同节点引用不变
const normalizeTree = (nodes, parent = null) => {
  return nodes.map(node => {
    const normalized = {
      ...node,
      parent,
      // 关键:用 Map 缓存已处理的节点,避免重复创建
      children: node.children ? normalizeTree(node.children, node) : null
    };
    return normalized;
  });
};

// 在顶层组件只做一次 normalize
const [treeData] = useState(() => normalizeTree(rawData));

这样,只要原始数据没变,所有子节点的引用就保持稳定,useMemo 才真正生效。

方案三:扁平化数据结构(终极提速)

上面优化后,展开速度从 2s 降到 300ms,但首次渲染根节点还是慢(因为根节点多)。于是祭出大招:把树拍平成 Map,用 visibility 控制显示

思路:不再用递归组件,而是把所有节点存在一个 Map<id, node> 里,每个节点记录自己的 depthvisible 状态。渲染时只遍历 visible 节点,用 CSS 控制缩进。

核心代码:

// 扁平化存储
const buildFlatNodes = (tree) => {
  const flat = new Map();
  const traverse = (nodes, depth, parentVisible = true) => {
    nodes.forEach(node => {
      flat.set(node.id, {
        ...node,
        depth,
        visible: parentVisible, // 默认可见性继承父级
        expanded: false
      });
      if (node.children) {
        traverse(node.children, depth + 1, parentVisible);
      }
    });
  };
  traverse(tree, 0);
  return flat;
};

// 渲染函数:只渲染 visible 节点
function Tree({ flatNodes, onToggle }) {
  const visibleNodes = Array.from(flatNodes.values()).filter(n => n.visible);
  
  return (
    <div>
      {visibleNodes.map(node => (
        <div 
          key={node.id}
          style={{ paddingLeft: ${node.depth * 20}px }}
          onClick={() => onToggle(node.id)}
        >
          {node.name}
        </div>
      ))}
    </div>
  );
}

配合一个高效的 toggle 函数(只更新受影响节点的 visible):

const toggleNode = (id) => {
  setFlatNodes(prev => {
    const next = new Map(prev);
    const node = next.get(id);
    if (!node) return prev;
    
    // 切换展开状态
    node.expanded = !node.expanded;
    
    // 更新所有子节点的 visible
    const updateChildrenVisibility = (parentId, isVisible) => {
      for (const [childId, child] of next.entries()) {
        if (child.parentId === parentId) {
          child.visible = isVisible && node.expanded;
          if (child.children) {
            updateChildrenVisibility(childId, child.visible);
          }
        }
      }
    };
    
    updateChildrenVisibility(id, node.expanded);
    return next;
  });
};

这个方案彻底干掉了递归,React 只 diff 一个扁平列表,性能飞起。

性能数据对比

在 2000 节点、平均深度 4 的测试数据下:

  • 优化前:首次渲染 5.2s,展开一级节点 1.8s,内存占用 180MB
  • 懒渲染 + 缓存:首次渲染 1.1s,展开一级节点 280ms,内存 95MB
  • 扁平化方案:首次渲染 800ms,展开一级节点 60ms,内存 70MB

用户感知最明显的是展开速度——从“以为卡死了”变成“秒开”。滚动也流畅了,因为 DOM 节点从 2000+ 降到平均 200 左右(只渲染可见部分)。

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

  • 不要过度优化:如果节点少于 500,用基础懒渲染就够了,扁平化反而增加复杂度
  • checkbox 状态同步:如果带父子联动,记得在 toggle 时批量更新相关节点,别触发多次 re-render
  • key 的稳定性:务必用唯一 ID 做 key,别用 index,否则展开/折叠会错乱

另外,改完后仍有个小问题:快速连续点击展开/折叠,偶尔会闪一下。但无大碍,用户基本遇不到,就没花时间修。

最后说两句

Tree 性能优化的核心就一句话:别渲染你看不见的东西。无论是懒加载、虚拟滚动还是扁平化,本质都是减少 DOM 节点数量。我的方案不是最优的(比如没用 Web Worker 计算),但胜在简单、兼容性好、改动小。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理超大 Tree(10w+ 节点)的?

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

暂无评论