import { h } from '@ui-arch/frame'; import type { VirtualListProps, VirtualListHandle } from './types.js'; /*** 虚拟列表:固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧 ***/ export function virtualList(props: VirtualListProps): VirtualListHandle { // —— 状态集中声明 —— const overscan = props.overscan ?? 5; // 上下各多渲染的缓冲条数,滚动更平滑 const total = props.items.length; // 数据总量(决定占位层高度) const raf = { n: 0 }; // 滚动节流帧句柄(对象包装便于闭包内改写) const view = h('div', { class: 'ua-vlist', role: 'list', style: `height:${props.height}px` }) as HTMLElement; // 可视容器:唯一滚动源 const phantom = h('div', { class: 'ua-vlist-phantom' }) as HTMLElement; // 占位层:撑出真实滚动高度 const win = h('div', { class: 'ua-vlist-window' }) as HTMLElement; // 可视窗口层:整体平移对齐滚动位置 /*** 按当前 scrollTop 重算可视区间并整窗重建:窗口内条目数固定少量,重建比 diff 更简单可靠 ***/ const render = (): void => { const first = Math.max(0, Math.floor(view.scrollTop / props.itemHeight) - overscan); // 窗口首条(含缓冲上探) const visible = Math.ceil(props.height / props.itemHeight) + 1; // 视口能容纳的整条数(+1 补零头) const end = Math.min(total, first + visible + overscan * 2); // 窗口尾条(含缓冲) win.style.transform = `translateY(${first * props.itemHeight}px)`; // 窗口平移到首条位置 const rows: HTMLElement[] = []; // 本轮窗口内的行节点 for (let i = first; i < end; i++) { rows.push(h('div', { class: 'ua-vlist-item', role: 'listitem', style: `height:${props.itemHeight}px` }, props.renderItem(props.items[i], i))); } win.replaceChildren(...rows); // 一次性换入,避免逐个 append 的中间态闪烁 }; /*** 滚动事件:rAF 合并同帧多次触发,滚得再快也只渲一帧 ***/ const onScroll = (): void => { if (raf.n) return; // 已有排程中的帧,吞掉本次 raf.n = requestAnimationFrame(() => { raf.n = 0; render(); }); }; view.addEventListener('scroll', onScroll); phantom.style.height = `${total * props.itemHeight}px`; // 占位高度 = 总行数 × 行高 view.append(phantom, win); render(); // 首屏先渲一帧,不等滚动 return { el: view, /*** 滚动到指定下标(钳制到有效区间),同步重渲避免同帧 scroll 不触发 ***/ scrollTo(index: number): void { view.scrollTop = Math.max(0, Math.min(index, total - 1)) * props.itemHeight; // 越界钳制 render(); }, }; }