从零实现一个带虚拟滚动的 Select

文章来源声明: 原文作者:再吃一根胡萝卜; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689656350306959406; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

虚拟滚动原理与真实踩坑讲得透,尤其 scrollTop 双状态与事件冒泡两坑最有价值;适合中高级前端面试复盘与组件封装参考。

从零实现一个带虚拟滚动的 Select:原理、踩坑与 `el-select-v2` 对比 --------------------------------------------

面试时被问"你封装过什么组件",只会答"封装过 table、form"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品:带虚拟滚动的下拉选择器。 包含完整原理、真实踩过的两个坑、以及与 Element Plus el-select-v2 的横向对比。


一、为什么要造这个轮子

业务里常见的场景:一个下拉框要塞几千甚至上万条选项(城市、门店、用户、SKU)。 如果按常规写法把 options 全量 v-for 渲染出来:

选项数量DOM 节点数后果
100100无感
10,00010,000首次展开明显卡顿、掉帧
50,00050,000直接卡死

浏览器的瓶颈不在"数据多少",而在真实 DOM 节点的数量(创建、布局、绘制、内存)。

虚拟滚动的解法很直接:数据可以有一万条,但 DOM 里永远只保留用户看得见的那十几个。


二、虚拟滚动到底做了什么(三句话讲清)

  1. 只渲染可视区:DOM 中只挂载"可视区 + 上下缓冲"的 ~20 个节点,不随数据量增长。
  2. 撑出假高度:用一个空的占位元素(phantom)撑出真实的总高度,让浏览器生成原生滚动条。
  3. 平移定位:用 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 里 → 换个表格又要重写一遍
  • ✅ 高级做法:抽成 useVirtualList composable,逻辑与 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。 理由:功能完整(多选/分组/远程/键盘/无障碍)、有社区维护、且同样是虚拟化渲染。

但仍值得自己实现一遍,因为:

  1. 面试问的是"虚拟滚动怎么实现",而不是"你配了哪个参数"
  2. 自研过程中抽象出的 useVirtualList 可以复用到表格、信息流等官方没覆盖的场景
  3. 只有亲手踩过"两份 scrollTop 不同步""stopPropagation 掐断全局通道"这些坑,排查线上问题时才有直觉

面试加分回答: "生产我会直接用 el-select-v2,它是官方虚拟化方案,还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理,我手写了一遍,并把虚拟逻辑抽成 useVirtualList composable——这样它不仅服务于 Select,表格和信息流也能复用。"


八、还能怎么演进

按优先级排序,都是面试官爱追问的方向:

  1. 不定高支持:ResizeObserver 测量 + 偏移表,或"估算高度 + 二分查找"(对齐 estimated-option-height)
  2. 键盘导航:上下键移动 activeIndex,回车选中,Esc 关闭
  3. 远程搜索:loading 态 + 输入防抖,对齐 remote-method + debounce
  4. 无限加载:对齐 end-reached,滚到底自动拉下一页
  5. Teleport:把下拉挂到 body 并用 getBoundingClientRect 定位,避免被父容器裁剪
  6. 多选:collapse-tags、全选等

九、面试怎么讲(STAR 话术,可直接背)

S(背景):业务里多个筛选下拉都是几千上万条选项,全量渲染会卡顿。

T(任务):需要一个支持万级数据、滚动不卡、体验与原生一致的下拉选择器。

A(行动):我把虚拟滚动抽成 useVirtualList composable——定高 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 的深度理解
——事件机制(冒泡/全局监听)与状态同步
——生产选型判断力(什么时候用官方、为什么要自研)

这才是"高级前端封装组件"该有的回答层次。