mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-05 21:53:27 +08:00
Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)
This commit is contained in:
@@ -68,61 +68,77 @@ export function useDynamicHeaderTab() {
|
||||
},
|
||||
}
|
||||
|
||||
// 收集所有 watch 句柄,组件 scope 销毁时主动 stop——
|
||||
// 即使在 keep-alive 缓存的页面里,scope 仍归属当前组件,显式 stop
|
||||
// 可保证 ReactiveEffect/Dep 链条释放,避免长跑场景累积。
|
||||
const watchStops: Array<() => void> = []
|
||||
|
||||
// 如果启用了PWA状态恢复,监听PWA状态变化并同步到modelValue
|
||||
// 但只在非激活状态下响应,避免干扰页面激活时的状态
|
||||
if (pwaTabState) {
|
||||
watch(pwaTabState.activeTab, newTab => {
|
||||
if (newTab && newTab !== config.modelValue.value) {
|
||||
config.modelValue.value = newTab
|
||||
// 更新tabConfig并重新注册
|
||||
tabConfig.modelValue = newTab
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
watchStops.push(
|
||||
watch(pwaTabState.activeTab, newTab => {
|
||||
if (newTab && newTab !== config.modelValue.value) {
|
||||
config.modelValue.value = newTab
|
||||
// 更新tabConfig并重新注册
|
||||
tabConfig.modelValue = newTab
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 监听modelValue变化并更新配置
|
||||
watch(config.modelValue, newValue => {
|
||||
tabConfig.modelValue = newValue
|
||||
// 同步到PWA状态
|
||||
if (pwaTabState && newValue) {
|
||||
pwaTabState.activeTab.value = newValue
|
||||
}
|
||||
// 重新注册以更新值
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
watchStops.push(
|
||||
watch(config.modelValue, newValue => {
|
||||
tabConfig.modelValue = newValue
|
||||
// 同步到PWA状态
|
||||
if (pwaTabState && newValue) {
|
||||
pwaTabState.activeTab.value = newValue
|
||||
}
|
||||
}
|
||||
})
|
||||
// 重新注册以更新值
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// 如果items是computed或ref,也需要监听其变化
|
||||
if (!Array.isArray(config.items)) {
|
||||
watch(
|
||||
config.items,
|
||||
newItems => {
|
||||
tabConfig.items = newItems
|
||||
// 重新注册以更新items
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
watchStops.push(
|
||||
watch(
|
||||
config.items,
|
||||
newItems => {
|
||||
tabConfig.items = newItems
|
||||
// 重新注册以更新items
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
},
|
||||
{ deep: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
watchStops.forEach(stop => stop())
|
||||
watchStops.length = 0
|
||||
})
|
||||
|
||||
// 注册函数
|
||||
const doRegister = () => {
|
||||
// 确保路由路径是最新的
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
/**
|
||||
* 无限滚动 composable
|
||||
* 用于管理分页显示和无限滚动加载
|
||||
* @param sourceData - 源数据(响应式引用)
|
||||
* @param pageSize - 每页显示数量,默认20
|
||||
*/
|
||||
export function useInfiniteScroll<T>(
|
||||
sourceData: Ref<T[]>,
|
||||
pageSize: number = 20
|
||||
) {
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<T[]>([])
|
||||
|
||||
// 剩余数据列表(用于无限滚动)
|
||||
const remainingDataList = ref<T[]>([]) as Ref<T[]>
|
||||
|
||||
// 初始化数据
|
||||
function initData() {
|
||||
if (sourceData.value?.length) {
|
||||
// 显示前 pageSize 个
|
||||
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
|
||||
// 保存剩余数据
|
||||
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
|
||||
} else {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
// 从 remainingDataList 中获取最前面的 pageSize 个元素
|
||||
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
|
||||
;(displayDataList.value as T[]).push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 重置数据
|
||||
function reset() {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
|
||||
// 监听源数据变化,重新初始化
|
||||
watch(sourceData, () => {
|
||||
initData()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
return {
|
||||
displayDataList,
|
||||
remainingDataList,
|
||||
initData,
|
||||
loadMore,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
110
src/composables/useScrollRestore.ts
Normal file
110
src/composables/useScrollRestore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* useScrollRestore - 长列表滚动位置 + 数据持久化恢复
|
||||
* ============================================================
|
||||
*
|
||||
* 用途:解决「Scroll-back Blank / 回滚白屏」问题
|
||||
*
|
||||
* 工作机制:
|
||||
* 1. 在 onBeforeRouteLeave / onDeactivated 时保存 scrollTop + items + meta
|
||||
* 2. 在 onMounted 时检查 sessionStorage,若有缓存则恢复 items 与滚动位置
|
||||
* 3. 数据未缓存时调用业务 loader 拉初始页
|
||||
*
|
||||
* 配套要求:
|
||||
* - 业务组件持有 items ref<any[]>
|
||||
* - 业务组件持有 VirtualList / VirtualGrid 的 ref(用于调 scrollToOffset)
|
||||
* - keep-alive 命中时本 composable 不重复触发恢复(onActivated 不接管)
|
||||
*
|
||||
* 典型用法:
|
||||
* const listRef = ref<InstanceType<typeof VirtualList> | null>(null)
|
||||
* const items = ref<Item[]>([])
|
||||
* const pageNum = ref(1)
|
||||
*
|
||||
* useScrollRestore({
|
||||
* listRef,
|
||||
* items,
|
||||
* getMeta: () => ({ pageNum: pageNum.value }),
|
||||
* applyMeta: (meta) => { pageNum.value = meta.pageNum ?? 1 },
|
||||
* loader: () => fetchInitial(),
|
||||
* })
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||
import { useScrollPositionStore } from '@/stores/scrollPosition'
|
||||
|
||||
interface ListRefLike {
|
||||
getScrollElement: () => HTMLElement | null
|
||||
scrollToOffset: (px: number) => void
|
||||
}
|
||||
|
||||
interface ScrollRestoreOptions<T> {
|
||||
/** VirtualList / VirtualGrid 的 ref(暴露了 getScrollElement / scrollToOffset) */
|
||||
listRef: Ref<ListRefLike | null>
|
||||
/** 业务侧持有的数据数组 ref */
|
||||
items: Ref<T[]>
|
||||
/** 自定义缓存 key(默认用当前路由 fullPath) */
|
||||
cacheKey?: () => string
|
||||
/** 抽取需要持久化的业务元数据(如 pageNum、查询参数) */
|
||||
getMeta?: () => Record<string, any>
|
||||
/** 恢复时回写业务元数据 */
|
||||
applyMeta?: (meta: Record<string, any>) => void
|
||||
/** 无缓存时的初始加载函数 */
|
||||
loader?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function useScrollRestore<T>(opts: ScrollRestoreOptions<T>) {
|
||||
const route = useRoute()
|
||||
const store = useScrollPositionStore()
|
||||
|
||||
const resolveKey = () => opts.cacheKey?.() ?? `scroll:${route.fullPath}`
|
||||
|
||||
// 单次锁:onBeforeRouteLeave + onBeforeUnmount 双钩子防漏,去重避免写两次 store
|
||||
let savedThisCycle = false
|
||||
|
||||
function save() {
|
||||
if (savedThisCycle) return
|
||||
const el = opts.listRef.value?.getScrollElement()
|
||||
if (!el) return
|
||||
store.save<T>(resolveKey(), {
|
||||
scrollTop: el.scrollTop,
|
||||
items: opts.items.value,
|
||||
meta: opts.getMeta?.(),
|
||||
})
|
||||
savedThisCycle = true
|
||||
}
|
||||
|
||||
async function restoreOrLoad() {
|
||||
savedThisCycle = false // 新一轮挂载,允许下次再保存
|
||||
const snap = store.restore<T>(resolveKey())
|
||||
if (snap && snap.items.length > 0) {
|
||||
// 恢复数据 + 元数据 + 滚动位置
|
||||
opts.items.value = snap.items as T[]
|
||||
if (snap.meta) opts.applyMeta?.(snap.meta)
|
||||
await nextTick()
|
||||
// 双 rAF 等 virtualizer 把 totalSize 算稳定(首帧可能为 0)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
opts.listRef.value?.scrollToOffset(snap.scrollTop)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await opts.loader?.()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void restoreOrLoad()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
save()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 兜底:被 keep-alive 踢出 / 组件销毁时也要保存(savedThisCycle 防双写)
|
||||
save()
|
||||
})
|
||||
|
||||
return { save, restoreOrLoad }
|
||||
}
|
||||
44
src/composables/virtual/useBreakpointCols.ts
Normal file
44
src/composables/virtual/useBreakpointCols.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useBreakpointCols - 视口断点驱动的列数
|
||||
* ============================================================
|
||||
*
|
||||
* 把 Vuetify 的视口断点(`useDisplay()`)映射成一个 cols 数值,
|
||||
* 喂给 VirtualGrid / VirtualMasonry 的 `:columns` prop。
|
||||
*
|
||||
* 适用:全宽路由级网格 —— 列数由窗口宽度决定。
|
||||
*
|
||||
* 容器自适应(嵌在窄/可变宽容器里)请改用 `useResponsiveCols`
|
||||
* 或 `<AutoSizer>` 包一层。
|
||||
*
|
||||
* 典型用法:
|
||||
* <script setup>
|
||||
* const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
|
||||
* </script>
|
||||
* <template>
|
||||
* <VirtualGrid :columns="cols" ...> ... </VirtualGrid>
|
||||
* </template>
|
||||
*/
|
||||
export interface Breakpoints {
|
||||
xs?: number
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
xxl?: number
|
||||
}
|
||||
|
||||
export function useBreakpointCols(breakpoints: Breakpoints): ComputedRef<number> {
|
||||
const display = useDisplay()
|
||||
return computed(() => {
|
||||
if (display.xs.value) return breakpoints.xs ?? 2
|
||||
if (display.sm.value) return breakpoints.sm ?? 3
|
||||
if (display.md.value) return breakpoints.md ?? 4
|
||||
if (display.lg.value) return breakpoints.lg ?? 5
|
||||
if (display.xl.value) return breakpoints.xl ?? 6
|
||||
return breakpoints.xxl ?? 6
|
||||
})
|
||||
}
|
||||
64
src/composables/virtual/useLoadMoreSentinel.ts
Normal file
64
src/composables/virtual/useLoadMoreSentinel.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ref, watch, nextTick, type MaybeRefOrGetter } from 'vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useLoadMoreSentinel - 虚拟滚动 Base Layer:触底/触顶加载哨兵
|
||||
* ============================================================
|
||||
*
|
||||
* 规则:
|
||||
* - sentinel 进入视口 + items 自上次 fire 起已变化 → fire
|
||||
* - sentinel 离开视口 → 解锁
|
||||
* - sentinel 持续 intersecting(短列表/大列宽)时,IntersectionObserver
|
||||
* 不会再回调,必须靠 items.length watcher 兜底重新评估 tryFire,
|
||||
* 否则只能 fire 一次后死锁、首屏填不满。
|
||||
*
|
||||
* 业务侧的 onFire 仍需自己持有 loading 锁防并发。
|
||||
*
|
||||
* VirtualList 的反向加载(聊天往上加载)= 再调一次本 composable,
|
||||
* 传 enabled 即可。
|
||||
*
|
||||
* @param itemsLength 返回当前 items 长度的 getter
|
||||
* @param onFire 触发加载的回调
|
||||
* @param root 容器内 scroll 模式的 IntersectionObserver root;window scroll 传 undefined
|
||||
* @param enabled 是否启用(反向加载未开启时返回 false)
|
||||
*/
|
||||
export function useLoadMoreSentinel(opts: {
|
||||
itemsLength: () => number
|
||||
onFire: () => void
|
||||
root?: MaybeRefOrGetter<HTMLElement | null>
|
||||
enabled?: () => boolean
|
||||
}) {
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let isIntersecting = false
|
||||
let lastFireLen = -1
|
||||
|
||||
function tryFire() {
|
||||
if (opts.enabled && !opts.enabled()) return
|
||||
if (!isIntersecting) return
|
||||
if (lastFireLen >= 0 && opts.itemsLength() === lastFireLen) return
|
||||
lastFireLen = opts.itemsLength()
|
||||
opts.onFire()
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sentinel,
|
||||
([entry]: IntersectionObserverEntry[]) => {
|
||||
isIntersecting = entry.isIntersecting
|
||||
if (isIntersecting) tryFire()
|
||||
},
|
||||
{ root: opts.root, rootMargin: '200px', threshold: 0 },
|
||||
)
|
||||
|
||||
watch(opts.itemsLength, (len: number) => {
|
||||
if (len === 0) {
|
||||
// 列表清空(如换搜索词)→ 重置锁状态
|
||||
lastFireLen = -1
|
||||
return
|
||||
}
|
||||
// 等下一帧,让 DOM 完成布局后再判断 sentinel 位置
|
||||
nextTick(tryFire)
|
||||
})
|
||||
|
||||
return { sentinel, tryFire }
|
||||
}
|
||||
48
src/composables/virtual/useResponsiveCols.ts
Normal file
48
src/composables/virtual/useResponsiveCols.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useResponsiveCols - 容器宽度驱动的列数
|
||||
* ============================================================
|
||||
*
|
||||
* 用 `@vueuse/core` 的 `useElementSize` 观察容器宽度,
|
||||
* 按 `minItemWidth` 算出能塞下几列。
|
||||
*
|
||||
* 适用:嵌在窄/可变宽容器里的网格 —— dashboard 卡片、对话框等
|
||||
* 「视口断点表达不了」的场景。
|
||||
*
|
||||
* 典型用法(配合 AutoSizer):
|
||||
* <AutoSizer #default="{ width }">
|
||||
* <VirtualGrid
|
||||
* :columns="useResponsiveCols(autoSizerRef, { minItemWidth: 240 })"
|
||||
* :container-height="'10rem'" ...
|
||||
* />
|
||||
* </AutoSizer>
|
||||
*
|
||||
* 或直接传一个外层容器 ref:
|
||||
* const wrapperRef = ref<HTMLElement | null>(null)
|
||||
* const cols = useResponsiveCols(wrapperRef, { minItemWidth: 240 })
|
||||
*/
|
||||
export function useResponsiveCols(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
opts: {
|
||||
/** 单项最小宽度(含 gap 估算更稳,但不强求) */
|
||||
minItemWidth: number
|
||||
/** 最少列数(默认 1,避免 width=0 时返回 0) */
|
||||
min?: number
|
||||
/** 最多列数(可选上限) */
|
||||
max?: number
|
||||
},
|
||||
): ComputedRef<number> {
|
||||
const { width } = useElementSize(containerRef)
|
||||
return computed(() => {
|
||||
const w = width.value
|
||||
const minCols = opts.min ?? 1
|
||||
if (!w || opts.minItemWidth <= 0) return minCols
|
||||
const raw = Math.floor(w / opts.minItemWidth)
|
||||
let n = Math.max(minCols, raw)
|
||||
if (opts.max !== undefined) n = Math.min(opts.max, n)
|
||||
return n
|
||||
})
|
||||
}
|
||||
58
src/composables/virtual/useTreeFlatten.ts
Normal file
58
src/composables/virtual/useTreeFlatten.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useTreeFlatten - 虚拟滚动 Base Layer:树扁平化
|
||||
* ============================================================
|
||||
*
|
||||
* 把树形数据按「当前展开状态」深度优先扁平成一维数组,
|
||||
* 交给 VirtualList 做虚拟滚动。纯 computed,无 DOM 副作用,易测。
|
||||
*
|
||||
* VirtualTree 即「useTreeFlatten + VirtualList」的组合,
|
||||
* 不引入新的虚拟化引擎。
|
||||
*/
|
||||
export interface FlatTreeNode<T> {
|
||||
/** 原始节点 */
|
||||
node: T
|
||||
/** 节点唯一 id */
|
||||
id: string | number
|
||||
/** 层级深度,根节点为 0 */
|
||||
depth: number
|
||||
/** 是否已展开(仅 hasChildren 时有意义) */
|
||||
expanded: boolean
|
||||
/** 是否有子节点 */
|
||||
hasChildren: boolean
|
||||
/** 父节点 id,根节点为 null */
|
||||
parentId: string | number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodes 根节点数组的 getter
|
||||
* @param getId 取节点唯一 id
|
||||
* @param getChildren 取子节点数组(无子节点返回 undefined/[])
|
||||
* @param isExpanded 判断某 id 当前是否展开
|
||||
*/
|
||||
export function useTreeFlatten<T>(opts: {
|
||||
nodes: () => T[]
|
||||
getId: (node: T) => string | number
|
||||
getChildren: (node: T) => T[] | undefined
|
||||
isExpanded: (id: string | number) => boolean
|
||||
}): ComputedRef<FlatTreeNode<T>[]> {
|
||||
return computed(() => {
|
||||
const out: FlatTreeNode<T>[] = []
|
||||
|
||||
const walk = (list: T[], depth: number, parentId: string | number | null) => {
|
||||
for (const node of list) {
|
||||
const id = opts.getId(node)
|
||||
const children = opts.getChildren(node)
|
||||
const hasChildren = !!children && children.length > 0
|
||||
const expanded = hasChildren && opts.isExpanded(id)
|
||||
out.push({ node, id, depth, expanded, hasChildren, parentId })
|
||||
if (expanded && children) walk(children, depth + 1, id)
|
||||
}
|
||||
}
|
||||
|
||||
walk(opts.nodes(), 0, null)
|
||||
return out
|
||||
})
|
||||
}
|
||||
75
src/composables/virtual/useVirtualizerBridge.ts
Normal file
75
src/composables/virtual/useVirtualizerBridge.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { computed } from 'vue'
|
||||
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/vue-virtual'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useVirtualizerBridge - 虚拟滚动 Base Layer:tanstack 桥接
|
||||
* ============================================================
|
||||
*
|
||||
* 封装 useVirtualizer / useWindowVirtualizer 的二选一 + measureRef null 转发。
|
||||
* 供 VirtualGrid / VirtualList 复用(VirtualMasonry 不走 tanstack,无 row 概念)。
|
||||
*
|
||||
* 为什么必须二选一:scroll 事件不冒泡到 <html>,
|
||||
* useVirtualizer + document.scrollingElement 会让 virtualizer 永远以为
|
||||
* scrollOffset=0 → 虚拟化失效。window scroll 必须用 useWindowVirtualizer。
|
||||
*
|
||||
* measureRef 的 null 转发是内存泄漏修复的【唯一真源】——
|
||||
* 行/项卸载时 Vue 用 null 调用 ref 回调,必须把 null 转发给 measureElement,
|
||||
* 否则 @tanstack/virtual-core 不会执行 elementsCache 的清理分支
|
||||
* (它只在 measureElement(null) 时遍历并 unobserve 掉 !isConnected 的元素)。
|
||||
* 不转发的话 ResizeObserver 会强引用每个曾渲染过的元素,
|
||||
* detached DOM 无法 GC → 钉住其下所有 Vue 组件实例(内存泄漏根因)。
|
||||
*/
|
||||
export function useVirtualizerBridge(opts: {
|
||||
count: () => number
|
||||
estimateSize: () => number
|
||||
overscan: () => number
|
||||
scrollMargin: () => number
|
||||
getScrollElement: () => HTMLElement | null
|
||||
useWindowScroll: boolean
|
||||
/** 可选:把 index 映射为稳定 key(VirtualList 用,VirtualGrid 自行做 row chunking 不传) */
|
||||
getItemKey?: (index: number) => string | number
|
||||
}) {
|
||||
const virtualizer = (opts.useWindowScroll
|
||||
? useWindowVirtualizer({
|
||||
get count() {
|
||||
return opts.count()
|
||||
},
|
||||
estimateSize: () => opts.estimateSize(),
|
||||
get overscan() {
|
||||
return opts.overscan()
|
||||
},
|
||||
get scrollMargin() {
|
||||
return opts.scrollMargin()
|
||||
},
|
||||
...(opts.getItemKey ? { getItemKey: opts.getItemKey } : {}),
|
||||
})
|
||||
: useVirtualizer({
|
||||
get count() {
|
||||
return opts.count()
|
||||
},
|
||||
getScrollElement: () => opts.getScrollElement(),
|
||||
estimateSize: () => opts.estimateSize(),
|
||||
get overscan() {
|
||||
return opts.overscan()
|
||||
},
|
||||
get scrollMargin() {
|
||||
return opts.scrollMargin()
|
||||
},
|
||||
...(opts.getItemKey ? { getItemKey: opts.getItemKey } : {}),
|
||||
})) as unknown as ReturnType<typeof useVirtualizer<Element, Element>>
|
||||
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize())
|
||||
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
|
||||
|
||||
function measureRef(el: any) {
|
||||
if (el instanceof HTMLElement) {
|
||||
virtualizer.value.measureElement(el)
|
||||
} else {
|
||||
// 见文件头注释:null 必须转发,否则 ResizeObserver 泄漏 detached DOM。
|
||||
virtualizer.value.measureElement(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { virtualizer, totalSize, virtualItems, measureRef }
|
||||
}
|
||||
75
src/composables/virtual/useWindowScrollMargin.ts
Normal file
75
src/composables/virtual/useWindowScrollMargin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* useWindowScrollMargin - 虚拟滚动 Base Layer:scrollMargin 追踪
|
||||
* ============================================================
|
||||
*
|
||||
* window scroll 模式下,virtualizer 需要知道滚动容器顶部相对文档的 Y 偏移
|
||||
* (scrollMargin = getBoundingClientRect().top + scrollY),才能把"窗口滚动量"
|
||||
* 换算成"容器内坐标"。
|
||||
*
|
||||
* 何时会变陈旧 —— 列表【上方】的内容高度变化(折叠面板展开、异步内容撑高等),
|
||||
* 会把列表整体往下推,scrollMargin 必须随之更新,否则虚拟项渲染位置整体偏移
|
||||
* (出现空隙或重叠)。三道防线覆盖:
|
||||
* 1. window resize —— 视口尺寸变化
|
||||
* 2. body ResizeObserver —— body 盒子自身变化(内容驱动高度的布局下,
|
||||
* 上方内容撑高会让 body 长高 → 触发)
|
||||
* 3. window scroll(rAF 节流)—— 自愈兜底:当布局是 `html,body{height:100%}`、
|
||||
* 滚动发生在 <html> 上时,上方内容撑高【不会】改变 body 盒子 → RO 不触发,
|
||||
* 此时靠下一次 scroll 重算自愈。正常滚动时 rect.top+scrollY 恒定(写回同值
|
||||
* 不触发响应式),只有真发生上方位移才会写入新值,故几乎零成本、无抖动。
|
||||
*
|
||||
* 残留边角:上方面板展开且用户【不滚动】、同时布局又非内容驱动高度 —— 此时
|
||||
* 需要消费方在已知的 toggle 时机主动调用返回的 updateScrollMargin() 即可消除。
|
||||
*
|
||||
* @param scrollEl 绑定到滚动容器根元素的模板 ref
|
||||
* @param enabled 是否启用(容器内 scroll 模式返回 false,window scroll 返回 true)
|
||||
*/
|
||||
export function useWindowScrollMargin(scrollEl: Ref<HTMLElement | null>, enabled: () => boolean) {
|
||||
const scrollMargin = ref(0)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
function updateScrollMargin() {
|
||||
if (!enabled() || !scrollEl.value || typeof window === 'undefined') {
|
||||
scrollMargin.value = 0
|
||||
return
|
||||
}
|
||||
scrollMargin.value = scrollEl.value.getBoundingClientRect().top + window.scrollY
|
||||
}
|
||||
|
||||
// scroll 自愈:rAF 节流,避免占用滚动热路径。
|
||||
function onScroll() {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
updateScrollMargin()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateScrollMargin()
|
||||
if (enabled() && typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', updateScrollMargin, { passive: true })
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
resizeObserver = new ResizeObserver(updateScrollMargin)
|
||||
if (document.body) resizeObserver.observe(document.body)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateScrollMargin)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
return { scrollMargin, updateScrollMargin }
|
||||
}
|
||||
Reference in New Issue
Block a user