Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)

This commit is contained in:
Aqr-K
2026-05-15 21:15:30 +08:00
committed by GitHub
parent 0fda7c70de
commit 5953496d84
51 changed files with 2398 additions and 2130 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import { getLogoUrl } from '@/utils/imageUtils'
import axios from 'axios'
import api from '@/api'
import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
@@ -256,6 +257,8 @@ async function handleCheckSubscribe() {
)
isSubscribed.value = subscribed
} catch (error) {
// 路由切换会主动 abort 在途请求cancel 不是错误,静默忽略
if (axios.isCancel(error)) return
console.error(error)
}
}
@@ -280,6 +283,8 @@ async function handleCheckExists() {
isExists.value = exists
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
} catch (error) {
// 路由切换会主动 abort 在途请求cancel 不是错误,静默忽略
if (axios.isCancel(error)) return
console.error(error)
}
}
@@ -434,17 +439,36 @@ function setupIntersectionObserver() {
}
}
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
// 包装 URL根据全局开关走系统图片缓存或 douban 代理
function wrapPosterUrl(url: string): string {
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
}
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 卡片在网格中显示宽度 ~150-220pxw342 在 2x DPR 下足够清晰,
// 比 w500 减少约 53% 的解码位图内存
const url = props.media?.poster_path?.replace('original', 'w342')
if (!url) return noImage
return wrapPosterUrl(url)
})
// 计算低画质占位图LQIP— TMDB 自带 w92 缩略图 ~5KB
// 公网/慢源场景先秒铺 w92 模糊预览,再淡入 w342 清晰图。
// 当 poster_path 不包含 'original' 串(如 Douban时 replace 退化为 no-op
// 此时返回空串VImg 不要设 lazy-src避免重复请求同一 URL
// 占位回退到 #placeholder 槽里的 skeleton。
const getLqipUrl: Ref<string> = computed(() => {
const path = props.media?.poster_path
if (!path) return ''
const lqip = path.replace('original', 'w92')
if (lqip === path) return ''
return wrapPosterUrl(lqip)
})
// 移除订阅
@@ -466,6 +490,13 @@ onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// keep-alive 缓存场景下 onBeforeUnmount 不会触发,需要在 onDeactivated 主动 disconnect
// 防止冻结的卡片继续持有 observer 引用
onDeactivated(() => {
observer.value?.disconnect()
observer.value = null
})
</script>
<template>
@@ -483,15 +514,22 @@ onBeforeUnmount(() => {
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
>
<!--
lazy-src #placeholder 不能同时给 VImg
placeholder 槽里的 skeleton 会被 Vuetify 叠在 lazy-src 上层渲染
主图加载期间 skeleton LQIP 模糊预览盖成灰屏
lazy-src 就让它自己当占位 lazy-srcDouban才走 skeleton
-->
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
:lazy-src="getLqipUrl || undefined"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<template v-if="!getLqipUrl" #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
@@ -593,6 +631,17 @@ onBeforeUnmount(() => {
/>
</template>
<style scoped>
.media-card {
/*
告诉浏览器:当本卡片远离视口时跳过 paint 和图片解码。
被跳过期间浏览器可以丢弃解码后的 bitmap图片缓存大头
重新进入视口时按 contain-intrinsic-size 撑出位置后再 paint。
auto 关键字让首次 paint 后记忆实际尺寸,避免滚动条跳动。
*/
content-visibility: auto;
contain-intrinsic-size: auto 280px;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;

View File

@@ -26,7 +26,9 @@ function getPersonImage() {
let url = ''
if (personProps.person?.source === 'themoviedb') {
if (!personInfo.value?.profile_path) return personIcon
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
// 头像在 VAvatar size=100 内裁剪显示w185 已覆盖到 3x DPR
// 比原来的 w600_and_h900_bestv2 减少约 90% 的解码位图内存
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w185${personInfo.value?.profile_path}`
} else if (personProps.person?.source === 'douban') {
if (!personInfo.value?.avatar) return personIcon
if (typeof personInfo.value?.avatar === 'object') {

View File

@@ -62,11 +62,21 @@ const releaseDialog = ref(false)
const detailDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
if (!imageElement) return
// 主色调提取ColorThief 走 canvas 解码)会阻塞主线程,
// 滚动时挤掉浏览器自身的图片解码窗口,放到 idle 中执行更安全。
const extract = () => {
getDominantColor(imageElement)
.then(c => {
backgroundColor.value = c
})
.catch(() => {})
}
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
else setTimeout(extract, 50)
}
// 安装插件

View File

@@ -108,11 +108,20 @@ watch(
)
// 图片加载完成
async function imageLoaded() {
function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
if (!imageElement) return
// ColorThief 走 canvas 解码会阻塞主线程,放到 idle 中执行
const extract = () => {
getDominantColor(imageElement)
.then(c => {
backgroundColor.value = c
})
.catch(() => {})
}
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
else setTimeout(extract, 50)
}
// 显示更新日志

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import { tmdbResize } from '@/@core/utils/image'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
@@ -39,19 +40,19 @@ function imageLoadHandler() {
// 分享时间
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
// 计算backdrop图片地址
// 计算backdrop图片地址(卡片 backdrop 宽度 ~400-600pxw780 在 TMDB 上足够覆盖 2x DPR
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
const raw = props.media?.backdrop || props.media?.poster
const url = tmdbResize(raw, 'w780') || raw
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
// 计算海报图片地址(缩略 64×96 显示w185 已覆盖 3x DPR
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
const raw = props.media?.poster
const url = tmdbResize(raw, 'w185') || raw
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url

View File

@@ -4,7 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -471,15 +471,14 @@ onMounted(() => {
</div>
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
<ProgressiveCardGrid
<VirtualList
:items="mobileResourceList"
:columns="1"
:gap="12"
:estimated-item-height="320"
:overscan-rows="5"
:estimate-size="320"
:overscan="5"
:get-item-key="getResourceItemKey"
container-height="100%"
>
<template #default="{ item }">
<template #item="{ item }">
<VCard>
<VCardText class="pa-4">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
@@ -578,7 +577,7 @@ onMounted(() => {
</VCardText>
</VCard>
</template>
</ProgressiveCardGrid>
</VirtualList>
</div>
<div v-else class="px-4 py-10 text-center text-medium-emphasis">

View File

@@ -6,6 +6,7 @@ import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import VirtualList from '@/components/virtual/VirtualList.vue'
// 国际化
const { t } = useI18n()
@@ -24,9 +25,6 @@ const emit = defineEmits(['close', 'save'])
// 订阅历史列表
const historyList = ref<Subscribe[]>([])
// 当前加载数据
const currData = ref<Subscribe[]>([])
// 当前页
const currentPage = ref(1)
@@ -36,6 +34,9 @@ const pageSize = ref(30)
// 是否加载中
const loading = ref(false)
// 是否还有更多数据
const hasMore = ref(true)
// 是否加载完成
const isRefreshed = ref(false)
@@ -45,41 +46,31 @@ const progressDialog = ref(false)
// 进度文字
const progressText = ref('')
// 调用API查询列表
async function loadHistory({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// VirtualList ref泛型组件无法用 InstanceType 表达,用 any
const listRef = ref<any>(null)
// 调用API查询列表
// 调用API查询列表VirtualList @load-more 触发)
async function loadHistory() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
// 设置加载中
loading.value = true
currData.value = await api.get(`subscribe/history/${props.type}`, {
const data: Subscribe[] = await api.get(`subscribe/history/${props.type}`, {
params: {
page: currentPage.value,
count: pageSize.value,
},
})
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
if (!data || data.length === 0) {
hasMore.value = false
} else {
// 合并数据
historyList.value = [...historyList.value, ...currData.value]
// 页码+1
historyList.value.push(...data)
currentPage.value++
// 返回加载成功
done('ok')
// 如果服务端返回不足一页,认为没有更多
if (data.length < pageSize.value) hasMore.value = false
}
} catch (e) {
console.error(e)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
@@ -143,6 +134,11 @@ function getMediaTypeText(type: string | undefined) {
if (!type) return ''
return mediaTypeDict[type]
}
// 初始加载
onMounted(() => {
void loadHistory()
})
</script>
<template>
@@ -154,67 +150,71 @@ function getMediaTypeText(type: string | undefined) {
<VDivider />
<VDialogCloseBtn @click="emit('close')" />
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
<template #default="{ item, itemRef }">
<div :ref="itemRef">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<VirtualList
ref="listRef"
:items="historyList"
:estimate-size="104"
:overscan="6"
key-field="id"
container-height="60vh"
:load-more-threshold="5"
@load-more="loadHistory"
>
<template #item="{ item }">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VListItem>
</div>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>
<template #loading>
<LoadingBanner v-if="loading" />
</template>
</VirtualList>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
t('dialog.subscribeHistory.noData')

View File

@@ -14,6 +14,7 @@ import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import VirtualList from '@/components/virtual/VirtualList.vue'
// 国际化
const { t } = useI18n()
@@ -756,8 +757,13 @@ onUnmounted(() => {
class="text-high-emphasis file-list-container"
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
>
<VVirtualScroll :items="displayItems" style="block-size: 100%">
<template #default="{ item }">
<VirtualList
:items="displayItems"
:estimate-size="56"
:overscan="8"
:container-height="`${listAvailableHeight}px`"
>
<template #item="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
@@ -824,7 +830,7 @@ onUnmounted(() => {
</template>
</VHover>
</template>
</VVirtualScroll>
</VirtualList>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">

View File

@@ -6,6 +6,7 @@ import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import VirtualList from '@/components/virtual/VirtualList.vue'
// 国际化
const { t } = useI18n()
@@ -254,11 +255,16 @@ onMounted(async () => {
<template>
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
<template #default="{ item }">
<VirtualList
:items="visibleTreeRows"
:estimate-size="32"
key-field="key"
container-height="100%"
class="tree-container"
>
<template #item="{ item }">
<div
v-if="item.type === 'root'"
:key="item.key"
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="
@@ -278,7 +284,6 @@ onMounted(async () => {
<div
v-else-if="item.type === 'loading'"
:key="item.key"
class="tree-loading"
:style="getTreeRowStyle(item.level)"
>
@@ -290,7 +295,6 @@ onMounted(async () => {
<div
v-else
:key="item.key"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="getTreeRowStyle(item.level)"
@@ -322,7 +326,7 @@ onMounted(async () => {
</div>
</div>
</template>
</VVirtualScroll>
</VirtualList>
</VCard>
</template>

View File

@@ -1,932 +0,0 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
type ItemKey = string | number
type ScrollTarget = Window | HTMLElement
const props = withDefaults(
defineProps<{
items: any[]
minItemWidth?: number
itemAspectRatio?: number
estimatedItemHeight?: number
scrollToIndex?: number
gap?: number
columns?: number
initialCount?: number
batchSize?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
}>(),
{
minItemWidth: 144,
itemAspectRatio: 1.5,
estimatedItemHeight: undefined,
scrollToIndex: undefined,
gap: 16,
columns: undefined,
initialCount: 24,
batchSize: 24,
overscanRows: 4,
getItemKey: undefined,
},
)
interface VirtualCell {
item: any
index: number
key: ItemKey
}
interface VirtualRange {
endIndex: number
endRow: number
startIndex: number
startRow: number
}
const containerRef = ref<HTMLElement | null>(null)
const trackRef = ref<HTMLElement | null>(null)
const layoutWidth = ref(0)
const viewportTop = ref(0)
const viewportBottom = ref(0)
const heightVersion = ref(0)
const frozenVisibleRange = ref<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
const itemHeights = new Map<ItemKey, number>()
const observedElements = new Map<HTMLElement, ItemKey>()
const keyElements = new Map<ItemKey, HTMLElement>()
const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicInstance | null) => void>()
let resizeObserver: ResizeObserver | null = null
let itemResizeObserver: ResizeObserver | null = null
let overlayLockObserver: MutationObserver | null = null
let scrollTarget: ScrollTarget | null = null
let layoutFrameId: number | null = null
let scrollFrameId: number | null = null
let mounted = false
let pendingRevealIndex: number | null = null
let lastMeasuredColumnCount = 0
let lastMeasuredColumnWidth = 0
const safeGap = computed(() => Math.max(0, props.gap))
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
const columnCount = computed(() => {
if (props.columns && props.columns > 0) {
return Math.max(1, Math.floor(props.columns))
}
if (!layoutWidth.value) {
return 1
}
return Math.max(1, Math.floor((layoutWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
})
const columnWidth = computed(() => {
const columns = columnCount.value
const width = layoutWidth.value || safeMinItemWidth.value
return Math.max(1, (width - safeGap.value * (columns - 1)) / columns)
})
const estimatedHeight = computed(() => {
if (props.estimatedItemHeight && props.estimatedItemHeight > 0) {
return props.estimatedItemHeight
}
return Math.max(1, columnWidth.value * props.itemAspectRatio)
})
const itemKeys = computed(() => props.items.map((item, index) => getComparableKey(item, index)))
const keyIndexMap = computed(() => {
const map = new Map<ItemKey, number>()
itemKeys.value.forEach((key, index) => {
map.set(key, index)
})
return map
})
const rowMetrics = computed(() => {
heightVersion.value
const rows = Math.ceil(props.items.length / columnCount.value)
const heights: number[] = []
const measuredRows: boolean[] = []
const offsets: number[] = [0]
for (let row = 0; row < rows; row += 1) {
const startIndex = row * columnCount.value
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
let rowHeight = 0
let hasUnmeasuredItem = false
for (let index = startIndex; index < endIndex; index += 1) {
const height = itemHeights.get(itemKeys.value[index])
if (height && height > 0) {
rowHeight = Math.max(rowHeight, height)
} else {
hasUnmeasuredItem = true
}
}
if (hasUnmeasuredItem) {
rowHeight = Math.max(rowHeight, estimatedHeight.value)
} else {
rowHeight = Math.max(rowHeight, 1)
}
heights.push(rowHeight)
measuredRows.push(!hasUnmeasuredItem)
offsets.push(offsets[row] + rowHeight + (row < rows - 1 ? safeGap.value : 0))
}
return {
heights,
measuredRows,
offsets,
rowCount: rows,
totalHeight: offsets[rows] ?? 0,
}
})
const totalHeight = computed(() => rowMetrics.value.totalHeight)
const calculatedVisibleRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
return {
endIndex: props.items.length,
endRow: rowCount - 1,
startIndex: 0,
startRow: 0,
}
}
const { heights, offsets, rowCount } = rowMetrics.value
if (!props.items.length || rowCount === 0) {
return {
endIndex: 0,
endRow: 0,
startIndex: 0,
startRow: 0,
}
}
const top = Math.max(0, Math.min(viewportTop.value, totalHeight.value))
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1)
const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1)
return {
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
endRow,
startIndex: startRow * columnCount.value,
startRow,
}
})
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
const visibleCells = computed<VirtualCell[]>(() => {
const cells: VirtualCell[] = []
for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) {
cells.push({
item: props.items[index],
index,
key: itemKeys.value[index],
})
}
return cells
})
const topSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
})
const visibleBlockHeight = computed(() => {
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
return 0
}
return Math.max(
(rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) +
(rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) -
(rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0),
0,
)
})
const bottomSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
})
const gridStyle = computed(() => ({
columnGap: `${safeGap.value}px`,
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
rowGap: `${safeGap.value}px`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function getComparableKey(item: any, index: number): ItemKey {
if (props.getItemKey) {
return props.getItemKey(item, index)
}
return index
}
function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) {
let low = 0
let high = heights.length - 1
let answer = 0
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const rowEnd = offsets[mid] + heights[mid]
if (rowEnd >= offset) {
answer = mid
high = mid - 1
} else {
low = mid + 1
}
}
return answer
}
function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset: number) {
let low = 0
let high = rowCount - 1
let answer = 0
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (offsets[mid] <= offset) {
answer = mid
low = mid + 1
} else {
high = mid - 1
}
}
return answer
}
function isDocumentOverlayLocked() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('v-overlay-scroll-blocked')
}
function isGridInsideOverlay() {
return Boolean(containerRef.value?.closest('.v-overlay, .v-overlay__content'))
}
function syncOverlayGridState() {
isOverlayGrid.value = isGridInsideOverlay()
}
function shouldPauseVirtualSync() {
return isDocumentOverlayLocked() && !isOverlayGrid.value
}
function freezeVisibleRange() {
if (frozenVisibleRange.value) {
return
}
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
}
function releaseVisibleRange() {
frozenVisibleRange.value = null
}
function handleOverlayLockChange() {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
queueLayoutSync()
}
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
if (!element || typeof HTMLElement === 'undefined') {
return null
}
if (element instanceof HTMLElement) {
return element
}
if (!('$el' in element)) {
return null
}
const componentElement = element.$el
return componentElement instanceof HTMLElement ? componentElement : null
}
function getRowHeight(row: number) {
const startIndex = row * columnCount.value
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
let rowHeight = 0
let hasUnmeasuredItem = false
for (let index = startIndex; index < endIndex; index += 1) {
const height = itemHeights.get(itemKeys.value[index])
if (height && height > 0) {
rowHeight = Math.max(rowHeight, height)
} else {
hasUnmeasuredItem = true
}
}
if (hasUnmeasuredItem) {
return Math.max(rowHeight, estimatedHeight.value)
}
return Math.max(rowHeight, 1)
}
function ensureItemResizeObserver() {
if (itemResizeObserver || typeof ResizeObserver === 'undefined') {
return
}
itemResizeObserver = new ResizeObserver(entries => {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
let shouldUpdate = false
let scrollAdjustment = 0
const currentViewportTop = viewportTop.value
const currentOffsets = rowMetrics.value.offsets
entries.forEach(entry => {
const element = entry.target
if (!(element instanceof HTMLElement)) {
return
}
const key = observedElements.get(element)
const index = key === undefined ? undefined : keyIndexMap.value.get(key)
if (key === undefined || index === undefined) {
return
}
const nextHeight = getResizeEntryHeight(entry)
const previousHeight = itemHeights.get(key)
if (!nextHeight || Math.abs((previousHeight ?? 0) - nextHeight) < 0.5) {
return
}
const row = Math.floor(index / columnCount.value)
const rowWasFullyMeasured = rowMetrics.value.measuredRows[row]
const previousRowHeight = getRowHeight(row)
const previousRowBottom = (currentOffsets[row] ?? 0) + previousRowHeight
if (
rowWasFullyMeasured &&
previousHeight !== undefined &&
previousHeight < previousRowHeight - 0.5 &&
nextHeight <= previousRowHeight + 0.5
) {
return
}
itemHeights.set(key, nextHeight)
const nextRowHeight = getRowHeight(row)
const delta = nextRowHeight - previousRowHeight
if (Math.abs(delta) >= 0.5 && previousRowBottom < currentViewportTop) {
scrollAdjustment += delta
}
shouldUpdate = true
})
if (!shouldUpdate) {
return
}
heightVersion.value += 1
if (Math.abs(scrollAdjustment) >= 0.5) {
adjustScrollTop(scrollAdjustment)
}
queueViewportSync()
})
}
function getResizeEntryHeight(entry: ResizeObserverEntry) {
const borderSize = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize
return borderSize?.blockSize || entry.contentRect.height
}
function setItemRef(element: Element | ComponentPublicInstance | null, key: ItemKey) {
const htmlElement = getElementFromRef(element)
const previousElement = keyElements.get(key)
if (!htmlElement) {
if (previousElement) {
itemResizeObserver?.unobserve(previousElement)
observedElements.delete(previousElement)
keyElements.delete(key)
}
return
}
if (previousElement === htmlElement) {
return
}
ensureItemResizeObserver()
if (previousElement) {
itemResizeObserver?.unobserve(previousElement)
observedElements.delete(previousElement)
}
observedElements.set(htmlElement, key)
keyElements.set(key, htmlElement)
itemResizeObserver?.observe(htmlElement)
}
function getItemRef(key: ItemKey) {
const existingCallback = itemRefCallbacks.get(key)
if (existingCallback) {
return existingCallback
}
const callback = (element: Element | ComponentPublicInstance | null) => setItemRef(element, key)
itemRefCallbacks.set(key, callback)
return callback
}
function findScrollTarget(): ScrollTarget {
let parent = containerRef.value?.parentElement ?? null
while (parent && parent !== document.body && parent !== document.documentElement) {
const overflowY = window.getComputedStyle(parent).overflowY
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent
}
parent = parent.parentElement
}
return window
}
function addScrollListener(target: ScrollTarget) {
target.addEventListener('scroll', queueViewportSync, { passive: true })
}
function removeScrollListener(target: ScrollTarget | null) {
target?.removeEventListener('scroll', queueViewportSync)
}
function refreshScrollTarget() {
if (!mounted) {
return
}
const nextTarget = findScrollTarget()
if (scrollTarget === nextTarget) {
return
}
removeScrollListener(scrollTarget)
scrollTarget = nextTarget
addScrollListener(scrollTarget)
}
function syncLayoutWidth() {
const element = trackRef.value
if (!element) {
layoutWidth.value = 0
return
}
layoutWidth.value = element.clientWidth
}
function syncViewport() {
const element = trackRef.value
if (!element) {
viewportTop.value = 0
viewportBottom.value = 0
return
}
const trackRect = element.getBoundingClientRect()
const viewportRect =
scrollTarget && scrollTarget !== window
? (scrollTarget as HTMLElement).getBoundingClientRect()
: {
bottom: window.innerHeight,
top: 0,
}
viewportTop.value = viewportRect.top - trackRect.top
viewportBottom.value = viewportRect.bottom - trackRect.top
}
function queueLayoutSync() {
if (typeof window === 'undefined' || layoutFrameId !== null) {
return
}
layoutFrameId = window.requestAnimationFrame(() => {
layoutFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
syncOverlayGridState()
releaseVisibleRange()
syncLayoutWidth()
refreshScrollTarget()
syncViewport()
flushPendingReveal()
})
}
function queueViewportSync() {
if (typeof window === 'undefined' || scrollFrameId !== null) {
return
}
scrollFrameId = window.requestAnimationFrame(() => {
scrollFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
syncViewport()
})
}
function getTrackScrollTop() {
const element = trackRef.value
if (!element || !scrollTarget || scrollTarget === window) {
return (element?.getBoundingClientRect().top ?? 0) + window.scrollY
}
const scrollElement = scrollTarget as HTMLElement
const trackRect = element.getBoundingClientRect()
const scrollRect = scrollElement.getBoundingClientRect()
return trackRect.top - scrollRect.top + scrollElement.scrollTop
}
function adjustScrollTop(delta: number) {
if (!scrollTarget || Math.abs(delta) < 0.5) {
return
}
if (scrollTarget === window) {
window.scrollBy({
behavior: 'auto',
top: delta,
})
} else {
const scrollElement = scrollTarget as HTMLElement
scrollElement.scrollTop += delta
}
}
function scrollToRelativeTop(top: number) {
if (!scrollTarget) {
return
}
const targetTop = getTrackScrollTop() + top
if (scrollTarget === window) {
window.scrollTo({
behavior: 'auto',
top: targetTop,
})
} else {
;(scrollTarget as HTMLElement).scrollTo({
behavior: 'auto',
top: targetTop,
})
}
queueViewportSync()
}
async function revealItem(index: number) {
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
return
}
await nextTick()
const row = Math.floor(index / columnCount.value)
const top = rowMetrics.value.offsets[row] ?? 0
scrollToRelativeTop(top)
}
function requestRevealItem(index: number) {
pendingRevealIndex = index
if (!mounted) {
return
}
queueLayoutSync()
}
function flushPendingReveal() {
if (pendingRevealIndex === null || !mounted || !scrollTarget || layoutWidth.value <= 0) {
return
}
const index = pendingRevealIndex
pendingRevealIndex = null
void revealItem(index)
}
function pruneMeasurements() {
const keys = new Set(itemKeys.value)
let changed = false
Array.from(itemHeights.keys()).forEach(key => {
if (!keys.has(key)) {
itemHeights.delete(key)
changed = true
}
})
Array.from(keyElements.entries()).forEach(([key, element]) => {
if (!keys.has(key)) {
itemResizeObserver?.unobserve(element)
observedElements.delete(element)
keyElements.delete(key)
}
})
Array.from(itemRefCallbacks.keys()).forEach(key => {
if (!keys.has(key)) {
itemRefCallbacks.delete(key)
}
})
if (changed) {
heightVersion.value += 1
}
}
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
return false
}
return previousKeys.every((key, index) => key === nextKeys[index])
}
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
itemHeights.clear()
heightVersion.value += 1
}
pruneMeasurements()
}
function invalidateMeasurementsForLayoutChange() {
const nextColumnCount = columnCount.value
const nextColumnWidth = columnWidth.value
if (
lastMeasuredColumnCount === nextColumnCount &&
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
) {
return
}
lastMeasuredColumnCount = nextColumnCount
lastMeasuredColumnWidth = nextColumnWidth
if (!itemHeights.size) {
return
}
itemHeights.clear()
heightVersion.value += 1
}
onMounted(() => {
mounted = true
syncOverlayGridState()
scrollTarget = findScrollTarget()
addScrollListener(scrollTarget)
resizeObserver = new ResizeObserver(queueLayoutSync)
if (trackRef.value) {
resizeObserver.observe(trackRef.value)
}
if (typeof MutationObserver !== 'undefined') {
overlayLockObserver = new MutationObserver(handleOverlayLockChange)
overlayLockObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
window.addEventListener('resize', queueLayoutSync, { passive: true })
queueLayoutSync()
})
onActivated(() => {
mounted = true
refreshScrollTarget()
queueLayoutSync()
})
onDeactivated(() => {
mounted = false
removeScrollListener(scrollTarget)
scrollTarget = null
})
onUnmounted(() => {
mounted = false
removeScrollListener(scrollTarget)
scrollTarget = null
window.removeEventListener('resize', queueLayoutSync)
resizeObserver?.disconnect()
resizeObserver = null
itemResizeObserver?.disconnect()
itemResizeObserver = null
overlayLockObserver?.disconnect()
overlayLockObserver = null
if (layoutFrameId !== null) {
window.cancelAnimationFrame(layoutFrameId)
layoutFrameId = null
}
if (scrollFrameId !== null) {
window.cancelAnimationFrame(scrollFrameId)
scrollFrameId = null
}
})
watch(
itemKeys,
(nextKeys, previousKeys) => {
syncMeasurementsForItems(nextKeys, previousKeys)
queueLayoutSync()
},
{ immediate: true },
)
watch(
[
() => props.minItemWidth,
() => props.gap,
() => props.estimatedItemHeight,
() => props.itemAspectRatio,
() => props.columns,
],
() => {
queueLayoutSync()
},
)
watch(
[columnCount, columnWidth],
() => {
invalidateMeasurementsForLayoutChange()
queueViewportSync()
},
)
watch(
[() => props.scrollToIndex, () => props.items.length, columnCount],
([scrollToIndex]) => {
if (scrollToIndex === undefined || scrollToIndex < 0 || scrollToIndex >= props.items.length) {
return
}
requestRevealItem(scrollToIndex)
},
{ immediate: true },
)
</script>
<template>
<div ref="containerRef" class="progressive-card-grid">
<div ref="trackRef" class="progressive-card-grid__track">
<div
v-if="topSpacerHeight > 0"
class="progressive-card-grid__spacer"
:style="{ blockSize: `${topSpacerHeight}px` }"
aria-hidden="true"
/>
<div v-if="visibleCells.length > 0" class="progressive-card-grid__grid" :style="gridStyle">
<div
v-for="cell in visibleCells"
:key="cell.key"
:ref="getItemRef(cell.key)"
class="progressive-card-grid__item"
:data-progressive-grid-index="cell.index"
>
<slot :item="cell.item" :index="cell.index" />
</div>
</div>
<div
v-if="bottomSpacerHeight > 0"
class="progressive-card-grid__spacer"
:style="{ blockSize: `${bottomSpacerHeight}px` }"
aria-hidden="true"
/>
</div>
</div>
</template>
<style scoped>
.progressive-card-grid {
inline-size: 100%;
}
.progressive-card-grid__track {
inline-size: 100%;
min-block-size: 1px;
overflow-anchor: none;
}
.progressive-card-grid__grid {
display: grid;
}
.progressive-card-grid__item {
inline-size: 100%;
min-inline-size: 0;
}
.progressive-card-grid__item > :deep(*) {
block-size: 100%;
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,60 @@
<!--
============================================================
AutoSizer - 测量自身容器尺寸并通过 slot 传给子虚拟组件
============================================================
设计目标
- 容器宽高测量职责从虚拟化组件里拿出来react-virtualized 经典分层
- 虚拟化组件只接受显式 width/height/columns不再内部 ResizeObserver
- 容器自适应需求由本组件 + 业务计算 useResponsiveCols解决
原理
- `@vueuse/core` `useElementSize` 观察自身根 div
- 默认 100% 宽高填满父容器父容器必须给出有限尺寸弹性盒/网格/显式 height
- { width, height } 通过默认 slot 暴露给子内容
典型用法dashboard 卡片里的可变宽网格
<VCard>
<VCardItem>...</VCardItem>
<AutoSizer #default="{ width }">
<VirtualGrid :columns="Math.max(1, Math.floor(width / 240))"
:container-height="'10rem'" ... />
</AutoSizer>
</VCard>
注意
- 父容器必须有有限宽CSS 默认 100% 即可和高度dashboard 卡片有明确 block-size
- 首次渲染时 width/height 0 直到 useElementSize 第一帧回写
子组件应能容忍 cols=最小值useResponsiveCols 默认 min=1
-->
<script setup lang="ts">
import { ref } from 'vue'
import { useElementSize } from '@vueuse/core'
defineProps<{
/** 透传到根 div 的额外 class */
class?: string | string[] | Record<string, boolean>
}>()
const rootRef = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(rootRef)
defineExpose({
getRootElement: () => rootRef.value,
})
</script>
<template>
<div ref="rootRef" :class="['virtual-auto-sizer', $props.class]">
<slot :width="width" :height="height" :root-ref="rootRef" />
</div>
</template>
<style scoped>
.virtual-auto-sizer {
position: relative;
inline-size: 100%;
block-size: 100%;
}
</style>

View File

@@ -0,0 +1,199 @@
<!--
============================================================
VirtualGrid - @tanstack/vue-virtual 二维虚拟网格兼容层
============================================================
设计目标
- 内部把扁平 items[] 按外部传入的 `columns` 数值打包成 rows[][]
- 对业务屏蔽 row chunking 细节业务只写一项卡片
- 网格只在垂直方向虚拟化水平方向用 CSS Grid 等分
- 支持两种滚动模式容器内 scroll默认+ 页面 window scrolluseWindowScroll=true
- 不感知容器组件本身不读视口不测自身宽度
cols 由调用方算好喂进来useBreakpointCols / useResponsiveCols / 显式数值
容器自适应需求请用 <AutoSizer> useResponsiveCols
典型用法路由主页window scroll视口断点决定列数
<script setup>
const cols = useBreakpointCols({ xs: 2, sm: 3, md: 4, lg: 5, xl: 6 })
</script>
<template>
<VirtualGrid :items="dataList" :columns="cols"
:row-estimate-size="320" key-field="id"
use-window-scroll @load-more="fetchData">
<template #item="{ item }"> <MediaCard :media="item" /> </template>
</VirtualGrid>
</template>
典型用法dashboard 卡片容器宽度决定列数
<AutoSizer #default="{ width }">
<VirtualGrid :columns="Math.max(1, Math.floor(width / 240))"
:items="data" :container-height="'10rem'" ... />
</AutoSizer>
-->
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, ref } from 'vue'
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
import { useVirtualizerBridge } from '@/composables/virtual/useVirtualizerBridge'
const props = withDefaults(
defineProps<{
items: T[]
/** 列数(必填)。调用方用 useBreakpointCols / useResponsiveCols / 显式数值喂进来 */
columns: number
/** 行高估算px */
rowEstimateSize?: number
/** 卡片间距 + 容器内边距 */
gap?: number | string
/** 视口外预渲染的行数 */
overscan?: number
/** key 字段名。若同时给了 getItemKey 则后者优先 */
keyField?: keyof T
/**
* 取 key 的函数式入口,优先级高于 keyField。
* 用于 fallback 链场景,如 `(item) => item.id || item.link || item.title`。
* `index` 是 item 在扁平 items[] 中的 0 起始下标。
*/
getItemKey?: (item: T, index: number) => string | number
/** 容器内 scroll 模式下的容器高度useWindowScroll=false 时生效) */
containerHeight?: string | number
/** 使用页面 window scroll路由主页推荐 */
useWindowScroll?: boolean
}>(),
{
rowEstimateSize: 320,
gap: 12,
overscan: 3,
containerHeight: '100%',
useWindowScroll: false,
},
)
const emit = defineEmits<{ loadMore: []; scroll: [event: Event] }>()
// 列数兜底为 1避免外部传 0/负数时切片死循环
const cols = computed(() => Math.max(1, Math.floor(props.columns) || 1))
const rows = computed<T[][]>(() => {
const n = cols.value
const list = props.items
const out: T[][] = []
for (let i = 0; i < list.length; i += n) {
out.push(list.slice(i, i + n))
}
return out
})
const scrollEl = ref<HTMLElement | null>(null)
// Base LayerscrollMargin 追踪window resize + body ResizeObserver 自管理 + 卸载清理)
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
// Base Layertanstack 桥接useVirtualizer/useWindowVirtualizer 二选一 + measureRef null 转发)
// 网格只在垂直方向虚拟化「行」,不传 getItemKeykey 由 rowItemKey 打在内层卡片上)。
const { virtualizer, totalSize, virtualItems: virtualRows, measureRef } = useVirtualizerBridge({
count: () => rows.value.length,
estimateSize: () => props.rowEstimateSize,
overscan: () => props.overscan,
scrollMargin: () => scrollMargin.value,
getScrollElement: () => scrollEl.value,
useWindowScroll: props.useWindowScroll,
})
// Base Layer触底加载哨兵sentinel + IntersectionObserver + items.length 兜底)
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
itemsLength: () => props.items.length,
onFire: () => emit('loadMore'),
})
function rowItemKey(item: T, rowIdx: number, colIdx: number): string | number {
// 优先级getItemKey 函数 > keyField 字段 > 位置 fallback
if (props.getItemKey) {
const k = props.getItemKey(item, rowIdx * cols.value + colIdx)
if (typeof k === 'string' || typeof k === 'number') return k
}
if (props.keyField) {
const k = (item as Record<string, any>)[props.keyField as string]
if (typeof k === 'string' || typeof k === 'number') return k
}
return `${rowIdx}-${colIdx}`
}
const gapStr = computed(() => (typeof props.gap === 'number' ? `${props.gap}px` : props.gap))
defineExpose({
scrollToRow: (idx: number) => virtualizer.value.scrollToIndex(idx),
scrollToIndex: (idx: number) => virtualizer.value.scrollToIndex(idx),
scrollToOffset: (px: number) => virtualizer.value.scrollToOffset(px),
getScrollElement: () => scrollEl.value,
cols,
})
const containerStyle = computed(() => {
if (props.useWindowScroll) {
return {
position: 'relative' as const,
width: '100%' as const,
}
}
return {
height:
typeof props.containerHeight === 'number'
? `${props.containerHeight}px`
: props.containerHeight,
overflow: 'auto' as const,
overscrollBehavior: 'contain' as const,
willChange: 'transform' as const,
}
})
</script>
<template>
<div ref="scrollEl" :style="containerStyle" @scroll="emit('scroll', $event)">
<slot v-if="!items.length" name="empty" />
<div :style="{ height: `${totalSize}px`, position: 'relative', width: '100%' }">
<div
v-for="v in virtualRows"
:key="String(v.key)"
:data-index="v.index"
:ref="measureRef"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${v.start - scrollMargin}px)`,
contain: 'layout style',
}"
>
<div
:style="{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: gapStr,
paddingBottom: gapStr,
}"
>
<template
v-for="(item, i) in rows[v.index]"
:key="rowItemKey(item, v.index, i)"
>
<slot name="item" :item="item" :row-index="v.index" :col-index="i" />
</template>
</div>
</div>
</div>
<!-- 底部 sentinel进入视口触发 loadMore watch(virtualRows) 更可靠 -->
<div
v-if="items.length"
ref="loadMoreSentinel"
aria-hidden="true"
style="height: 1px; width: 100%"
/>
<slot name="loading" />
</div>
</template>

View File

@@ -0,0 +1,189 @@
<!--
============================================================
VirtualList - @tanstack/vue-virtual 一维虚拟列表兼容层
============================================================
设计目标
- headless useVirtualizer 封装成 slot SFC
- 业务侧只关心一项 item 长什么样
- 支持两种滚动模式容器内 scroll默认+ 页面 window scrolluseWindowScroll=true
- 内置触底加载@load-more
- 内置 Layout Jump 防护measureElement + scroll anchoring
- 内置 Scroll-back Blank 防护overscroll-behavior + 暴露 scrollTo* 命令式 API
典型用法容器内 scroll对话框/弹窗
<VirtualList :items="list" :estimate-size="104" key-field="id"
container-height="60vh" @load-more="loadMore">
<template #item="{ item }"> ... </template>
</VirtualList>
典型用法页面级 window scroll路由主页
<VirtualList :items="list" :estimate-size="120" key-field="id"
use-window-scroll @load-more="loadMore">
<template #item="{ item }"> ... </template>
</VirtualList>
-->
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, ref } from 'vue'
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
import { useVirtualizerBridge } from '@/composables/virtual/useVirtualizerBridge'
const props = withDefaults(
defineProps<{
items: T[]
/** 估算每项高度px。不定高时仍需给估算值库自动用 measureElement 校正 */
estimateSize?: number
/** 视口外预渲染项数。调高减少回滚白屏,调低减少内存 */
overscan?: number
/** key 字段名,强烈建议传。若同时给了 getItemKey 则后者优先 */
keyField?: keyof T
/**
* 取 key 的函数式入口,优先级高于 keyField。
* 用途:单字段 keyField 表达不了的场景,如 fallback 链
* `(m) => getMessageKey(m) || index`、组合 id 等。
*/
getItemKey?: (item: T, index: number) => string | number
/** 容器内 scroll 模式下的容器高度useWindowScroll=false 时生效) */
containerHeight?: string | number
/** 末尾还剩多少项时触发 load-more */
loadMoreThreshold?: number
/**
* 反向加载阈值:当首项 index 小于等于该值时触发 load-more-reverse。
* 用途:聊天/消息流场景,滚动到顶端加载更早的内容(如 MessageView
* 0 = 禁用反向加载(默认)。
*/
loadMoreReverseThreshold?: number
/**
* 使用页面 window scroll路由主页推荐
* - true: 不在内部生成滚动条,跟随页面滚动,自动用 scrollMargin 校正
* - false默认: 在内部生成固定高度滚动容器(适合对话框/抽屉)
*/
useWindowScroll?: boolean
}>(),
{
estimateSize: 100,
overscan: 5,
containerHeight: '100%',
loadMoreThreshold: 5,
loadMoreReverseThreshold: 0,
useWindowScroll: false,
},
)
const emit = defineEmits<{
loadMore: []
loadMoreReverse: []
scroll: [event: Event]
}>()
const scrollEl = ref<HTMLElement | null>(null)
// Base LayerscrollMargin 追踪window resize + body ResizeObserver 自管理 + 卸载清理)
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
// Base Layertanstack 桥接useVirtualizer/useWindowVirtualizer 二选一 + measureRef null 转发)
const { virtualizer, totalSize, virtualItems, measureRef } = useVirtualizerBridge({
count: () => props.items.length,
estimateSize: () => props.estimateSize,
overscan: () => props.overscan,
scrollMargin: () => scrollMargin.value,
getScrollElement: () => scrollEl.value,
useWindowScroll: props.useWindowScroll,
// 优先级getItemKey 函数 > keyField 字段 > index 兜底
getItemKey: (i: number) => {
const item = props.items[i]
if (props.getItemKey && item !== undefined) return props.getItemKey(item, i)
if (props.keyField && item !== undefined) return item[props.keyField] as string | number
return i
},
})
// Base Layer触底加载哨兵。容器内 scroll 模式需把 IntersectionObserver root
// 指向滚动容器window scroll 模式传 null = 视口)。
const sentinelRoot = computed(() => (props.useWindowScroll ? null : scrollEl.value))
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
itemsLength: () => props.items.length,
onFire: () => emit('loadMore'),
root: sentinelRoot,
})
// 反向加载(聊天/消息流往上加载更早内容):再调一次哨兵,按阈值启用。
const { sentinel: loadMoreReverseSentinel } = useLoadMoreSentinel({
itemsLength: () => props.items.length,
onFire: () => emit('loadMoreReverse'),
root: sentinelRoot,
enabled: () => props.loadMoreReverseThreshold > 0,
})
defineExpose({
scrollToIndex: (idx: number, opts?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
virtualizer.value.scrollToIndex(idx, opts),
scrollToOffset: (px: number) => virtualizer.value.scrollToOffset(px),
getScrollElement: () => scrollEl.value,
getVirtualizer: () => virtualizer.value,
})
const containerStyle = computed(() => {
if (props.useWindowScroll) {
return {
position: 'relative' as const,
width: '100%' as const,
}
}
return {
height:
typeof props.containerHeight === 'number'
? `${props.containerHeight}px`
: props.containerHeight,
overflow: 'auto' as const,
overscrollBehavior: 'contain' as const,
willChange: 'transform' as const,
}
})
</script>
<template>
<div ref="scrollEl" :style="containerStyle" @scroll="emit('scroll', $event)">
<slot v-if="!items.length" name="empty" />
<!-- 顶部 sentinel反向加载聊天往上加载-->
<div
v-if="items.length && loadMoreReverseThreshold > 0"
ref="loadMoreReverseSentinel"
aria-hidden="true"
style="height: 1px; width: 100%"
/>
<div :style="{ height: `${totalSize}px`, position: 'relative', width: '100%' }">
<div
v-for="v in virtualItems"
:key="String(v.key)"
:data-index="v.index"
:ref="measureRef"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${v.start - scrollMargin}px)`,
contain: 'layout style',
}"
>
<slot name="item" :item="items[v.index]" :index="v.index" :virtual="v" />
</div>
</div>
<!-- 底部 sentinel触底加载 -->
<div
v-if="items.length"
ref="loadMoreSentinel"
aria-hidden="true"
style="height: 1px; width: 100%"
/>
<slot name="loading" />
</div>
</template>

View File

@@ -0,0 +1,227 @@
<!--
============================================================
VirtualMasonry - 不等高瀑布流 + 窗口虚拟化
============================================================
VirtualGrid 的区别
- VirtualGrid 假定每张卡等高适合海报这种 2:3 等比卡
- VirtualMasonry 支持每项独立高度Pinterest 混排
布局算法
- items[] 按到 N 每来一项放进当前最矮的那一列
- 总高度 = max(列高)可视范围按 scrollY+overscan 过滤位置
虚拟化策略
- 不依赖 @tanstack/vue-virtualmasonry 没有"行"的概念
- 监听 window.scroll 计算可视 Y 区间只渲染 top..top+vh+overscan 范围的项
- requestAnimationFrame 节流滚动帧率不丢
高度来源优先级从高到低
1. 用户传入 getItemHeight(item) 回调如根据 aspect ratio 计算
2. estimateItemHeight 兜底
v1 不做 mount 后实测回填masonry 实测会引发已布局项位移
列数由调用方决定 VirtualGrid 一致组件本身不感知容器
useBreakpointCols / useResponsiveCols / 显式数值喂给 :columns
典型用法
<script setup>
const cols = useBreakpointCols({ xs: 2, sm: 3, md: 4, lg: 5 })
</script>
<template>
<VirtualMasonry
:items="people"
:columns="cols"
:estimate-item-height="280"
:get-item-height="p => p.height ?? 280"
key-field="id"
@load-more="fetchMore">
<template #item="{ item }"> <PersonCard :person="item" /> </template>
</VirtualMasonry>
</template>
-->
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
const props = withDefaults(
defineProps<{
items: T[]
/** 列数(必填)。调用方用 useBreakpointCols / useResponsiveCols / 显式数值喂进来 */
columns: number
/** 估算项高度px未提供 getItemHeight 时用这个 */
estimateItemHeight?: number
/** 从 item 计算实际高度(如已知图片宽高比) */
getItemHeight?: (item: T) => number | undefined
/** key 字段名(去重 + 复用 DOM */
keyField?: keyof T
/** 列间距px */
gap?: number
/** 视口外预渲染像素(上下各 overscan px*/
overscan?: number
}>(),
{
estimateItemHeight: 300,
gap: 12,
overscan: 600,
},
)
const emit = defineEmits<{ loadMore: [] }>()
// 列数兜底为 1避免外部传 0/负数时除零
const cols = computed(() => Math.max(1, Math.floor(props.columns) || 1))
interface LayoutItem {
item: T
key: string | number
top: number
leftPct: number
widthPct: number
height: number
col: number
index: number
}
const layout = computed<{ positions: LayoutItem[]; totalHeight: number }>(() => {
const n = cols.value
const colHeights = new Array<number>(n).fill(0)
const positions: LayoutItem[] = []
const widthPct = 100 / n
const gap = props.gap
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i]
// 找最矮列
let c = 0
let minH = colHeights[0]
for (let j = 1; j < n; j++) {
if (colHeights[j] < minH) {
minH = colHeights[j]
c = j
}
}
const top = colHeights[c]
const h = props.getItemHeight?.(item) ?? props.estimateItemHeight
const key = props.keyField
? ((item as Record<string, any>)[props.keyField as string] ?? i)
: i
positions.push({
item,
key,
top,
leftPct: c * widthPct,
widthPct,
height: h,
col: c,
index: i,
})
colHeights[c] = top + h + gap
}
const totalHeight = colHeights.reduce((m, v) => (v > m ? v : m), 0)
return { positions, totalHeight }
})
const scrollEl = ref<HTMLElement | null>(null)
const scrollY = ref(0)
const viewportH = ref(typeof window !== 'undefined' ? window.innerHeight : 800)
let rafId: number | null = null
// Base LayerscrollMargin 追踪。Masonry 永远是 window scrollenabled 恒为 true。
// window resize + body ResizeObserver 由 composable 自管理 + 卸载清理)
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => true)
function onScroll() {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
scrollY.value = window.scrollY
rafId = null
})
}
function onResize() {
viewportH.value = window.innerHeight
}
onMounted(() => {
if (typeof window === 'undefined') return
scrollY.value = window.scrollY
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onResize, { passive: true })
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
}
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
})
// 可视范围内的项(含 overscan
// 本组件 Y 坐标系从 0容器顶开始窗口里看到的容器顶 Y =
// scrollY - scrollMarginscrollMargin 是容器在文档里的偏移)。
// 所以可视区间 [vTop, vBottom] 是容器内坐标。
const visibleItems = computed(() => {
const vTop = scrollY.value - scrollMargin.value - props.overscan
const vBottom = scrollY.value - scrollMargin.value + viewportH.value + props.overscan
return layout.value.positions.filter(p => p.top + p.height >= vTop && p.top <= vBottom)
})
// Base Layer触底加载哨兵sentinel + IntersectionObserver + items.length 兜底)
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
itemsLength: () => props.items.length,
onFire: () => emit('loadMore'),
})
defineExpose({
getScrollElement: () => scrollEl.value,
getLayout: () => layout.value,
cols,
})
const gapStr = computed(() => `${props.gap}px`)
</script>
<template>
<div ref="scrollEl" style="position: relative; width: 100%">
<slot v-if="!items.length" name="empty" />
<!-- 撑出总高度,让滚动条反映真实长度 -->
<div :style="{ position: 'relative', height: `${layout.totalHeight}px`, width: '100%' }">
<div
v-for="p in visibleItems"
:key="String(p.key)"
:data-index="p.index"
:style="{
position: 'absolute',
top: `${p.top}px`,
left: `${p.leftPct}%`,
width: `${p.widthPct}%`,
height: `${p.height}px`,
paddingRight: p.col < cols - 1 ? gapStr : '0',
paddingBottom: gapStr,
boxSizing: 'border-box',
contain: 'layout style',
}"
>
<slot name="item" :item="p.item" :index="p.index" />
</div>
</div>
<div
v-if="items.length"
ref="loadMoreSentinel"
aria-hidden="true"
style="height: 1px; width: 100%"
/>
<slot name="loading" />
</div>
</template>

View File

@@ -0,0 +1,149 @@
<!--
============================================================
VirtualTree - 虚拟滚动树
============================================================
设计目标
- 不引入新的虚拟化引擎VirtualTree = useTreeFlatten + VirtualList
- useTreeFlatten 按展开状态把树深度优先扁平成一维数组
- VirtualList 负责虚拟滚动 / 触底加载 / measureElement 防泄漏
- 业务侧只写一个节点长什么样缩进/展开按钮由业务在 #node slot 里画
展开状态双模式
- 非受控默认组件内部维护可用 defaultExpandedKeys 设初值
- 受控传入 expandedKeys 即进入受控模式组件只 emit update:expandedKeys
典型用法文件树容器内 scroll
<VirtualTree :nodes="roots" :get-node-id="n => n.path"
:estimate-size="32" container-height="60vh">
<template #node="{ node, depth, expanded, hasChildren, toggle }">
<div :style="{ paddingInlineStart: `${depth * 16}px` }" @click="hasChildren && toggle()">
<VIcon v-if="hasChildren">{{ expanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</VIcon>
{{ node.name }}
</div>
</template>
</VirtualTree>
-->
<script setup lang="ts" generic="T">
import { computed, ref } from 'vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import { useTreeFlatten } from '@/composables/virtual/useTreeFlatten'
const props = withDefaults(
defineProps<{
/** 根节点数组 */
nodes: T[]
/** 取节点唯一 id必填用作虚拟列表 key + 展开状态键) */
getNodeId: (node: T) => string | number
/** 取子节点数组,默认读 node.children */
getChildren?: (node: T) => T[] | undefined
/** 估算行高px */
estimateSize?: number
/** 视口外预渲染项数 */
overscan?: number
/** 使用页面 window scroll */
useWindowScroll?: boolean
/** 容器内 scroll 模式下的容器高度 */
containerHeight?: string | number
/** 受控模式:传入即进入受控,组件只 emit update:expandedKeys */
expandedKeys?: (string | number)[]
/** 非受控模式的初始展开 id 列表 */
defaultExpandedKeys?: (string | number)[]
}>(),
{
getChildren: (node: any) => node?.children,
estimateSize: 32,
overscan: 8,
useWindowScroll: false,
containerHeight: '100%',
expandedKeys: undefined,
defaultExpandedKeys: () => [],
},
)
const emit = defineEmits<{
loadMore: []
'update:expandedKeys': [keys: (string | number)[]]
toggle: [id: string | number, expanded: boolean]
}>()
// 展开状态受控props.expandedKeys 已传)走 emit非受控走内部 ref。
const internalExpanded = ref<Set<string | number>>(new Set(props.defaultExpandedKeys))
const isControlled = computed(() => props.expandedKeys !== undefined)
const expandedSet = computed<Set<string | number>>(() =>
isControlled.value ? new Set(props.expandedKeys) : internalExpanded.value,
)
function isExpanded(id: string | number) {
return expandedSet.value.has(id)
}
function setExpanded(id: string | number, expanded: boolean) {
const next = new Set(expandedSet.value)
if (expanded) next.add(id)
else next.delete(id)
if (isControlled.value) emit('update:expandedKeys', [...next])
else internalExpanded.value = next
emit('toggle', id, expanded)
}
function toggle(id: string | number) {
setExpanded(id, !expandedSet.value.has(id))
}
// Base Layer按展开状态扁平化纯 computed结果喂给 VirtualList
const flatNodes = useTreeFlatten<T>({
nodes: () => props.nodes,
getId: props.getNodeId,
getChildren: node => props.getChildren(node),
isExpanded,
})
const listRef = ref<any>(null)
defineExpose({
toggle,
expand: (id: string | number) => setExpanded(id, true),
collapse: (id: string | number) => setExpanded(id, false),
isExpanded,
getFlatNodes: () => flatNodes.value,
scrollToIndex: (idx: number, opts?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
listRef.value?.scrollToIndex(idx, opts),
getScrollElement: () => listRef.value?.getScrollElement?.() ?? null,
})
</script>
<template>
<VirtualList
ref="listRef"
:items="flatNodes"
key-field="id"
:estimate-size="estimateSize"
:overscan="overscan"
:use-window-scroll="useWindowScroll"
:container-height="containerHeight"
@load-more="emit('loadMore')"
>
<template #item="{ item }">
<slot
name="node"
:node="item.node"
:id="item.id"
:depth="item.depth"
:expanded="item.expanded"
:has-children="item.hasChildren"
:parent-id="item.parentId"
:toggle="() => toggle(item.id)"
/>
</template>
<template #empty>
<slot name="empty" />
</template>
<template #loading>
<slot name="loading" />
</template>
</VirtualList>
</template>