从零实现一个带虚拟滚动的 Select:原理、踩坑与 el-select-v2 对比
面试时被问"你封装过什么组件",只会答"封装过 table、form"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品:带虚拟滚动的下拉选择器 。 包含完整原理、真实踩过的两个坑、以及与 Element Plus
el-select-v2的横向对比。
一、为什么要造这个轮子
业务里常见的场景:一个下拉框要塞几千甚至上万条选项(城市、门店、用户、SKU)。 如果按常规写法把 options 全量 v-for 渲染出来:
| 选项数量 | DOM 节点数 | 后果 |
|---|---|---|
| 100 | 100 | 无感 |
| 10,000 | 10,000 | 首次展开明显卡顿、掉帧 |
| 50,000 | 50,000 | 直接卡死 |
浏览器的瓶颈不在"数据多少",而在真实 DOM 节点的数量(创建、布局、绘制、内存)。
虚拟滚动的解法很直接:数据可以有一万条,但 DOM 里永远只保留用户看得见的那十几个。
二、虚拟滚动到底做了什么(三句话讲清)
- 只渲染可视区:DOM 中只挂载"可视区 + 上下缓冲"的 ~20 个节点,不随数据量增长。
- 撑出假高度 :用一个空的占位元素(
phantom)撑出真实的总高度,让浏览器生成原生滚动条。 - 平移定位 :用
transform: translateY(...)把这 ~20 个节点整体移动到正确的位置。
因为每一项高度固定,偏移量可以 O(1) 算出来:
ini
startIndex = floor(scrollTop / itemHeight) - overscan
renderCount = ceil(viewportHeight / itemHeight) + overscan * 2
offsetY = startIndex * itemHeight
三、架构决策:为什么把虚拟逻辑抽成 composable
一个很容易被忽略的"高级感"分水岭:
- ❌ 初级做法:把滚动计算直接写在
Select.vue里 → 换个表格又要重写一遍 - ✅ 高级做法:抽成
useVirtualListcomposable,逻辑与 UI 解耦
markdown
useVirtualList(纯逻辑,不碰 DOM 结构)
↓ 被复用
VirtualSelect / VirtualTable / VirtualFeed ...
这样做的好处:可单测、可复用、组件层只关心交互。面试时能讲出"我为什么这么分层",比讲出"我写了什么"更值钱。
四、核心实现
4.1 虚拟滚动内核 useVirtualList.ts
输入是一个数据 getter(支持过滤后动态变化)和项高配置,输出渲染所需的一切:
ts
export function useVirtualList<T>(source: () => T[], options: UseVirtualListOptions) {
const itemHeight = options.itemHeight
const overscan = options.overscan ?? 6
const scrollTop = ref(0)
const viewportHeight = ref(0)
// 滚动总高度:决定原生滚动条的真实长度
const totalHeight = computed(() => source().length * itemHeight)
// 可视区起始下标(向上多留 overscan 缓冲)
const startIndex = computed(() => {
const idx = Math.floor(scrollTop.value / itemHeight) - overscan
return Math.max(0, idx)
})
// 可视区结束下标
const endIndex = computed(() => {
const capacity =
viewportHeight.value > 0
? Math.ceil(viewportHeight.value / itemHeight) + overscan
: source().length
return Math.min(source().length, startIndex.value + capacity)
})
// 真正渲染的少量节点(带原始下标)
const visibleItems = computed<VirtualItem<T>[]>(() =>
source()
.slice(startIndex.value, endIndex.value)
.map((item, i) => ({ item, index: startIndex.value + i })),
)
// 把可视窗口整体下移到正确位置
const offsetY = computed(() => startIndex.value * itemHeight)
function onScroll(e: Event) {
scrollTop.value = (e.target as HTMLElement).scrollTop
}
function setViewportHeight(h: number) {
viewportHeight.value = h
}
return { totalHeight, visibleItems, offsetY, onScroll, setViewportHeight, scrollTop }
}
4.2 DOM 结构:三层嵌套是关键
html
<!-- 1. 滚动容器:固定高度 + overflow-y:auto -->
<div class="vselect-viewport" :style="{ height: dropdownHeight + 'px' }" @scroll="onScroll">
<!-- 2. 占位层:撑出总高度,让浏览器生成原生滚动条 -->
<div class="vselect-phantom" :style="{ height: totalHeight + 'px' }">
<!-- 3. 内容层:只渲染可视项,整体平移定位 -->
<div class="vselect-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="(vi, i) in visibleItems" :key="i" ...>{{ vi.item.label }}</div>
</div>
</div>
</div>
举例:1 万条 × 34px = 34 万像素高的空 div,而里面实际只有 ~20 个节点。
4.3 数据流
scss
滚动(滚轮 / 拖滚动条 / 代码赋值)
↓
浏览器改变 viewport.scrollTop
↓
触发 scroll 事件 → onScroll 读取值 → 写入响应式 scrollTop
↓
startIndex / visibleItems / offsetY 自动重算
↓
Vue 只渲染那 20 个节点 + translateY 平移到位
五、五个关键机制(都是面试高频追问)
5.1 右侧滚动条是原生的吗?是我们"模拟"出来的吗?
是原生的,不是模拟的。 准确说法是:
我们伪造的是"高度",不是滚动条。
phantom 撑出 34 万像素,外层容器只有 300px 且 overflow-y: auto → 内容溢出 → 浏览器自动生成 一条真实滚动条,滑块长度比例 = 300 / 340000,完全由浏览器计算。
好处是不需要自己画滚动条、不需要处理拖拽手感,直接"借用"原生能力。
5.2 滚轮和直接拖滚动条,是两套逻辑吗?
不是,只有一套。 两者最终都只改变同一个变量 scrollTop,因此都触发同一个 scroll 事件、走同一段 onScroll。
虚拟滚动优雅的地方就在于:不关心输入来源,只看 scrollTop。
拖到最底部时 scrollTop 可能从 0 瞬间跳到 339700,startIndex 直接从 0 变成 ~9985,中间那 9900 多条从头到尾没被渲染过。因为是 O(1) 计算,瞬间大跳反而比渐变滚动更省。
5.3 滚到底部是"一瞬间渲染全部 DOM"吗?DOM 是换掉还是复用?
都不是全部渲染。 DOM 节点数恒定在 ~20 个。但"这 20 个节点是被销毁重建,还是被复用改文字",取决于 key 的取值------这是个很容易被忽略的性能细节:
| key 取值 | 滚动后的表现 | 开销 |
|---|---|---|
:key="vi.index"(数据绝对下标) |
旧 key 0~19、新 key 9985~10000 完全不同 → Vue 销毁 20 个 + 新建 20 个 |
有 DOM 创建/销毁 |
:key="i"(可视区相对位置) |
新旧 key 集合恒为 0~19 → Vue 复用同一批 DOM,只 patch 文本 |
几乎为零 |
所以最终采用相对索引,真正做到"DOM 固定不动,只换里面的内容"。
另外要清醒:"数据已在内存"不是快的根本原因。数据在内存只让"取哪一段"变成零成本;真正决定性能的是"只渲染 20 个"。即使数据有 1000 万条在内存,真渲染 1000 万个 DOM 照样卡死。
5.4 怎么手动控制滚动条位置(打开时定位到已选项)
关键点:响应式 scrollTop 和 DOM 真实 scrollTop 是两份状态,必须同时更新,否则会算出错误的可视区:
ts
/** 同时同步「响应式 scrollTop」与「DOM 真实 scrollTop」 */
function syncScroll(top: number) {
scrollTop.value = top
if (listRef.value) listRef.value.scrollTop = top
}
function open() {
if (props.disabled || visible.value) return
visible.value = true
keyword.value = ''
nextTick(() => {
// 下拉是 v-if 重建的:必须先归零,否则上次残留的 scrollTop 会让 offsetY 算到很远、视口空白
syncScroll(0)
const idx = props.options.findIndex((o) => o.value === props.modelValue)
if (idx >= 0) {
syncScroll(Math.max(0, idx * props.itemHeight - props.dropdownHeight / 2))
}
})
}
// 过滤后结果集长度会变,滚动位置必须回到顶部
watch(keyword, () => syncScroll(0))
5.5 点击外部关闭
用 document 上的全局监听 + rootRef.contains(target) 判断:
ts
function onDocClick(e: MouseEvent) {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) close()
}
这样多个实例天然互斥:点 B 时,A 的监听器发现"点的是别人" → A 关闭。
六、真实踩坑记录(最有价值的部分)
坑 1:@click.stop 掐断了全局通道,导致"点另一个 select 关不掉"
现象:页面上有两个 select,点开 A 后再点 B,A 不关闭,两个下拉重叠。
根因 :control 上原本写了 @click.stop="toggle"。stopPropagation() 把点击事件挡在了 document 之外,而"点击外部关闭"恰恰依赖 document 监听 → A 的监听器收不到事件。
注意:每个组件实例的状态(
visible/keyword/scrollTop)是隔离的,并不共享。 真正被共享的是document这个全局事件通道 ,而.stop切断了它。
修复 :去掉 control 与 dropdown 上的 .stop,让事件正常冒泡,由 onDocClick 统一裁决。 只有"清空按钮"保留 .stop(否则点清空会顺带触发 control 的 toggle 把下拉打开)。
坑 2:scrollTop 状态残留,导致重新打开时内容空白
现象:滚到很深的位置 → 关闭 → 再次打开,下拉一片空白。
根因 :下拉是 v-if 渲染的,关闭时 DOM 销毁,但响应式 scrollTop 还留着上次的值(如 339700)。重新打开时:
- 新 DOM 的真实
scrollTop= 0 - 响应式值 = 339700 →
offsetY算成 33 万多 content被平移到 33 万像素处,而视口停在顶部 → 看到空白
修复 :新增 syncScroll 强制同步两份状态,并在 open() 时先归零(见 5.4)。
坑 3:过滤后滚动位置越界
搜索关键字后结果集变短,滚动位置可能停留在已不存在的偏移上,同样导致空白。 修复:watch(keyword, () => syncScroll(0))。
这三个坑都指向同一类问题:虚拟列表里"渲染位置"是由数据算出来的,一旦数据和真实 DOM 状态不同步,界面就会崩。 承认并讲清楚这类坑,比背一个完美原理更有说服力。
七、与 Element Plus el-select-v2 对比
Element Plus 官方确实提供了虚拟化选择器 el-select-v2 (官方文档明确标注:用于解决"单个选择器加载数万行数据渲染到 DOM 带来的性能问题")。它内部同样是虚拟化渲染,并通过 item-height / estimated-option-height 对外暴露了高度模式开关。
7.1 能力对比
| 维度 | 本文 VirtualSelect |
el-select-v2 |
|---|---|---|
| 虚拟化 | ✅ 定高方案(自研 composable) | ✅ 定高(item-height,默认 34)/ 动态高度(estimated-option-height) |
| 下拉高度 | dropdown-height(自定义) |
height(默认 274,每项 34px) |
| 项高配置 | itemHeight(默认 34) |
item-height(默认 34) |
| 数据源 | options: {label, value}[] |
options + props 字段映射(2.4.2) |
| 本地过滤 | ✅ filterable |
✅ filterable + 自定义 filter-method |
| 远程搜索 | ❌ | ✅ remote + remote-method + debounce(默认 300ms) |
| 多选 | ❌ | ✅ multiple + collapse-tags + max-collapse-tags |
| 选项分组 | ❌ | ✅(options 嵌套 options) |
| 键盘导航 | ❌ | ✅ 原生支持(含 default-first-option) |
| 滚动到底事件 | ❌ | ✅ end-reached(2.14.0,可配合无限加载) |
| 创建临时选项 | ❌ | ✅ allow-create |
| 下拉挂载方式 | absolute 定位(无 teleport) |
teleported 默认 true(挂到 body,避免被容器裁剪) |
| 下拉销毁策略 | v-if 销毁重建 |
persistent:未激活且为 false 时销毁 |
| 插槽扩展 | #option 自定义选项 |
default / header / footer / empty / tag / loading / label |
| 无障碍 | ❌ | ✅ aria-label、tabindex 等 |
| 稳定性 | 自研,可控 | 官方标注组件目前在测试中 |
7.2 关键差异解读
① 高度模式:item-height vs estimated-option-height 这是官方设计里最值得学习的一点:
- 不传
estimated-option-height→ 走固定高度 模式,用item-height(默认 34)计算,性能好(和我实现的思路一致) - 传了
estimated-option-height→ 走动态高度模式,把该值当作估算高度,运行时测量真实高度
我的实现目前只支持定高。要支持不定高,需要引入 ResizeObserver 测量 + 偏移缓存表(或"预估高度 + 二分查找"定位 startIndex)------这正是 el-select-v2 动态模式的做法。
② end-reached 与无限加载 当数据量大到"连内存里都放不下"(比如 10 万条在服务端),纯前端虚拟滚动就失效了。官方提供 end-reached 事件让你在滚到底时加载下一页。这是虚拟滚动的重要边界:它只优化"已加载数据的渲染",不解决"数据从哪来"。
③ teleported 与 persistent 官方默认把下拉 teleport 到 body,避免被父级 overflow: hidden 裁剪(我的实现用 absolute,在有裁剪的容器里会被切掉)。persistent 则控制下拉是否销毁------这个开关背后,正是我在坑 2 里遇到的"销毁重建导致状态残留"问题。
7.3 那到底该用哪个?
生产环境优先用 el-select-v2。 理由:功能完整(多选/分组/远程/键盘/无障碍)、有社区维护、且同样是虚拟化渲染。
但仍值得自己实现一遍,因为:
- 面试问的是"虚拟滚动怎么实现",而不是"你配了哪个参数"
- 自研过程中抽象出的
useVirtualList可以复用到表格、信息流等官方没覆盖的场景 - 只有亲手踩过"两份 scrollTop 不同步""stopPropagation 掐断全局通道"这些坑,排查线上问题时才有直觉
面试加分回答: "生产我会直接用
el-select-v2,它是官方虚拟化方案,还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理,我手写了一遍,并把虚拟逻辑抽成useVirtualListcomposable------这样它不仅服务于 Select,表格和信息流也能复用。"
八、还能怎么演进
按优先级排序,都是面试官爱追问的方向:
- 不定高支持 :
ResizeObserver测量 + 偏移表,或"估算高度 + 二分查找"(对齐estimated-option-height) - 键盘导航 :上下键移动
activeIndex,回车选中,Esc关闭 - 远程搜索 :
loading态 + 输入防抖,对齐remote-method+debounce - 无限加载 :对齐
end-reached,滚到底自动拉下一页 - Teleport :把下拉挂到 body 并用
getBoundingClientRect定位,避免被父容器裁剪 - 多选 :
collapse-tags、全选等
九、面试怎么讲(STAR 话术,可直接背)
S(背景):业务里多个筛选下拉都是几千上万条选项,全量渲染会卡顿。
T(任务):需要一个支持万级数据、滚动不卡、体验与原生一致的下拉选择器。
A(行动) :我把虚拟滚动抽成
useVirtualListcomposable------定高 O(1) 计算偏移、phantom 撑出总高度让浏览器生成原生滚动条、translateY 平移可视窗口;组件层只负责交互与过滤。并用相对索引做 key 让 Vue 复用同一批 DOM,只 patch 文本。过程中还修了两个坑:stopPropagation掐断全局点击通道导致多实例无法互斥、v-if重建后响应式 scrollTop 与 DOM 不同步导致视口空白。R(结果):万级(实测 5 万)数据下 DOM 恒定只挂载约 20 个节点,滚动流畅;同一套 composable 后续可复用到表格与信息流。
如果能再补一句边界认知,印象分会更高:
"虚拟滚动只优化已加载数据的渲染。如果数据量大到要放在服务端,就必须配合远程搜索和
end-reached式的无限加载------这也是el-select-v2提供remote-method和end-reached的原因。"
十、小结
| 你以为学到了 | 实际能展示的能力 |
|---|---|
| 一个 Select 组件 | 性能优化(虚拟列表)原理 |
| ------ | 抽象分层(逻辑抽成 composable 复用) |
| ------ | Vue diff 与 key 的深度理解 |
| ------ | 事件机制(冒泡/全局监听)与状态同步 |
| ------ | 生产选型判断力(什么时候用官方、为什么要自研) |
这才是"高级前端封装组件"该有的回答层次。