面试时被问"你封装过什么组件",只会答"封装过 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) 算出来:
<span>startIndex</span> = floor(scrollTop / itemHeight) - overscan
<span>renderCount</span> = ceil(viewportHeight / itemHeight) + overscan * <span>2</span>
<span>offsetY</span> = startIndex * itemHeight
三、架构决策:为什么把虚拟逻辑抽成 composable
一个很容易被忽略的"高级感"分水岭:
- ❌ 初级做法:把滚动计算直接写在
Select.vue里 → 换个表格又要重写一遍 - ✅ 高级做法:抽成
useVirtualListcomposable,逻辑与 UI 解耦
useVirtualList(纯逻辑,不碰 DOM 结构)
<span> ↓ 被复用
VirtualSelect / VirtualTable / VirtualFeed ...
</span>
这样做的好处:可单测、可复用、组件层只关心交互。面试时能讲出"我为什么这么分层",比讲出"我写了什么"更值钱。
四、核心实现
4.1 虚拟滚动内核 useVirtualList.ts
输入是一个数据 getter(支持过滤后动态变化)和项高配置,输出渲染所需的一切:
<span>export</span> <span>function</span> useVirtualList<T>(<span>source</span>: <span>() =></span> T[], <span>options</span>: <span>UseVirtualListOptions</span>) {
<span>const</span> itemHeight = options.<span>itemHeight</span>
<span>const</span> overscan = options.<span>overscan</span> ?? <span>6</span>
<span>const</span> scrollTop = <span>ref</span>(<span>0</span>)
<span>const</span> viewportHeight = <span>ref</span>(<span>0</span>)
<span>// 滚动总高度:决定原生滚动条的真实长度</span>
<span>const</span> totalHeight = <span>computed</span>(<span>() =></span> <span>source</span>().<span>length</span> * itemHeight)
<span>// 可视区起始下标(向上多留 overscan 缓冲)</span>
<span>const</span> startIndex = <span>computed</span>(<span>() =></span> {
<span>const</span> idx = <span>Math</span>.<span>floor</span>(scrollTop.<span>value</span> / itemHeight) - overscan
<span>return</span> <span>Math</span>.<span>max</span>(<span>0</span>, idx)
})
<span>// 可视区结束下标</span>
<span>const</span> endIndex = <span>computed</span>(<span>() =></span> {
<span>const</span> capacity =
viewportHeight.<span>value</span> > <span>0</span>
? <span>Math</span>.<span>ceil</span>(viewportHeight.<span>value</span> / itemHeight) + overscan
: <span>source</span>().<span>length</span>
<span>return</span> <span>Math</span>.<span>min</span>(<span>source</span>().<span>length</span>, startIndex.<span>value</span> + capacity)
})
<span>// 真正渲染的少量节点(带原始下标)</span>
<span>const</span> visibleItems = computed<<span>VirtualItem</span><T>[]>(<span>() =></span>
<span>source</span>()
.<span>slice</span>(startIndex.<span>value</span>, endIndex.<span>value</span>)
.<span>map</span>(<span>(<span>item, i</span>) =></span> ({ item, <span>index</span>: startIndex.<span>value</span> + i })),
)
<span>// 把可视窗口整体下移到正确位置</span>
<span>const</span> offsetY = <span>computed</span>(<span>() =></span> startIndex.<span>value</span> * itemHeight)
<span>function</span> <span>onScroll</span>(<span>e: Event</span>) {
scrollTop.<span>value</span> = (e.<span>target</span> <span>as</span> <span>HTMLElement</span>).<span>scrollTop</span>
}
<span>function</span> <span>setViewportHeight</span>(<span>h: <span>number</span></span>) {
viewportHeight.<span>value</span> = h
}
<span>return</span> { totalHeight, visibleItems, offsetY, onScroll, setViewportHeight, scrollTop }
}
4.2 DOM 结构:三层嵌套是关键
<span><!-- 1. 滚动容器:固定高度 + overflow-y:auto --></span>
<span><<span>div</span> <span>class</span>=<span>"vselect-viewport"</span> <span>:style</span>=<span>"{ height: dropdownHeight + 'px' }"</span> @<span>scroll</span>=<span>"onScroll"</span>></span>
<span><!-- 2. 占位层:撑出总高度,让浏览器生成原生滚动条 --></span>
<span><<span>div</span> <span>class</span>=<span>"vselect-phantom"</span> <span>:style</span>=<span>"{ height: totalHeight + 'px' }"</span>></span>
<span><!-- 3. 内容层:只渲染可视项,整体平移定位 --></span>
<span><<span>div</span> <span>class</span>=<span>"vselect-content"</span> <span>:style</span>=<span>"{ transform: `translateY(${offsetY}px)` }"</span>></span>
<span><<span>div</span> <span>v-for</span>=<span>"(vi, i) in visibleItems"</span> <span>:key</span>=<span>"i"</span> <span>...</span>></span>{{ vi.item.label }}<span></<span>div</span>></span>
<span></<span>div</span>></span>
<span></<span>div</span>></span>
<span></<span>div</span>></span>
举例:1 万条 × 34px = 34 万像素高的空 div,而里面实际只有 ~20 个节点。
4.3 数据流
滚动(滚轮 / 拖滚动条 / 代码赋值)
↓
浏览器改变 viewport<span>.scrollTop</span>
↓
触发 scroll 事件 → onScroll 读取值 → 写入响应式 scrollTop
↓
startIndex / visibleItems / offsetY 自动重算
↓
Vue 只渲染那 <span>20</span> 个节点 + 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 是两份状态,必须同时更新,否则会算出错误的可视区:
<span>/** 同时同步「响应式 scrollTop」与「DOM 真实 scrollTop」 */</span>
<span>function</span> <span>syncScroll</span>(<span>top: <span>number</span></span>) {
scrollTop.<span>value</span> = top
<span>if</span> (listRef.<span>value</span>) listRef.<span>value</span>.<span>scrollTop</span> = top
}
<span>function</span> <span>open</span>(<span></span>) {
<span>if</span> (props.<span>disabled</span> || visible.<span>value</span>) <span>return</span>
visible.<span>value</span> = <span>true</span>
keyword.<span>value</span> = <span>''</span>
<span>nextTick</span>(<span>() =></span> {
<span>// 下拉是 v-if 重建的:必须先归零,否则上次残留的 scrollTop 会让 offsetY 算到很远、视口空白</span>
<span>syncScroll</span>(<span>0</span>)
<span>const</span> idx = props.<span>options</span>.<span>findIndex</span>(<span>(<span>o</span>) =></span> o.<span>value</span> === props.<span>modelValue</span>)
<span>if</span> (idx >= <span>0</span>) {
<span>syncScroll</span>(<span>Math</span>.<span>max</span>(<span>0</span>, idx * props.<span>itemHeight</span> - props.<span>dropdownHeight</span> / <span>2</span>))
}
})
}
<span>// 过滤后结果集长度会变,滚动位置必须回到顶部</span>
<span>watch</span>(keyword, <span>() =></span> <span>syncScroll</span>(<span>0</span>))
5.5 点击外部关闭
用 document 上的全局监听 + rootRef.contains(target) 判断:
<span>function</span> <span>onDocClick</span>(<span>e: MouseEvent</span>) {
<span>if</span> (rootRef.<span>value</span> && !rootRef.<span>value</span>.<span>contains</span>(e.<span>target</span> <span>as</span> <span>Node</span>)) <span>close</span>()
}
这样多个实例天然互斥:点 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 的深度理解 |
| —— | 事件机制(冒泡/全局监听)与状态同步 |
| —— | 生产选型判断力(什么时候用官方、为什么要自研) |
这才是"高级前端封装组件"该有的回答层次。
虚拟滚动原理与真实踩坑讲得透,尤其 scrollTop 双状态与事件冒泡两坑最有价值;适合中高级前端面试复盘与组件封装参考。