手把手实现高性能Dropdown下拉菜单的那些坑与优化技巧

打工人秀英 组件 阅读 2,272
赞 23 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

这破下拉菜单我真是忍了好久。项目里有个商品分类的 Dropdown,数据量大概两三千条,结构是多级嵌套的,展开后能有五六层。最开始没想那么多,直接递归渲染,用户一点开就卡住两三秒,页面直接无响应,Chrome 都弹出“页面未响应”提示让我杀掉标签页。我自己测的时候都笑了,这哪是用户体验,这是考验用户耐心。

手把手实现高性能Dropdown下拉菜单的那些坑与优化技巧

更离谱的是,滚动都卡。你以为点了之后等几秒就能用?错。展开后稍微一滚动,又开始掉帧,10fps 都不到,滑动动画跟幻灯片似的。客户那边直接打回,说“这个交互完全不可用”,我也觉得确实没法上线。

找到病根了!

先上 Chrome DevTools 的 Performance 面板录了一段操作。点开 Dropdown,录完一看,JS Call Stack 里 render 占了整整 2.3 秒,其中大部分时间花在 React.createElementdiff 上。DOM 节点数直接冲到 8000+,光是 <li> 就 6000 多个。这谁顶得住?

内存也爆了。Heap Snapshot 显示组件挂载后内存占用涨了快 40MB,GC 频繁触发,主线程被拖死。问题很明确:一次性渲染太多节点,而且全是 React VNode 创建 + DOM 插入,双重压力。

其实早该想到的——这种大数据量的 Dropdown,根本不该全量渲染。但一开始图省事,想着“数据也不算特别大”,结果真上了数据才发现完全不是一回事。

试了几种方案

第一反应是虚拟滚动。常见的库比如 react-window 或者 react-virtualized,但它们对嵌套树结构支持不太友好,尤其是要支持展开/收起的时候,计算每个节点高度会变得很麻烦。我折腾了半天,发现维护成本太高,还得自己写 TreeNode 的测量逻辑,干脆放弃。

第二招是懒加载 + 动态加载子节点。也就是点击父节点时才去 fetch 子级数据。这在某些场景可行,但我们这需求是“必须本地搜索所有分类”,所以数据必须一次性加载进前端。这条路也走不通。

最后决定:**虚拟滚动 + 按需渲染可视区域 + 扁平化树结构**。思路是把嵌套 JSON 树拍平成数组,但保留层级信息(比如 depth),然后只渲染当前可视区域内的项,滚动时动态更新。

核心代码就这几行

先把树结构扁平化:

function flattenTree(data, depth = 0, result = [], parent = null) {
  data.forEach(item => {
    const node = { ...item, depth, parent };
    result.push(node);
    if (item.children && item.expanded) {
      flattenTree(item.children, depth + 1, result, item);
    }
  });
  return result;
}

注意这里只展开已标记 expanded: true 的节点,避免无限展开。

然后是虚拟滚动的核心逻辑:只渲染视口内的元素。我用了个轻量方案,没引入库,自己算位置:

function VirtualList({ items, itemHeight = 36, height = 400 }) {
  const [scrollTop, setScrollTop] = useState(0);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 5);
  const endIndex = Math.min(
    items.length,
    Math.floor((scrollTop + height) / itemHeight) + 5
  );

  const visibleItems = items.slice(startIndex, endIndex);

  return (
    <div
      onScroll={handleScroll}
      style={{ height, overflow: 'auto', position: 'relative' }}
    >
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        <div
          style={{
            position: 'absolute',
            top: startIndex * itemHeight,
            left: 0,
            right: 0,
          }}
        >
          {visibleItems.map((item, index) => (
            <div
              key={item.id}
              style={{
                paddingLeft: item.depth * 20,
                height: itemHeight,
                lineHeight: ${itemHeight}px,
              }}
              onClick={() => toggleExpand(item)}
            >
              {item.name}
              {item.children?.length > 0 && (
                <span>{item.expanded ? '▼' : '▶'}</span>
              )}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

这里有几个关键点:

  • startIndex 和 endIndex 多往外取 5 个,防止快速滚动时白屏,亲测有效
  • 外层容器定高 + overflow: auto,保证滚动容器独立
  • 绝对定位偏移,避免创建大量空 div
  • paddingLeft 控制缩进,不用嵌套 DOM,结构更扁平

还有个细节:我给每个节点加了 key={item.id},不然展开收起时 React 会错乱重渲染。之前试过用索引做 key,结果一滚动就炸,踩过好几次坑。

其他优化顺手做了

防抖搜索也加上了。原本输入框一打字就重新过滤整个列表,几千条数据 map 一遍又要几百毫秒。改成防抖 300ms,配合 useMemo 缓存结果:

const filteredItems = useMemo(() => {
  if (!searchTerm) return flattenedItems;
  return flattenedItems.filter(item =>
    item.name.includes(searchTerm)
  );
}, [flattenedItems, searchTerm]);

还把 toggleExpand 做了批处理,避免每次 setState 都触发全量 diff:

function toggleExpand(node) {
  // 使用 produce 或 immer 简化不可变更新
  setData(prev => {
    const newTree = [...prev];
    const target = newTree.find(item => item.id === node.id);
    if (target) {
      target.expanded = !target.expanded;
    }
    return reflatten(newTree); // 重新生成扁平数组
  });
}

优化后:流畅多了

改完之后再测,打开 Dropdown 响应时间从 2300ms 直接降到 80ms 左右,滚动帧率稳定在 50-60fps,完全不卡。内存占用从 40MB 降到不到 5MB,GC 触发次数少了 90%。客户再也没提过性能问题。

最明显的是用户体验:现在点开瞬间出来,滚动丝滑,搜索也不卡顿。虽然第一次展开深层节点时会重新拍平数据,但也就几十毫秒,根本感知不到。

性能数据对比

以下是优化前后关键指标对比:

  • Dropdown 展开耗时:2300ms → 80ms(降低 96.5%)
  • 滚动平均帧率:10fps → 58fps
  • 内存峰值占用:42MB → 4.7MB
  • DOM 节点数:8000+ → 最多维持 50 个可见项
  • 搜索响应延迟:每输入一个字符卡顿 → 输入流畅,防抖后执行

这些数据都是在一台中端安卓机上用 DevTools 实测的,不是开发机那种顶级设备,所以更有说服力。

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

1. 不要用 index 当 key。我最开始图省事这么干,结果展开收起时 UI 错乱,因为列表变了,index 对应的元素也变了,React diff 出问题。

2. itemHeight 必须固定。如果你的下拉项高度不一致,这套虚拟滚动就不适用了,得上 Intersection Observer 或 resize observer 配合测量,复杂度翻倍。

3. 扁平化过程别放 render 里。一开始我把 flattenTree 放组件内部,结果每次 rerender 都重新跑一遍,性能反而更差。后来移到 useMemo 里,依赖 dataexpandedKeys,才稳住。

结尾:就这样吧

以上是我对这个 Dropdown 组件的完整优化过程。方案不算完美,比如深层嵌套展开时还是会有轻微计算延迟,但已经不影响使用了。比起引入一堆重型库,这个轻量实现更适合我们这种中后台项目。

如果你有更好的做法,比如用 Web Worker 做扁平化、或者用 CSS container queries 配合虚拟滚动,欢迎评论区交流。我也在持续找更优解。

这玩意儿折腾了我整整三天,中间一度想甩锅给后端让他们分页……还好坚持下来了。前端嘛,总得为体验买单。

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

暂无评论