- 前端
- UI组件
【免费下载链接】virtual
🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte
@tanstack/vue-virtual是 TanStack Virtual 在 Vue 框架上的官方适配层,负责把无框架的@tanstack/virtual-core核心虚拟化逻辑封装为响应式的 Vue 组合式 API。本文以仓库中的 CHANGELOG 为主线,梳理该适配器从 3.13.3 到 3.13.39 的版本演进脉络,结合 源码实现、虚拟化核心 API 文档 与 Vue 示例 中的真实代码,讲清楚每个关键修复背后的原理,以及 Vue 适配器究竟如何把核心虚拟化能力桥接到 Vue 的响应式体系。读完你会掌握:如何阅读这个包的版本记录并追踪底层 core 的变更、useVirtualizer/useWindowVirtualizer两个入口的响应式工作原理,以及动态测量、锚定模式等新能力的实战用法。
版本总览:Vue 适配器版本节奏与 core 的耦合关系
@tanstack/vue-virtual走的是「薄适配层 + 共享核心」的包结构。从 CHANGELOG 可以看到一个非常清晰的事实:几乎所有版本号(3.13.4 ~ 3.13.39)都只有一个 Patch Changes 条目,即「Updated dependencies」指向@tanstack/virtual-core的某个新版本。
这并非版本记录偷懒,而是工程设计的必然结果。查看包的依赖声明,package.json 中唯一的运行时依赖就是@tanstack/virtual-core(以 workspace 协议引入),整个src/index.ts不过百来行。也就是说:
- 3.13.4 ~ 3.13.12、3.13.14 ~ 3.13.21、3.13.23 ~ 3.13.26 等版本,纯属 core 升级,适配器自身零改动;
- 3.13.13 是唯一一个自带 Fix 条目的版本,修复了
count变化时getTotalSize()返回过期值的问题; - 3.13.22、3.13.27、3.13.28、3.13.39 等版本对应 core 的 minor(如 3.14.0、3.15.0、3.16.0、3.17.0/3.17.11)或 patch 升级,其中 core 的 3.16.0 与 3.17.0 引入了影响面较大的新能力。
在仓库中可以通过两条 CHANGELOG 对照阅读:Vue 侧记录「适配器自身改了什么 + 依赖的 core 升到哪」,virtual-core 的 CHANGELOG 记录「core 具体修了什么」。这种分层让框架适配器与核心逻辑可以各自独立迭代、共享同一份性能与正确性改进,这也是本仓库所有框架包(React、Solid、Svelte、Lit、Marko、Angular 等)共用的架构。
版本对照速查表
下表整理了本仓库所记录的两个包在对应版本号上的耦合关系(Vue 适配器 3.13.39 对应 virtual-core 3.17.11,为当前最新版本):
| @tanstack/vue-virtual | @tanstack/virtual-core | 主要变更 |
|---|---|---|
| 3.13.3 | 3.13.3 | 基础版本 |
| 3.13.13 | 3.13.13 | 修复 count 变化时 getTotalSize() 过期(适配器层唯一 Fix) |
| 3.13.24 | 3.14.0 | core minor 升级 |
| 3.13.25 | 3.15.0 | core minor 升级(多列 masonry 相关能力) |
| 3.13.27 | 3.16.0/3.16.1 | core minor:anchorTo: 'end' 聊天/日志模式等 |
| 3.13.28 | 3.17.0 | core minor:useCachedMeasurements 选项 |
| 3.13.39 | 3.17.11 | 最新版本;core 修复平滑滚动、锚定补偿等 |
唯一自带修复的版本:3.13.13 与 count 变化时的高度更新
在 CHANGELOG 的 3.13.13 条目中记录了一个值得展开的修复(对应 upstream PR #1085):
Fix: Notify framework when count changes to update
getTotalSize()
问题现象:当count选项发生变化时(例如前端做过滤或搜索,列表从 100 条变成 20 条),getTotalSize()会返回过期值。修复前,过滤后列表容器仍保持之前的高度——count减少时出现大片空白,count增加时新内容不可达。
修复方式:virtualizer 在「会影响测量结果的选项」变化时自动通知框架。也就是说,core 现在会追踪哪些选项影响测量缓存,一旦count这类选项变更,就触发框架侧重新渲染,让高度随count同步更新,用户不再需要手写useMemo之类的补偿逻辑。条目还强调该修复对所有框架适配器生效,且每次变化的性能开销极小(< 0.1ms)。
对照 Vue 适配器的实现可以理解「通知框架」是如何落地的。在 packages/vue-virtual/src/index.ts 中,适配器用watch监听选项对象,一旦变化就调用virtualizer.setOptions(...)并在onChange回调里执行triggerRef(state):
watch( () => unref(options), (options) => { virtualizer.setOptions({ ...options, onChange: (instance, sync) => { triggerRef(state) options.onChange?.(instance, sync) }, }) virtualizer._willUpdate() triggerRef(state) }, { immediate: true }, )state是一个shallowRef(virtualizer),triggerRef强制触发该 ref 的更新,从而让依赖它的getTotalSize()/getVirtualItems()计算属性重新求值。这就是「core 通知框架」在 Vue 侧的完整链路:core 内部检测到测量相关选项变化 → 调用onChange→ 适配器triggerRef→ 模板中的computed重新计算。
适配器的响应式工作原理:useVirtualizer 与 useWindowVirtualizer
两个组合式 API 的入口
Vue 框架文档 明确说明@tanstack/vue-virtual是围绕核心虚拟逻辑的薄封装,对外只暴露两个函数:
function useVirtualizer<TScrollElement, TItemElement = unknown>( options: PartialKeys< VirtualizerOptions<TScrollElement, TItemElement>, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' >, ): Virtualizer<TScrollElement, TItemElement> function useWindowVirtualizer<TItemElement = unknown>( options: PartialKeys< VirtualizerOptions<Window, TItemElement>, | 'getScrollElement' | 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' >, ): Virtualizer<Window, TItemElement>两者的区别只在于滚动载体:
useVirtualizer:返回配置为以HTML 元素作为滚动元素的Virtualizer实例;useWindowVirtualizer:返回以window作为滚动元素的实例,用于整页滚动场景。
源码级实现解析
从 packages/vue-virtual/src/index.ts 可以完整还原适配器实现。两个入口都汇聚到私有的useVirtualizerBase:
- 创建实例:
const virtualizer = new Virtualizer(unref(options)),用shallowRef包裹为state; - 挂载清理:调用
virtualizer._didMount()得到cleanup,注册到onScopeDispose(cleanup),组件销毁时自动解除 ResizeObserver、滚动监听等副作用; - 滚动元素监听:
watch(() => unref(options).getScrollElement(), ...),滚动元素一旦就绪(比如 ref 绑定完成)就调用_willUpdate(); - 选项响应式同步:上面的
watch用setOptions把新选项(含包装后的onChange)同步给 core,并触发_willUpdate()与triggerRef(state); - 返回
state(即Ref<Virtualizer>),模板中通过.value访问实例方法。
useVirtualizer额外通过computed注入三个默认实现:
useVirtualizerBase( computed(() => ({ observeElementRect: observeElementRect, observeElementOffset: observeElementOffset, scrollToFn: elementScroll, ...unref(options), })), )即元素模式下默认使用observeElementRect/observeElementOffset(基于 ResizeObserver 与 scroll 事件)和elementScroll。useWindowVirtualizer则注入getScrollElement: () => window、observeWindowRect、observeWindowOffset、windowScroll以及initialOffset: () => window.scrollY(见 源码),这些默认实现与 virtualizer API 文档 中描述的elementScroll/windowScroll/observeElementRect/observeWindowRect一一对应。
需要注意:尽管文档签名写作返回Virtualizer,实际实现返回的是Ref<Virtualizer>(state),Vue 模板与计算属性中要用.value访问——这正是 示例代码 中rowVirtualizer.value.getVirtualItems()的写法来源。
版本演进中的核心能力:从 core 升级看功能增量
虽然 Vue 适配器自身改动极少,但跟随 core 的版本升级,Vue 用户也同步获得了大量底层能力。以下是本仓库 virtual-core CHANGELOG 中记录的、随 Vue 适配器各版本一起落地的关键能力:
core 3.16.0:anchorTo: 'end' 聊天/日志模式
core 3.16.0(对应 Vue 3.13.26 → 3.13.27)引入了端锚定虚拟化,专为聊天、日志、反向信息流设计:
- 新增
anchorTo: 'end'选项:当旧内容被前插(prepend)时保持当前可见项稳定;流式输出中最后一项增长时保持视口钉在底部;默认仍是'start'(顶部/左侧锚定),保持原有行为; - 新增
followOnAppend:只有视口原本就在末尾时,新追加内容才自动滚入视野;往上翻看历史的用户不会被拉回底部; - 新增辅助 API:
scrollEndThreshold、scrollToEnd()、getDistanceFromEnd()、isAtEnd()。
这些 API 的语义在 virtualizer 文档 中有完整定义:scrollEndThreshold默认1(像素阈值),isAtEnd(threshold?)判断视口是否在距末端阈值范围内,scrollToEnd()对纵向列表滚动到底部。配套文档还强调:前插稳定性要求基于持久 id 的稳定getItemKey,因为索引键无法区分前插与追加。
3.16.1 又修复了一个前插时的「一帧跳跃」问题:anchorTo: 'end'下前插内容时,会有一帧按旧估算位置计算可见范围,随后_willUpdate修正,产生可见跳动;修复后在渲染过程中于setOptions内提前调整scrollOffset,使calculateRange/getVirtualItems立即返回正确条目。
core 3.17.0:useCachedMeasurements 与测量缓存
core 3.17.0(对应 Vue 3.13.28)新增useCachedMeasurements选项(见 virtualizer 文档):
- 启用后默认
measureElement跳过 DOM 读取,直接返回缓存尺寸(无缓存则回退到estimateSize); - 典型场景:列表被临时隐藏(如父元素
display: none)时,ResizeObserver 会对所有项报告尺寸 0,导致测量被重置;启用该选项后隐藏期间测量不被清零,恢复显示后也不会出现布局跳动; - 使用方式是在隐藏前把该选项置
true、显示后置false,ResizeObserver 始终保持挂载,关闭后真实测量自动恢复; - 注意它只影响默认
measureElement,自定义测量时需自行处理。
3.17.0 还顺带优化了默认measureElement:已有缓存时跳过同步 DOM 读(offsetWidth/offsetHeight),减少重渲染时的 layout reflow。
滚动与测量正确性修复(3.17.x 系列)
从 3.17.1 到 3.17.11 的密集 patch 主要打磨滚动补偿与测量时序,这些修复全部随 Vue 适配器 3.13.29+ 自动获得:
- 向上滚动不跳动(3.17.1):默认滚动补偿谓词在向上滚动时也补偿「估算→实测」首测差值,但跳过重测补偿,避免级联抖动;
- 滚动方向不误锁(3.17.3、3.17.5):虚拟器自身补偿写入触发的滚动事件不再被当作
backward方向锁定,避免多帧回流期间视口漂移; - 减少 GC 压力(3.17.3):默认单车道路径按滚动帧零分配,去掉每次滚动事件上的选项对象与闭包分配;
- gap 选项变化失效测量(3.17.4):gap 变更会失效测量缓存;多车道(masonry)布局改用增量车道 argmin,替代反向扫描;
- 滚动事件去重与端锚定同步(3.17.2):跳过相同 offset 的冗余滚动事件;
applyScrollAdjustment中同步scrollOffset,避免端锚定流式增长时被浏览器 clamp 丢失; - iOS 处理(3.17.5、3.17.6、3.17.7):清理时重置 iOS 手势/延迟状态;视口整体跨越折叠线的条目增长不再默认补偿,避免聊天流式消息被逐 token 拖拽;iOS 延迟补偿不再重放过期增量;
- 平滑滚动存活(3.17.11):前插内容时保持行进中的平滑
scrollToIndex存活(anchorTo: 'end'下不再被同步写scrollTop打断);debounced 滚动结束回退读取当前 offset,避免被过期状态覆盖。
这些条目同样值得开发者关注:如果你的 Vue 列表在聊天、日志、流式输出、iOS 触屏滚动等场景遇到跳动、漂移或钉底失效问题,对应的修复版本就是排查与升级依据。
Vue 中的实际用法:从仓库示例看标准接线
固定/动态尺寸的经典写法
examples/vue/fixed 展示了基于固定尺寸的「行、列、网格」三种形态,examples/vue/variable 展示了动态尺寸写法。核心接线方式(以动态为例):
<script setup lang="ts"> import { ref, computed } from 'vue' import { useVirtualizer } from '@tanstack/vue-virtual' const parentRef = ref<HTMLElement | null>(null) const rowVirtualizer = useVirtualizer({ count: props.rows.length, getScrollElement: () => parentRef.value, estimateSize: (i) => props.rows[i], overscan: 5, }) const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems()) const totalSize = computed(() => rowVirtualizer.value.getTotalSize()) </script> <template> <div ref="parentRef" class="List" style="height: 200px; overflow: auto"> <div :style="{ height: `${totalSize}px`, position: 'relative' }"> <div v-for="virtualRow in virtualRows" :key="virtualRow.index" :style="{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }" > Row {{ virtualRow.index }} </div> </div> </div> </template>要点拆解:
getScrollElement: () => parentRef.value返回滚动容器,配合适配器内部的watch实现滚动元素的响应式绑定;- 外层容器高度设为
totalSize,撑起整个滚动区域; - 每个虚拟项
position: absolute; top: 0加transform: translateY(start px)绝对定位到对应位置; - 需要动态测量时(variable 场景),给元素加
:ref="rowVirtualizer.value.measureElement"与data-index,虚拟器会用 ResizeObserver 实测尺寸并逐步逼近真实高度。
无限滚动:与 Vue Query 组合
examples/vue/infinite-scroll/src/App.vue 展示了无限滚动的完整模式:useInfiniteQuery分页拉数据 →allRows合并所有页 →useVirtualizer接收computed选项(count: hasNextPage ? allRows.length + 1 : allRows.length)→ 用一个额外的 loader 行占位。关键触发逻辑用watchEffect实现:当可见项中最后一项接近数据末尾且hasNextPage为真时调用fetchNextPage():
watchEffect(() => { const [lastItem] = [...virtualRows.value].reverse() if (!lastItem) return if ( lastItem.index >= allRows.value.length - 1 && hasNextPage.value && !isFetchingNextPage.value ) { fetchNextPage() } })这个示例还体现了两个适配器特性:选项用computed传入(useVirtualizer(rowVirtualizerOptions)),当分页数据增长时count变化,适配器会通过watch+setOptions把新选项同步给 core——这正是 3.13.13 修复所保证的「count 变化后高度自动更新」在真实场景中的应用。
版本对照与升级建议
结合本仓库两条 CHANGELOG 可以给出以下实用的版本追踪方法:
- 看 Vue 包版本号:
@tanstack/vue-virtual的 3.13.x 序列几乎全部对应@tanstack/virtual-core的 3.13.x ~ 3.17.x,只有 3.13.13 是适配器自身修复; - 追 core 的 minor:想要新能力(聊天锚定、缓存测量、多车道优化),看 core 的 3.14.0、3.15.0、3.16.0、3.17.0 各自引入什么,再映射到对应的 Vue 版本(3.13.24、3.13.25、3.13.27、3.13.28);
- 关注滚动正确性修复:3.17.x 系列密集修复了 iOS、平滑滚动、锚定补偿等边界问题,如果你的场景命中这些边界,优先升级到较新的 3.13.39;
- 包结构与构建信息:
@tanstack/vue-virtual以 ESM/CJS 双格式发布(见 package.json 的exports字段),peerDependencies支持 Vue^2.7.0 || ^3.0.0,sideEffects: false便于 tree-shaking。
升级时建议参照仓库的 workspace 结构:Vue 适配器与 core 在同一仓库内协同发版,pnpm-lock.yaml与pnpm-workspace.yaml锁定了版本关系,本地开发可通过packages/vue-virtual目录结合examples/vue下的固定、动态、无限滚动、padding、scroll-padding、smooth-scroll、sticky、table 等示例进行验证。文档侧,Vue 框架指南 提供组合式 API 的类型签名,Virtualizer API 文档 提供全部选项与实例方法详解,两者配合 CHANGELOG 阅读即可获得完整的使用与演进视图。
- 前端
- UI组件
【免费下载链接】virtual
🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte
相关推荐
TanStack Solid Query Devtools 演进与实战:版本变更、核心原理与配置详解
TanStack Solid Query Devtools 演进与实战:版本变更、核心原理与配置详解 @tanstack/solid query devtool
前端缓存状态管理Focalboard 版本演进全解析:从 v0.6 到 v0.15 的核心功能与实现原理
Focalboard 版本演进全解析:从 v0.6 到 v0.15 的核心功能与实现原理 Focalboard 是一个开源、可自托管的项目管理工具,定位为 Tr
后端前端企业应用桌面应用协同办公从 CHANGELOG 读懂 @eggjs/core:Egg 框架核心的版本演进与底层机制
从 CHANGELOG 读懂 @eggjs/core:Egg 框架核心的版本演进与底层机制 本文以 packages/core/CHANGELOG.md htt
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考