feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user