Vuetify 无限滚动组件v-infinite-scroll完全指南:自动/手动加载、双向滚动与虚拟化实战
【免费下载链接】vuetify🐉 Vue Component Framework项目地址: https://gitcode.com/gh_mirrors/vu/vuetify
v-infinite-scroll是 Vuetify 内置的无限滚动容器组件,用于在用户滚动接近内容末尾时按需加载更多条目,非常适合展示数量未知且庞大的数据列表,避免一次性渲染全部内容导致性能下降。本文将以 Vuetify 官方文档(infinite-scroller.md)为核心骨架,结合组件源码(VInfiniteScroll.tsx)与全部官方示例(packages/docs/src/examples/v-infinite-scroll/),系统讲解其核心用法、全部 Props/Slots、reset()暴露方法,以及虚拟化无限列表的进阶方案。读完本文你将能够独立实现自动加载、手动"加载更多"、顶部/底部/双向滚动等多种无限列表场景。
组件概览:一个可无限延伸的滚动容器
从文档定义来看,v-infinite-scroll是一个容器组件:当用户滚动接近容器边缘时,组件会触发加载逻辑,把新内容追加进列表。它同时支持垂直与水平两种滚动方向,且可以通过mode属性在"自动加载"与"手动加载"之间切换。
组件由以下部分组成(对应文档 Anatomy 章节):
| 元素 / 区域 | 说明 |
|---|---|
| 1. Container(容器) | 承载列表内容的滚动容器,对应默认插槽 |
| 2. Loader(加载器) | 容器顶部/底部的内容加载区域,根据状态渲染加载指示器、空状态或错误提示 |
组件渲染结构(见 VInfiniteScroll.tsx)在默认插槽前后各放置了一个v-infinite-scroll__side区域,用于渲染加载状态;在intersect模式下还会在两侧插入VInfiniteScrollIntersect哨兵元素,用于监听滚动是否到达边界。
快速开始:基础用法与load事件
滚动接近底部时,组件会自动加载更多条目;用户也可以切换为手动模式,通过点击按钮加载。以下是官方 Usage 示例的核心代码(完整文件见 usage.vue):
<template> <v-infinite-scroll height="300" @load="load" > <template v-for="(item, index) in items" :key="item"> <div :class="['pa-2', index % 2 === 0 ? 'bg-grey-lighten-2' : '']"> Item number {{ item }} </div> </template> </v-infinite-scroll> </template> <script setup> import { ref } from 'vue' const items = ref(Array.from({ length: 30 }, (k, v) => v + 1)) async function api () { return new Promise(resolve => { setTimeout(() => { resolve(Array.from({ length: 10 }, (k, v) => v + items.value.at(-1) + 1)) }, 1000) }) } async function load ({ done }) { // Perform API call const res = await api() items.value.push(...res) done('ok') } </script>load事件的参数对象
当组件需要加载更多内容时会触发load事件(事件声明见 VInfiniteScroll.tsx),回调参数是一个包含两个属性的对象:
side:告知新内容应添加到哪一侧,取值为'start'或'end'。示例中用它决定向数组头部unshift还是向尾部push。done:加载完成后的回调函数,接收单个参数status来描述加载结果。其取值如下表:
| Status | 说明 |
|---|---|
'ok' | 内容已成功添加 |
'error' | 添加内容时出错。此时会显示error插槽 |
'empty' | 没有更多内容可获取。此时会显示empty插槽 |
'loading' | 内容正在加载中。会显示一条加载中的提示。该状态仅由组件内部设置,不应通过done函数传入 |
需要特别注意:'loading'状态是组件内部在触发load事件前自行设置的(见 VInfiniteScroll.tsx),业务代码中只需在异步请求完成后调用done('ok' | 'error' | 'empty')即可。组件会依据done返回的状态分别渲染加载器、错误提示或空状态。
Props 详解:定制加载行为
文档明确列出了v-infinite-scroll用于定制行为的几个核心属性,其类型声明与默认值集中在 makeVInfiniteScrollProps 中,可归纳为下表:
| Prop | 类型 | 默认值 | 可选值 | 说明 |
|---|---|---|---|---|
mode | String | 'intersect' | 'intersect'/'manual' | 加载触发方式:自动(滚动接近末尾)或手动(点击按钮) |
direction | String | 'vertical' | 'vertical'/'horizontal' | 滚动方向 |
side | String | 'end' | 'start'/'end'/'both' | 新内容出现的位置 |
color | String | — | 任意颜色 | 默认"加载更多"按钮与加载中旋转指示器的颜色 |
margin | Number/String | — | 任意长度值 | 触发加载的边距,可理解为"提前多少距离开始加载" |
loadMoreText | String | '$vuetify.infiniteScroll.loadMore' | 任意字符串或 i18n key | 手动模式默认按钮文案 |
emptyText | String | '$vuetify.infiniteScroll.empty' | 任意字符串或 i18n key | 空状态默认文案 |
此外组件还继承了makeDimensionProps()(如height、width等尺寸属性)与makeTagProps()(自定义根元素标签,默认div),因此可以直接通过height="300"为滚动容器设定固定高度——无限滚动通常需要给容器一个确定的高度或宽度才能形成可滚动的溢出区域。
Mode:自动加载与手动加载
默认行为(mode="intersect")是滚动条接近末尾时自动尝试加载更多内容。文档同时支持手动模式(mode="manual"),此时需要用户主动交互(默认是一个按钮)才能触发加载。完整示例见 prop-mode.vue:
<template> <v-infinite-scroll height="300" mode="manual" @load="load" > <template v-for="(item, index) in items" :key="item"> <div :class="['px-2', index % 2 === 0 ? 'bg-grey-lighten-2' : '']"> Item number {{ item }} </div> </template> </v-infinite-scroll> </template> <script setup> import { ref } from 'vue' const items = ref(Array.from({ length: 50 }, (k, v) => v + 1)) function load ({ done }) { setTimeout(() => { items.value.push(...Array.from({ length: 10 }, (k, v) => v + items.value.at(-1) + 1)) done('ok') }, 1000) } </script>手动模式下,默认的"加载更多"按钮可以被load-more插槽完全替换(见下文 Slots 章节)。
Direction:垂直与水平滚动
v-infinite-scroll同时支持垂直与水平滚动。只需将direction设为'horizontal'即可(示例见 prop-direction.vue):
<v-infinite-scroll direction="horizontal" @load="load" > <!-- 内容列表,例如横向排列的卡片 --> </v-infinite-scroll>从源码实现看,方向不仅影响 CSS 类名(v-infinite-scroll--vertical/v-infinite-scroll--horizontal),还决定组件内部读写滚动量时使用哪一组属性:垂直方向操作scrollTop/scrollHeight/clientHeight,水平方向操作scrollLeft/scrollWidth/clientWidth(见 VInfiniteScroll.tsx)。
Side:控制新内容出现的位置
默认情况下组件假定新内容追加到已有内容的末尾(side="end"),但也支持将内容添加到开头(side="start"),以及开头与末尾同时加载(side="both")。
- 使用
start侧时,滚动条初始位于内容的底部(因为新内容总是插到最前面,用户向上滚动查看更早的内容)。 - 使用
both侧时,滚动条初始位于内容的中间。
side="start"的完整示例见 prop-side-start.vue:
<template> <v-infinite-scroll height="300" side="start" @load="load" > <template v-for="(item, index) in items" :key="item"> <div :class="['px-2', index % 2 === 0 ? 'bg-grey-lighten-2' : '']"> Item number {{ item }} </div> </template> </v-infinite-scroll> </template> <script setup> import { ref } from 'vue' const items = ref(Array.from({ length: 50 }, (k, v) => v + 1)) function load ({ done }) { setTimeout(() => { items.value.unshift(...Array.from({ length: 10 }, (k, v) => items.value[0] - (10 - v))) done('ok') }, 1000) } </script>注意向start侧添加内容时应使用unshift(或在both模式下根据side参数决定unshift还是push),并保证新数据的数值/排序方向正确。
side="both"的示例见 prop-side-both.vue,其load处理函数需要根据side参数分别处理:
function load ({ side, done }) { setTimeout(() => { if (side === 'start') { const arr = Array.from({ length: 10 }, (k, v) => items.value[0] - (10 - v)) items.value = [...arr, ...items.value] } else if (side === 'end') { const arr = Array.from({ length: 10 }, (k, v) => items.value.at(-1) + 1 + v) items.value = [...items.value, ...arr] } done('ok') }, 1000) }在源码中,组件挂载完成后会根据side自动调整初始滚动位置(见 VInfiniteScroll.tsx):start直接滚动到最底部;both则滚动到(scrollSize - containerSize) / 2,即内容中点。
Color:着色加载控件
默认的"加载更多"按钮与加载中的旋转指示器(VProgressCircular)都可以通过color属性着色(示例见 prop-color.vue):
<v-infinite-scroll color="secondary" height="400" mode="manual" @load="load" > <!-- 内容列表 --> </v-infinite-scroll>从渲染逻辑(renderSide)可以看到,color会同时传递给默认的VBtn(outlined 风格)与VProgressCircular指示器。
Slots 详解:完全掌控加载状态的表现
v-infinite-scroll通过一组插槽让你可以完全自定义各状态的展示。插槽的slotProps提供{ side, props },其中props内含onClick(触发加载的回调)与color,可直接通过v-bind="props"绑定到你的自定义按钮上。
| 插槽 | 展示时机 |
|---|---|
default | 容器内的列表内容 |
load-more | mode="manual"且状态不是loading时显示的加载控件 |
loading | mode="manual"且状态为loading时显示的加载中提示 |
empty | 状态为empty时显示的空状态提示 |
error | 状态为error时显示的错误提示 |
Loading 插槽
自定义加载中的提示文案(示例见 slot-loading.vue):
<v-infinite-scroll height="400" @load="load" > <!-- 内容列表 --> <template v-slot:loading> This is taking a very long time... </template> </v-infinite-scroll>示例中load函数延迟 4000ms 才调用done('ok'),便于观察自定义 loading 文案的展示效果。若未提供loading插槽,自动模式与手动模式默认都会渲染一个带color的VProgressCircular(indeterminate)旋转指示器。
Load-more 插槽
手动模式下自定义触发加载的操作控件(示例见 slot-load-more.vue):
<v-infinite-scroll height="400" mode="manual" @load="load" > <!-- 内容列表 --> <template v-slot:load-more="{ props }"> <v-btn icon="mdi-refresh" size="small" variant="text" v-bind="props" ></v-btn> </template> </v-infinite-scroll>这里的v-bind="props"会把组件内置的onClick与color绑定到你的自定义按钮上,点击后即触发intersecting(side)开始加载。
Empty 插槽
自定义"没有更多内容"的空状态提示(示例见 slot-empty.vue)。当load中调用done('empty')后显示:
<v-infinite-scroll height="400" @load="load" > <!-- 内容列表 --> <template v-slot:empty> <v-alert type="warning">No more items!</v-alert> </template> </v-infinite-scroll>若未提供empty插槽,则默认渲染由emptyText属性(或对应 i18n 文案)指定的文本。
Error 插槽
当done('error')被调用时显示错误插槽(示例见 slot-error.vue)。通常会在错误提示旁附带一个"重试"按钮,复用插槽提供的props:
<template v-slot:error="{ props }"> <v-alert type="error"> <div class="d-flex justify-space-between align-center"> Something went wrong... <v-btn color="white" size="small" variant="outlined" v-bind="props" > Retry </v-btn> </div> </v-alert> </template>点击"Retry"会再次触发load,实现错误后的重试机制。
组件暴露的方法:reset()
v-infinite-scroll通过组件实例暴露reset()方法(实现见 VInfiniteScroll.tsx),用于在到达empty状态后程序化地将状态重置回默认,从而让load可以被再次触发。方法签名与行为如下:
reset():不带参数时,按当前side属性重置(side="both"时两侧同时重置)。reset(side):可传入'start'、'end'或'both',只重置指定一侧。
典型场景:服务端数据新增了记录,希望重新拉取已到达末尾的列表。官方示例 misc-reset.vue 完整演示了这一能力:
<script setup> import { ref, useTemplateRef } from 'vue' const infiniteScrollRef = useTemplateRef('scroll') const items = ref([]) const showEmptyText = ref(false) let firstId = 0 let lastId = 0 const serverItems = ref(Array.from({ length: 30 }, () => ++lastId)) function prependFewMore () { serverItems.value = [...serverItems.value, ...Array.from({ length: 6 }, () => --firstId)] } function appendFewMore () { serverItems.value = [...serverItems.value, ...Array.from({ length: 6 }, () => ++lastId)] } function reset (side) { infiniteScrollRef.value?.reset(side) } async function load ({ side, done }) { await new Promise(resolve => setTimeout(resolve, 500)) let page = [] if (side === 'start') { page = loadPreviousPage() if (page.length) { items.value = [...page, ...items.value] } } if (side === 'end') { page = loadNextPage() if (page.length) { items.value = [...items.value, ...page] } } done(page.length === 10 ? 'ok' : 'empty') } function loadPreviousPage () { const cursor = items.value.at(0) ?? 0 return serverItems.value.filter(x => x < cursor).reverse().slice(0, 10) } function loadNextPage () { const cursor = items.value.at(-1) ?? 0 return serverItems.value.filter(x => x > cursor).slice(0, 10) } </script><template> <v-container> <div class="d-flex ga-3 mb-2"> <v-chip>Server items: {{ serverItems.length }}</v-chip> <v-chip>Loaded items: {{ items.length }}</v-chip> <v-spacer></v-spacer> <v-checkbox v-model="showEmptyText" hide-details="" label="show empty text"></v-checkbox> </div> <v-infinite-scroll ref="scroll" :empty-text="showEmptyText ? 'No more records' : ''" height="300" side="both" @load="load" > <template v-for="(item, index) in items" :key="index"> <div :class="['px-2', index % 2 === 0 ? 'bg-grey-lighten-2' : '']"> Item number {{ item }} </div> </template> </v-infinite-scroll> <div class="d-flex ga-3 mt-2"> <v-btn @click="prependFewMore(); reset('start')">prepend items + reset('start')</v-btn> <v-btn @click="appendFewMore(); reset('end')">append items + reset('end')</v-btn> <v-btn @click="reset()">just reset()</v-btn> </div> </v-container> </template>该示例还展示了emptyText的用法:通过:empty-text传入字符串即可覆盖默认空状态文案(也可以传空字符串关闭文案显示)。
进阶实战:虚拟化无限滚动
官方文档还提供了一个重要的进阶方案——虚拟化无限滚动(示例见 misc-virtual.vue)。核心思路是:当列表项尺寸均匀一致时,无论滚动多远,都只渲染固定数量的小部分条目,从而获得与列表总长度无关的稳定渲染开销。
<script setup> import { nextTick, ref } from 'vue' const infinite = ref() const size = ref(300) const virtualLength = ref(12) const cards = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) function createRange (length, start) { return Array.from({ length }).map((_, i) => i + start) } function load ({ side, done }) { const halfVirtualLength = virtualLength.value / 2 if (side === 'start') { const arr = createRange(halfVirtualLength, cards.value[0] - halfVirtualLength) cards.value = [...arr, ...cards.value.slice(0, halfVirtualLength)] nextTick(() => { infinite.value.$el.scrollTop = infinite.value.$el.scrollHeight - (halfVirtualLength * size.value) - infinite.value.$el.scrollTop }) } else { const arr = createRange(halfVirtualLength, cards.value.at(-1) + 1) cards.value = [...cards.value.slice(halfVirtualLength), ...arr] } done('ok') } </script><template> <v-infinite-scroll ref="infinite" height="500" side="both" @load="load" > <div> <template v-for="card in cards" :key="card"> <v-sheet :color="card % 2 === 0 ? 'primary' : card % 4 === 0 ? 'secondary' : 'warning'" :height="size" class="d-flex align-center justify-center" > <div>{{ card }}</div> </v-sheet> </template> </div> </v-infinite-scroll> </template>实现要点:
- 维护一个固定长度的窗口(
virtualLength = 12),始终只渲染这 12 个卡片。 - 向
end侧滚动时:丢弃窗口前一半(cards.value.slice(halfVirtualLength)),在尾部追加新生成的 6 个卡片。 - 向
start侧滚动时:在头部插入新生成的 6 个卡片,只保留原窗口前一半;随后用nextTick在 DOM 更新后手动修正scrollTop(scrollHeight - 半窗口高度 - 当前 scrollTop),保证视觉位置不跳动。
该方案的适用前提是条目尺寸一致(示例中所有v-sheet高度均为size),这样才能用高度直接计算滚动补偿。若条目高度不固定,则需要结合v-virtual-scroll等其他虚拟滚动方案。
源码原理:IntersectionObserver 与状态机
从源码层面理解组件内部机制(VInfiniteScroll.tsx),有助于正确使用和排查问题:
1. 哨兵元素 + IntersectionObserver 触发加载
intersect模式下,组件在内容两侧各渲染一个不可见的VInfiniteScrollIntersect哨兵元素(类名v-infinite-scroll-intersect,样式见 VInfiniteScroll.sass)。哨兵内部通过useIntersectionObserver(intersectionObserver.ts)监听自身是否进入视口:一旦进入,就向父组件发出intersect事件并触发加载(见 VInfiniteScroll.tsx)。margin属性会通过 CSS 变量--v-infinite-margin-size作用到哨兵上,从而实现"提前 N 距离开始加载"。
2. 双端独立状态机
组件为start与end两侧分别维护startStatus/endStatus两个状态(初始均为'ok'),side="both"时同步更新两侧(setStatus / getStatus)。触发加载前会先检查:mode === 'manual'时不自动触发;状态为empty或loading时不再重复触发(intersecting)。
3. 滚动位置补偿
向start侧插入新内容后,如果不修正滚动位置,用户会"看到"内容突然跳动。组件在done('ok')后的nextTick中计算getScrollSize() - previousScrollSize + getScrollAmount()并回写scrollTop/scrollLeft进行补偿(见 VInfiniteScroll.tsx)。reset()方法内部也使用了相同的补偿逻辑。
4. 三次requestAnimationFrame的细节
在非手动模式下完成一次加载后,组件会嵌套三次window.requestAnimationFrame再重新调用intersecting。源码注释(对应 issue #17475)说明:浏览器需要 2~3 个动画帧后 IntersectionObserver 才会在哨兵离开视口后再次触发回调,这是为了确保在内容高度不足一屏时能继续自动加载。
5. 本地化文案
loadMoreText与emptyText的默认值指向$vuetify.infiniteScroll下的 i18n key,英文默认文案定义在 en.ts:
infiniteScroll: { loadMore: 'Load more', empty: 'No more', },如果应用中配置了其他 locale(如中文zh-Hans),组件会自动显示对应语言的文案;也可以通过loadMoreText/emptyText属性直接覆盖。
组件自带单元测试(见 packages/vuetify/src/components/VInfiniteScroll/tests/),API 元数据与 props 文档生成定义位于 packages/api-generator/src/locale/en/VInfiniteScroll.json,可进一步查阅每个属性的完整描述。
小结
v-infinite-scroll以"滚动触发 → 状态机管理 → 插槽渲染"的简洁架构,覆盖了无限列表的全部常见形态:自动/手动加载、垂直/水平方向、单侧/双侧追加、空态/错误态展示,以及reset()程序化复位。配合本文介绍的虚拟化窗口技巧,即使数据量极大也能保持流畅。官方文档中列出的相关组件(Lists、Data tables、Data iterators)中同样大量运用了该组件,可作为真实场景的进一步参考。
动手建议:在 Vuetify 项目中直接复制本文的 Usage 示例即可得到可运行的最小无限列表;需要分页/游标加载时,参考misc-reset.vue的分页逻辑;需要超大列表时,再应用misc-virtual.vue的虚拟化窗口方案。
【免费下载链接】vuetify🐉 Vue Component Framework项目地址: https://gitcode.com/gh_mirrors/vu/vuetify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考