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

@@ -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 = () => {
// 确保路由路径是最新的

View File

@@ -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,
}
}

View 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 }
}

View 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
})
}

View 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 rootwindow 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 }
}

View 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
})
}

View 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
})
}

View File

@@ -0,0 +1,75 @@
import { computed } from 'vue'
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/vue-virtual'
/**
* ============================================================
* useVirtualizerBridge - 虚拟滚动 Base Layertanstack 桥接
* ============================================================
*
* 封装 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 映射为稳定 keyVirtualList 用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 }
}

View File

@@ -0,0 +1,75 @@
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
/**
* ============================================================
* useWindowScrollMargin - 虚拟滚动 Base LayerscrollMargin 追踪
* ============================================================
*
* window scroll 模式下virtualizer 需要知道滚动容器顶部相对文档的 Y 偏移
* scrollMargin = getBoundingClientRect().top + scrollY才能把"窗口滚动量"
* 换算成"容器内坐标"。
*
* 何时会变陈旧 —— 列表【上方】的内容高度变化(折叠面板展开、异步内容撑高等),
* 会把列表整体往下推scrollMargin 必须随之更新,否则虚拟项渲染位置整体偏移
* (出现空隙或重叠)。三道防线覆盖:
* 1. window resize —— 视口尺寸变化
* 2. body ResizeObserver —— body 盒子自身变化(内容驱动高度的布局下,
* 上方内容撑高会让 body 长高 → 触发)
* 3. window scrollrAF 节流)—— 自愈兜底:当布局是 `html,body{height:100%}`、
* 滚动发生在 <html> 上时,上方内容撑高【不会】改变 body 盒子 → RO 不触发,
* 此时靠下一次 scroll 重算自愈。正常滚动时 rect.top+scrollY 恒定(写回同值
* 不触发响应式),只有真发生上方位移才会写入新值,故几乎零成本、无抖动。
*
* 残留边角:上方面板展开且用户【不滚动】、同时布局又非内容驱动高度 —— 此时
* 需要消费方在已知的 toggle 时机主动调用返回的 updateScrollMargin() 即可消除。
*
* @param scrollEl 绑定到滚动容器根元素的模板 ref
* @param enabled 是否启用(容器内 scroll 模式返回 falsewindow 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 }
}