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

从零实现一个带虚拟滚动的 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 里永远只保留用户看得见的那十几个。


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

  1. 只渲染可视区:DOM 中只挂载"可视区 + 上下缓冲"的 ~20 个节点,不随数据量增长。
  2. 撑出假高度 :用一个空的占位元素(phantom)撑出真实的总高度,让浏览器生成原生滚动条。
  3. 平移定位 :用 transform: translateY(...) 把这 ~20 个节点整体移动到正确的位置。

因为每一项高度固定,偏移量可以 O(1) 算出来:

ini 复制代码
startIndex = floor(scrollTop / itemHeight) - overscan
renderCount = ceil(viewportHeight / itemHeight) + overscan * 2
offsetY    = startIndex * itemHeight

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

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

  • ❌ 初级做法:把滚动计算直接写在 Select.vue 里 → 换个表格又要重写一遍
  • ✅ 高级做法:抽成 useVirtualList composable,逻辑与 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。 理由:功能完整(多选/分组/远程/键盘/无障碍)、有社区维护、且同样是虚拟化渲染。

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

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

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

相关推荐
程序员Flycan1 小时前
🚀 跨域终结者:前端代理服务器(Proxy)原理解析与配置总结
前端
怕浪猫1 小时前
顶级模型一句话,AI 写出了能玩的 QQ飞车
前端·面试·github
huakoh1 小时前
MCP 报错分不清?先看响应里是 result 还是 error
前端
呃呃呃呃ex1 小时前
10. 现代前端工程化:ES6 React 项目实战与原理解析
前端·javascript
呃呃呃呃ex1 小时前
9. TypeScript:从类型系统到工程实践
前端·面试
粥里有勺糖1 小时前
视野修炼-技术周刊第134期 | 豆豆眼头像
前端·github·aigc
浪遏1 小时前
D2C 系统架构复盘:从设计稿到代码,我们踩过的坑和最终方案
前端·javascript·后端
光影少年1 小时前
Fabric渲染流程
前端·react native·react.js
IT_陈寒1 小时前
我的JavaScript代码为啥在forEach里没按预期执行?
前端·人工智能·后端