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

54 阅读12分钟

从零实现一个带虚拟滚动的 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) 算出来:

startIndex = floor(scrollTop / itemHeight) - overscan
renderCount = ceil(viewportHeight / itemHeight) + overscan * 2
offsetY    = startIndex * itemHeight

三、架构决策:为什么把虚拟逻辑抽成 composable

一个很容易被忽略的"高级感"分水岭:

  • ❌ 初级做法:把滚动计算直接写在 Select.vue 里 → 换个表格又要重写一遍
  • ✅ 高级做法:抽成 useVirtualList composable,逻辑与 UI 解耦
useVirtualList(纯逻辑,不碰 DOM 结构)
        ↓ 被复用
VirtualSelect / VirtualTable / VirtualFeed ...

这样做的好处:可单测、可复用、组件层只关心交互。面试时能讲出"我为什么这么分层",比讲出"我写了什么"更值钱。


四、核心实现

4.1 虚拟滚动内核 useVirtualList.ts

输入是一个数据 getter(支持过滤后动态变化)和项高配置,输出渲染所需的一切:

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 结构:三层嵌套是关键

<!-- 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 数据流

滚动(滚轮 / 拖滚动条 / 代码赋值)
   ↓
浏览器改变 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 是两份状态,必须同时更新,否则会算出错误的可视区:

/** 同时同步「响应式 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) 判断:

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 能力对比

维度本文 VirtualSelectel-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 的深度理解
——事件机制(冒泡/全局监听)与状态同步
——生产选型判断力(什么时候用官方、为什么要自研)

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