Select选择器组件开发中的性能优化与交互细节处理
项目初期的技术选型
上个月接了个后台管理系统的活,需求里有个“地区选择器”,要支持省市区三级联动。一开始我想直接用 Ant Design 的 Select,但产品经理说:“用户反馈下拉太卡,而且选项太多时根本找不到。” 我一想也是,全国 3000 多个区县,全塞进一个下拉框,光渲染就可能卡死。
后来我决定自己写一个轻量级的 Select 组件,核心诉求就三点:支持虚拟滚动、能模糊搜索、还得能自定义选项样式。技术栈是 React + TypeScript,没打算用第三方 UI 库(因为项目整体风格偏定制化),所以得从零搞起。
最大的坑:性能问题
刚开始偷懒,直接用原生 select 元素包装了一下,结果数据一多,页面直接卡成幻灯片。后来换成 div 模拟下拉,把所有选项都 render 出来——更糟,浏览器内存直接飙到 1G。折腾了半天才发现,问题出在没做虚拟滚动。
我试过用 react-window,但发现它和下拉菜单的定位逻辑有点冲突(比如菜单高度动态变化时,滚动容器计算不准)。最后还是自己撸了一个简易版的虚拟滚动:只渲染可视区域内的选项,加上缓冲区。关键点在于监听滚动事件,动态计算当前应该显示哪些项。
这里注意我踩过好几次坑:滚动条高度算错、选项高度不一致导致错位、快速滚动时白屏…… 最后妥协方案是固定每个选项高度为 36px,虽然牺牲了灵活性,但稳了。
核心代码就这几行
下面是我最终用的虚拟滚动核心逻辑(简化版):
const VirtualList = ({ options, visibleCount = 10, itemHeight = 36 }) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
const totalHeight = options.length * itemHeight;
const startIndex = Math.floor(scrollTop / itemHeight) - 2;
const endIndex = startIndex + visibleCount + 4;
const visibleOptions = options.slice(
Math.max(0, startIndex),
Math.min(options.length, endIndex)
);
const offsetTop = Math.max(0, startIndex * itemHeight);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{ height: visibleCount * itemHeight, overflowY: 'auto' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: offsetTop, width: '100%' }}>
{visibleOptions.map((option, index) => (
<div
key={option.value}
style={{ height: itemHeight, lineHeight: ${itemHeight}px }}
>
{option.label}
</div>
))}
</div>
</div>
</div>
);
};
配合搜索功能,我加了个 debounce,避免频繁请求:
const [searchValue, setSearchValue] = useState('');
const debouncedSearch = useMemo(
() => debounce((value) => fetchOptions(value), 300),
[]
);
useEffect(() => {
if (searchValue.trim()) {
debouncedSearch(searchValue);
} else {
// 加载默认选项
loadDefaultOptions();
}
}, [searchValue]);
另一个头疼的问题:键盘导航
产品经理突然提了个需求:“要用键盘上下键选择,回车确认。” 我当时就懵了——之前完全没考虑无障碍访问。赶紧补上键盘事件监听。
难点在于焦点管理和高亮同步。我给每个选项加了 tabIndex="-1",然后用状态记录当前高亮的索引。上下键时更新索引,同时滚动到对应位置(避免高亮项被滚出视口):
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) {
onSelect(filteredOptions[activeIndex]);
}
}
};
// 滚动到高亮项
useEffect(() => {
if (listRef.current && activeIndex >= 0) {
const itemTop = activeIndex * itemHeight;
const container = listRef.current;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
if (itemTop < scrollTop || itemTop > scrollTop + containerHeight) {
container.scrollTop = itemTop - containerHeight / 2;
}
}
}, [activeIndex]);
这部分调了快一天,主要是不同浏览器对 scrollTop 的行为有细微差异,最后加了点容错判断才稳住。
最终的解决方案
整合下来,整个 Select 组件结构大概是这样:
- 外层 input 触发下拉
- 下拉面板用 Portal 渲染到 body,避免父级 overflow:hidden 被裁剪
- 选项列表用上面的 VirtualList 实现
- 支持异步搜索(调用
https://jztheme.com/api/regions?q=xxx这类接口) - 键盘导航 + 点击选择双模式
为了兼容移动端,我还加了 touch 事件处理,不过因为项目主要是桌面端,这部分测试不够充分,偶尔在 iOS Safari 上有点小问题(比如点击穿透),但客户说“能用就行”,我就没深究。
回顾与反思
这个组件上线后,用户反馈确实比之前流畅多了,尤其在低配电脑上。我自己也觉得挺满意,毕竟没引入额外依赖,体积小,还能灵活定制样式。
但有几个地方其实还能优化:
- 选项高度现在是固定的,如果未来需要支持富文本(比如带图标+描述),得重构虚拟滚动逻辑
- 搜索时如果网络慢,loading 状态提示不够明显,用户容易以为没反应
- 键盘导航在选项特别多时,滚动定位还是有点跳,可以加个平滑滚动
不过考虑到项目 deadline,这些都留着以后再说吧。反正目前跑着没问题,客户也验收了。
以上是我个人对这个 Select 组件的完整实战总结,核心就是:**别一次性渲染所有选项,虚拟滚动是救命稻草;键盘导航别等 QA 提出来再做,早点加上少返工**。有更优的实现方式欢迎评论区交流,比如怎么优雅地处理动态高度的虚拟列表?

暂无评论