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

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

面试时被问"你封装过什么组件",只会答"封装过 table、form"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品:带虚拟滚动的下拉选择器。 包含完整原理、真实踩过的两个坑、以及与 Element Plus el-select-v2 的横向对比。
面试时被问"你封装过什么组件",只会答"封装过 table、form"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品:带虚拟滚动的下拉选择器。 包含完整原理、真实踩过的两个坑、以及与 Element Plus el-select-v2 的横向对比。 业务里常见的场景:一个下拉框要塞几千甚至上万条选项(城市、门店、用户、SKU)。 如果按常规写法把 options 全量 v-for 渲染出来: 浏览器的瓶颈不在"数据多少",而在真实 DOM 节点的数量(创建、布局、绘制、内存)。 虚拟滚动的解法很直接:数据可以有一万条,但 DOM 里永远只保留用户看得见的那十几个。 只渲染可视区:DOM 中只挂载"可视区 + 上下缓冲"的 ~20 个节点,不随数据量增长。 撑出假高度:用一个空的占位元素(phantom)撑出真实的总高度,让浏览器生成原生滚动条。 平移定位:用 transform: translateY(...) 把这 ~20 个节点整体移动到正确的位置。 因为每一项高度固定,偏移量可以 O(1) 算出来: 三、架构决策:为什么把虚拟逻辑抽成 composable ❌ 初级做法:把滚动计算直接写在 Select.vue 里 → 换个表格又要重写一遍 ✅ 高级做法:抽成 useVirtualList composable,逻辑与 UI 解耦 这样做的好处:可单测、可复用、组件层只关心交互。面试时能讲出"我为什么这么分层",比讲出"我写了什么"更值钱。 输入是一个数据 getter(支持过滤后动态变化)和项高配置,输出渲染所需的一切: 举例:1 万条 × 34px = 34 万像素高的空 div,而里面实际只有 ~20 个节点。 phantom 撑出 34 万像素,外层容器只有 300px 且 overflow-y: auto → 内容溢出 → 浏览器自动生成一条真实滚动条,滑块长度比例 = 300 / 340000,完全由浏览器计算。 好处是不需要自己画滚动条、不需要处理拖拽手感,直接"借用"原生能力。 不是,只有一套。 两者最终都只改变同一个变量
scrollTop,因此都触发同一个 scroll 事件、走同一段 onScroll。 虚拟滚动优雅的地方就在于:不关心输入来源,只看 scrollTop。 拖到最底部时 scrollTop 可能从 0 瞬间跳到 339700,startIndex 直接从 0 变成 ~9985,中间那 9900 多条从头到尾没被渲染过。因为是 O(1) 计算,瞬间大跳反而比渐变滚动更省。 都不是全部渲染。 DOM 节点数恒定在 ~20 个。但"这 20 个节点是被销毁重建,还是被复用改文字",取决于 key 的取值——这是个很容易被忽略的性能细节: 所以最终采用相对索引,真正做到"DOM 固定不动,只换里面的内容"。 另外要清醒:"数据已在内存"不是快的根本原因。数据在内存只让"取哪一段"变成零成本;真正决定性能的是"只渲染 20 个"。即使数据有 1000 万条在内存,真渲染 1000 万个 DOM 照样卡死。 另外要清醒:"数据已在内存"不是快的根本原因。数据在内存只让"取哪一段"变成零成本;真正决定性能的是"只渲染 20 个"。即使数据有 1000 万条在内存,真渲染 1000 万个 DOM 照样卡死。 关键点:响应式 scrollTop 和 DOM 真实 scrollTop 是两份状态,必须同时更新,否则会算出错误的可视区: 用 document 上的全局监听 + rootRef.contains(target) 判断: 这样多个实例天然互斥:点 B 时,A 的监听器发现"点的是别人" → A 关闭。 现象:页面上有两个 select,点开 A 后再点 B,A 不关闭,两个下拉重叠。 根因:control 上原本写了 @click.stop="toggle"。stopPropagation() 把点击事件挡在了 document 之外,而"点击外部关闭"恰恰依赖 document 监听 → A 的监听器收不到事件。 注意:每个组件实例的状态(visible/keyword/scrollTop)是隔离的,并不共享。 真正被共享的是 document 这个全局事件通道,而
.stop 切断了它。 注意:每个组件实例的状态(visible/keyword/scrollTop)是隔离的,并不共享。 真正被共享的是 document 这个全局事件通道,而 .stop 切断了它。 修复:去掉 control 与 dropdown 上的 .stop,让事件正常冒泡,由 onDocClick 统一裁决。 只有"清空按钮"保留 .stop(否则点清空会顺带触发 control 的 toggle 把下拉打开)。 现象:滚到很深的位置 → 关闭 → 再次打开,下拉一片空白。 根因:下拉是 v-if 渲染的,关闭时 DOM 销毁,但响应式 scrollTop 还留着上次的值(如 339700)。重新打开时: 响应式值 = 339700 → offsetY 算成 33 万多 content 被平移到 33 万像素处,而视口停在顶部 → 看到空白 修复:新增 syncScroll 强制同步两份状态,并在 open() 时先归零(见 5.4)。 搜索关键字后结果集变短,滚动位置可能停留在已不存在的偏移上,同样导致空白。 修复:watch(keyword, () => syncScroll(0))。 这三个坑都指向同一类问题:虚拟列表里"渲染位置"是由数据算出来的,一旦数据和真实 DOM 状态不同步,界面就会崩。 承认并讲清楚这类坑,比背一个完美原理更有说服力。 七、与 Element Plus el-select-v2 对比
Element Plus 官方确实提供了虚拟化选择器 el-select-v2(官方文档明确标注:用于解决"单个选择器加载数万行数据渲染到 DOM 带来的性能问题")。它内部同样是虚拟化渲染,并通过 item-height / estimated-option-height 对外暴露了高度模式开关。 ① 高度模式: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 里遇到的"销毁重建导致状态残留"问题。 生产环境优先用 el-select-v2。 理由:功能完整(多选/分组/远程/键盘/无障碍)、有社区维护、且同样是虚拟化渲染。 面试问的是"虚拟滚动怎么实现",而不是"你配了哪个参数" 自研过程中抽象出的 useVirtualList 可以复用到表格、信息流等官方没覆盖的场景 只有亲手踩过"两份 scrollTop 不同步""stopPropagation 掐断全局通道"这些坑,排查线上问题时才有直觉
面试加分回答: "生产我会直接用 el-select-v2,它是官方虚拟化方案,还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理,我手写了一遍,并把虚拟逻辑抽成 useVirtualList composable——这样它不仅服务于 Select,表格和信息流也能复用。"
面试加分回答: "生产我会直接用 el-select-v2,它是官方虚拟化方案,还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理,我手写了一遍,并把虚拟逻辑抽成 useVirtualList composable——这样它不仅服务于 Select,表格和信息流也能复用。" 不定高支持:ResizeObserver 测量 + 偏移表,或"估算高度 + 二分查找"(对齐 estimated-option-height) 键盘导航:上下键移动 activeIndex,回车选中,Esc 关闭 远程搜索:loading 态 + 输入防抖,对齐 remote-method + debounce 无限加载:对齐 end-reached,滚到底自动拉下一页 Teleport:把下拉挂到 body 并用 getBoundingClientRect 定位,避免被父容器裁剪
