feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame

This commit is contained in:
Ilia Mashkov
2026-01-16 17:48:33 +03:00
parent 261c19db69
commit 8c0c91deb7

View File

@@ -79,27 +79,23 @@ export interface VirtualizerOptions {
* </div> * </div>
* ``` * ```
*/ */
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
// Reactive State
let scrollOffset = $state(0); let scrollOffset = $state(0);
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
// Reactive Options // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer) // This derivation now tracks: count, measuredSizes, AND the data array itself
// Only recalculates when item count or measured sizes change.
const offsets = $derived.by(() => { const offsets = $derived.by(() => {
const count = options.count; const count = options.count;
const result = Array.from<number>({ length: count }); const result = new Float64Array(count);
let accumulated = 0; let accumulated = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
result[i] = accumulated; result[i] = accumulated;
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i); accumulated += measuredSizes[i] ?? options.estimateSize(i);
} }
return result; return result;
@@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
: 0, : 0,
); );
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => { const items = $derived.by((): VirtualItem[] => {
const count = options.count; // We MUST read options.data here so Svelte knows to re-run
if (count === 0 || containerHeight === 0) return []; // this derivation when the items array is replaced!
const { count, data } = options;
if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5; const overscan = options.overscan ?? 5;
const viewportStart = scrollOffset;
const viewportEnd = scrollOffset + containerHeight;
// Find Start (Linear Scan) // Binary search for efficiency
let low = 0;
let high = count - 1;
let startIdx = 0; let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) { while (low <= high) {
startIdx++; const mid = Math.floor((low + high) / 2);
if (offsets[mid] <= scrollOffset) {
startIdx = mid;
low = mid + 1;
} else {
high = mid - 1;
}
} }
// Find End
let endIdx = startIdx; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const size = measuredSizes[i] ?? options.estimateSize(i);
result.push({ result.push({
index: i, index: i,
start: offsets[i], start: offsets[i],
size, size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + size, end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
}); });
} }
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // Svelte Actions (The DOM Interface)
/** /**
@@ -185,6 +185,8 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
}; };
} }
let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null;
/** /**
* Svelte action to measure individual item elements for dynamic height support. * Svelte action to measure individual item elements for dynamic height support.
* *
@@ -195,23 +197,32 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
* @returns Object with destroy method for cleanup * @returns Object with destroy method for cleanup
*/ */
function measureElement(node: HTMLElement) { function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => { const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) { if (!entry) return;
const index = parseInt(node.dataset.index || '', 10); const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops if (!isNaN(index) && measuredSizes[index] !== height) {
if (!isNaN(index) && measuredSizes[index] !== height) { // 1. Stuff the measurement into a temporary buffer
measuredSizes[index] = height; measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// 4. Reset the buffer
measurementBuffer = {};
frameId = null;
});
} }
} }
}); });
resizeObserver.observe(node); resizeObserver.observe(node);
return { return { destroy: () => resizeObserver.disconnect() };
destroy: () => resizeObserver.disconnect(),
};
} }
// Programmatic Scroll // Programmatic Scroll