Last active
April 12, 2024 11:30
-
-
Save ypresto/d4ebfa7e573f0b659dc243901add38c6 to your computer and use it in GitHub Desktop.
Reuse rows rendered by @tanstack/virtual to optimize for complicated row component with slow mount (React / Vue)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Not tested! | |
import type { VirtualItem, Virtualizer } from '@tanstack/virtual' | |
import { useEffect, useRef, useState } from 'react' | |
const DEBUG = false | |
export interface PooledVirtualItem extends Omit<VirtualItem, 'key'> { | |
key: number | |
suspended?: boolean | |
} | |
function createObjectPool<T>() { | |
const pool: T[] = [] | |
return { | |
add: (item: T) => { | |
pool.push(item) | |
}, | |
take: () => { | |
return pool.pop() | |
}, | |
items: () => { | |
return pool | |
}, | |
drain: (size: number) => { | |
const overflow = pool.length - size | |
if (DEBUG && overflow > 0) console.debug('usePooledVirtualItems: draining ' + overflow + ' items') | |
for (let i = 0; i < overflow; i++) { | |
pool.pop() | |
} | |
}, | |
} | |
} | |
export function usePooledVirtualItems<T extends Element>( | |
virtualizer: Virtualizer<any, T>, | |
options?: { maxExtraItems?: number; sort?: boolean } | |
) { | |
const poolRef = useRef(createObjectPool<PooledVirtualItem>()) | |
const prevItemsRef = useRef<VirtualItem[]>([]) | |
const nextRowIdRef = useRef(0) | |
const keyToRowIdMapRef = useRef<Map<string | number, number> | null>(null) | |
const [items, setItems] = useState(virtualizer.getVirtualItems()) | |
useEffect(() => { | |
const pool = poolRef.current | |
const prevItems = prevItemsRef.current | |
const virtualItems = virtualizer.getVirtualItems() | |
if (!keyToRowIdMapRef.current) { | |
keyToRowIdMapRef.current = new Map(virtualItems.map((item, i) => [item.key, i])) | |
nextRowIdRef.current = virtualItems.length | |
if (DEBUG) console.debug('usePooledVirtualItems: initial row count: ' + nextRowIdRef.current) | |
} | |
const keyToRowIdMap = keyToRowIdMapRef.current | |
const visibleItemKeys = virtualItems.map(item => item.key) | |
const disappeared = prevItems.filter(prevItem => !visibleItemKeys.includes(prevItem.key)) | |
for (const item of disappeared) { | |
pool.add({ ...item, key: keyToRowIdMap.get(item.key)!, suspended: true }) | |
keyToRowIdMap.delete(item.key) | |
} | |
for (const item of virtualItems) { | |
if (!keyToRowIdMap.has(item.key)) { | |
const reused = pool.take() | |
if (DEBUG && !reused) console.debug('usePooledVirtualItems: creating row id: ' + nextRowIdRef.current) | |
keyToRowIdMap.set(item.key, reused ? reused.key : nextRowIdRef.current++) | |
} | |
} | |
if (options?.maxExtraItems != null) { | |
pool.drain(options?.maxExtraItems) | |
} | |
prevItemsRef.current = virtualItems | |
const result = [ | |
...virtualItems.map((item): PooledVirtualItem => ({ ...item, key: keyToRowIdMap.get(item.key)! })), | |
...pool.items(), | |
] | |
if (options?.sort) { | |
result.sort((a, b) => a.key - b.key) | |
} | |
setItems(result) | |
}, [virtualizer]) | |
return items | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { VirtualItem, Virtualizer } from '@tanstack/vue-virtual' | |
import { computed, type Ref } from 'vue' | |
const DEBUG = false | |
export interface PooledVirtualItem extends Omit<VirtualItem, 'key'> { | |
key: number | |
suspended?: boolean | |
} | |
function createObjectPool<T>() { | |
const pool: T[] = [] | |
return { | |
add: (item: T) => { | |
pool.push(item) | |
}, | |
take: () => { | |
return pool.pop() | |
}, | |
items: () => { | |
return pool | |
}, | |
drain: (size: number) => { | |
const overflow = pool.length - size | |
if (DEBUG && overflow > 0) console.debug('usePooledVirtualItems: draining ' + overflow + ' items') | |
for (let i = 0; i < overflow; i++) { | |
pool.pop() | |
} | |
}, | |
} | |
} | |
export function usePooledVirtualItems<T extends Element>( | |
virtualizer: Ref<Virtualizer<any, T>>, | |
options?: { maxExtraItems?: number; sort?: boolean } | |
) { | |
const pool = createObjectPool<PooledVirtualItem>() | |
let prevItems: VirtualItem[] = [] | |
let nextRowId = 0 | |
let keyToRowIdMap: Map<string | number, number> | |
return computed(() => { | |
const virtualItems = virtualizer.value.getVirtualItems() | |
if (!keyToRowIdMap) { | |
keyToRowIdMap = new Map(virtualItems.map((item, i) => [item.key, i])) | |
nextRowId = virtualItems.length | |
if (DEBUG) console.debug('usePooledVirtualItems: initial row count: ' + nextRowId) | |
} | |
const visibleItemKeys = virtualItems.map(item => item.key) | |
const disappeared = prevItems.filter(prevItem => !visibleItemKeys.includes(prevItem.key)) | |
for (const item of disappeared) { | |
pool.add({ ...item, key: keyToRowIdMap.get(item.key)!, suspended: true }) | |
keyToRowIdMap.delete(item.key) | |
} | |
for (const item of virtualItems) { | |
if (!keyToRowIdMap.has(item.key)) { | |
const reused = pool.take() | |
if (DEBUG && !reused) console.debug('usePooledVirtualItems: creating row id: ' + nextRowId) | |
keyToRowIdMap.set(item.key, reused ? reused.key : nextRowId++) | |
} | |
} | |
if (options?.maxExtraItems != null) { | |
pool.drain(options?.maxExtraItems) | |
} | |
prevItems = virtualItems | |
const result = [ | |
...virtualItems.map((item): PooledVirtualItem => ({ ...item, key: keyToRowIdMap.get(item.key)! })), | |
...pool.items(), | |
] | |
if (options?.sort) { | |
result.sort((a, b) => a.key - b.key) | |
} | |
return result | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- Not tested! --> | |
<template> | |
<div | |
ref="wrapperElement" | |
:style="{ | |
height: `${totalSize}px`, | |
width: '100%', | |
position: 'relative', | |
}" | |
> | |
<div | |
:style="{ | |
position: 'absolute', | |
top: 0, | |
left: 0, | |
width: '100%', | |
transform: `translateY(${virtualRows[0]?.start ?? 0}px)`, | |
}" | |
> | |
<div | |
v-for="virtualRow in pooledVirtualRows" | |
:key="virtualRow.key" | |
:data-index="virtualRow.index" | |
:hidden="virtualRow.suspended" | |
> | |
Row {{ virtualRow.index }} | |
</div> | |
</div> | |
</div> | |
</template> | |
<script setup lang="ts"> | |
import { computed, toRefs, ref, type PropType } from 'vue' | |
import { useVirtualizer } from '@tanstack/vue-virtual' | |
import { usePooledVirtualItems } from '~/utils/usePooledVirtualItems' | |
const props = defineProps({ | |
items: { | |
type: Array as PropType<string[]>, | |
required: true, | |
}, | |
}) | |
const { items } = toRefs(props) | |
const wrapperElement = ref<HTMLElement | null>(null) | |
const rowVirtualizer = useVirtualizer( | |
computed(() => { | |
return { | |
count: items.length, | |
getScrollElement: () => wrapperElement.value, | |
estimateSize: () => 120, | |
overscan: 0, | |
} | |
}) | |
) | |
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems()) | |
const pooledVirtualRows = usePooledVirtualItems(rowVirtualizer) | |
const totalSize = computed(() => rowVirtualizer.value.getTotalSize()) | |
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- With sort: true option it does not change order of items even it is suspended. --> | |
<!-- This removes DOM mutation for moving element in container element (insertBefore()). --> | |
<!-- You should specify order: style because DOM order does not match with actual list order. --> | |
<!-- Not tested! --> | |
<template> | |
<div | |
ref="wrapperElement" | |
:style="{ | |
height: `${totalSize}px`, | |
width: '100%', | |
position: 'relative', | |
}" | |
> | |
<div | |
:style="{ | |
position: 'absolute', | |
top: 0, | |
left: 0, | |
width: '100%', | |
transform: `translateY(${virtualRows[0]?.start ?? 0}px)`, | |
display: 'flex', | |
flexDirection: 'column', | |
}" | |
> | |
<div | |
v-for="virtualRow in pooledVirtualRows" | |
:key="virtualRow.key" | |
:data-index="virtualRow.index" | |
:hidden="virtualRow.suspended" | |
:style="{ order: virtualRow.index }" | |
> | |
Row {{ virtualRow.index }} | |
</div> | |
</div> | |
</div> | |
</template> | |
<script setup lang="ts"> | |
import { computed, toRefs, ref, type PropType } from 'vue' | |
import { useVirtualizer } from '@tanstack/vue-virtual' | |
import { usePooledVirtualItems } from '~/utils/usePooledVirtualItems' | |
const props = defineProps({ | |
items: { | |
type: Array as PropType<string[]>, | |
required: true, | |
}, | |
}) | |
const { items } = toRefs(props) | |
const wrapperElement = ref<HTMLElement | null>(null) | |
const rowVirtualizer = useVirtualizer( | |
computed(() => { | |
return { | |
count: items.length, | |
getScrollElement: () => wrapperElement.value, | |
estimateSize: () => 120, | |
overscan: 0, | |
} | |
}) | |
) | |
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems()) | |
const pooledVirtualRows = usePooledVirtualItems(rowVirtualizer, { sort: true }) | |
const totalSize = computed(() => rowVirtualizer.value.getTotalSize()) | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment