[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f20ymtdb991oqz":3},{"_id":4,"slug":5,"title":6,"subtitle":7,"kind":8,"cards":9,"tags":56,"categories":57,"source":58,"lang":61,"author":62,"audioState":65,"stats":66,"publishedAt":69,"renderer":70},"6ab9f133ca21c797c7e972f8","select-caa42909","从零实现一个带虚拟滚动的 Select","从零实现一个带虚拟滚动的 Select：原理、踩坑与 el-select-v2 对比","news",[10,13,18,22,27,32,37,42,47,52],{"headline":6,"body":7,"imageUrl":11,"images":12},"\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F0.webp",{"local":11},{"headline":14,"body":15,"imageUrl":16,"images":17},"面试时被问\"你封装过什么组件\"，只会答\"封装过 table、form\"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品：带虚拟滚动的下拉选择器。…","面试时被问\"你封装过什么组件\"，只会答\"封装过 table、form\"是不够的。 这篇文章记录一个真正能体现高级前端水准的作品：带虚拟滚动的下拉选择器。 包含完整原理、真实踩过的两个坑、以及与 Element Plus el-select-v2 的横向对比。","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F1.webp",{"local":16},{"headline":14,"body":19,"imageUrl":20,"images":21},"面试时被问\"你封装过什么组件\"，只会答\"封装过 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 \u002F 340000，完全由浏览器计算。 好处是不需要自己画滚动条、不需要处理拖拽手感，直接\"借用\"原生能力。 不是，只有一套。 两者最终都只改变同一个变量","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F2.webp",{"local":20},{"headline":23,"body":24,"imageUrl":25,"images":26},"scrollTop，因此都触发同一个 scroll 事件、走同一段 onScroll。 虚拟滚动优雅的地方就在于：不关心输入来源，只看 scrollTop。…","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\u002Fkeyword\u002FscrollTop）是隔离的，并不共享。 真正被共享的是 document 这个全局事件通道，而","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F3.webp",{"local":25},{"headline":28,"body":29,"imageUrl":30,"images":31},".stop 切断了它。 注意：每个组件实例的状态（visible\u002Fkeyword\u002FscrollTop）是隔离的，并不共享。 真正被共享的是 document…",".stop 切断了它。 注意：每个组件实例的状态（visible\u002Fkeyword\u002FscrollTop）是隔离的，并不共享。 真正被共享的是 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 对比","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F4.webp",{"local":30},{"headline":33,"body":34,"imageUrl":35,"images":36},"Element Plus 官方确实提供了虚拟化选择器 el-select-v2（官方文档明确标注：用于解决\"单个选择器加载数万行数据渲染到 DOM…","Element Plus 官方确实提供了虚拟化选择器 el-select-v2（官方文档明确标注：用于解决\"单个选择器加载数万行数据渲染到 DOM 带来的性能问题\"）。它内部同样是虚拟化渲染，并通过 item-height \u002F estimated-option-height 对外暴露了高度模式开关。 ① 高度模式：item-height vs estimated-option-height 这是官方设计里最值得学习的一点： 不传 estimated-option-height → 走固定高度模式，用 item-height（默认 34）计算，性能好（和我实现的思路一致） 传了 estimated-option-height → 走动态高度模式，把该值当作估算高度，运行时测量真实高度 我的实现目前只支持定高。要支持不定高，需要引入 ResizeObserver 测量 + 偏移缓存表（或\"预估高度 + 二分查找\"定位 startIndex）——这正是 el-select-v2 动态模式的做法。","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F5.webp",{"local":35},{"headline":38,"body":39,"imageUrl":40,"images":41},"② end-reached 与无限加载 当数据量大到\"连内存里都放不下\"（比如 10 万条在服务端），纯前端虚拟滚动就失效了。官方提供 end-reached…","② end-reached 与无限加载 当数据量大到\"连内存里都放不下\"（比如 10 万条在服务端），纯前端虚拟滚动就失效了。官方提供 end-reached 事件让你在滚到底时加载下一页。这是虚拟滚动的重要边界：它只优化\"已加载数据的渲染\"，不解决\"数据从哪来\"。","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F6.webp",{"local":40},{"headline":43,"body":44,"imageUrl":45,"images":46},"③ teleported 与 persistent 官方默认把下拉 teleport 到 body，避免被父级","③ teleported 与 persistent 官方默认把下拉 teleport 到 body，避免被父级 overflow: hidden 裁剪（我的实现用 absolute，在有裁剪的容器里会被切掉）。persistent 则控制下拉是否销毁——这个开关背后，正是我在坑 2 里遇到的\"销毁重建导致状态残留\"问题。 生产环境优先用 el-select-v2。 理由：功能完整（多选\u002F分组\u002F远程\u002F键盘\u002F无障碍）、有社区维护、且同样是虚拟化渲染。 面试问的是\"虚拟滚动怎么实现\"，而不是\"你配了哪个参数\" 自研过程中抽象出的 useVirtualList 可以复用到表格、信息流等官方没覆盖的场景 只有亲手踩过\"两份 scrollTop 不同步\"\"stopPropagation 掐断全局通道\"这些坑，排查线上问题时才有直觉","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F7.webp",{"local":45},{"headline":48,"body":49,"imageUrl":50,"images":51},"面试加分回答： \"生产我会直接用 el-select-v2，它是官方虚拟化方案，还覆盖了不定高、远程搜索、无限加载。…","面试加分回答： \"生产我会直接用 el-select-v2，它是官方虚拟化方案，还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理，我手写了一遍，并把虚拟逻辑抽成 useVirtualList composable——这样它不仅服务于 Select，表格和信息流也能复用。\"","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F8.webp",{"local":50},{"headline":48,"body":53,"imageUrl":54,"images":55},"面试加分回答： \"生产我会直接用 el-select-v2，它是官方虚拟化方案，还覆盖了不定高、远程搜索、无限加载。 但为了真正理解原理，我手写了一遍，并把虚拟逻辑抽成 useVirtualList composable——这样它不仅服务于 Select，表格和信息流也能复用。\" 不定高支持：ResizeObserver 测量 + 偏移表，或\"估算高度 + 二分查找\"（对齐 estimated-option-height） 键盘导航：上下键移动 activeIndex，回车选中，Esc 关闭 远程搜索：loading 态 + 输入防抖，对齐 remote-method + debounce 无限加载：对齐 end-reached，滚到底自动拉下一页 Teleport：把下拉挂到 body 并用 getBoundingClientRect 定位，避免被父容器裁剪","\u002Fapi\u002Fmedia\u002Fposts\u002Fselect-caa42909\u002F9.webp",{"local":54},[],[],{"name":59,"url":60},"Juejin","https:\u002F\u002Fjuejin.cn\u002Fpost\u002F7689656350306959406","zh",{"handle":63,"displayName":64},"spots","Spots",null,{"views":67,"likes":68,"saves":68,"shares":68,"completions":68,"opens":68,"skips":68,"depthSum":68},5,0,"2026-09-28T04:46:43.070Z","local"]