手把手实现高性能Dropdown下拉菜单的那些坑与优化技巧
优化前:卡得不行
这破下拉菜单我真是忍了好久。项目里有个商品分类的 Dropdown,数据量大概两三千条,结构是多级嵌套的,展开后能有五六层。最开始没想那么多,直接递归渲染,用户一点开就卡住两三秒,页面直接无响应,Chrome 都弹出“页面未响应”提示让我杀掉标签页。我自己测的时候都笑了,这哪是用户体验,这是考验用户耐心。
更离谱的是,滚动都卡。你以为点了之后等几秒就能用?错。展开后稍微一滚动,又开始掉帧,10fps 都不到,滑动动画跟幻灯片似的。客户那边直接打回,说“这个交互完全不可用”,我也觉得确实没法上线。
找到病根了!
先上 Chrome DevTools 的 Performance 面板录了一段操作。点开 Dropdown,录完一看,JS Call Stack 里 render 占了整整 2.3 秒,其中大部分时间花在 React.createElement 和 diff 上。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 里,依赖 data 和 expandedKeys,才稳住。
结尾:就这样吧
以上是我对这个 Dropdown 组件的完整优化过程。方案不算完美,比如深层嵌套展开时还是会有轻微计算延迟,但已经不影响使用了。比起引入一堆重型库,这个轻量实现更适合我们这种中后台项目。
如果你有更好的做法,比如用 Web Worker 做扁平化、或者用 CSS container queries 配合虚拟滚动,欢迎评论区交流。我也在持续找更优解。
这玩意儿折腾了我整整三天,中间一度想甩锅给后端让他们分页……还好坚持下来了。前端嘛,总得为体验买单。

暂无评论