Files
UI/packages/skin/ui/src/virtualList.ts
T
2026-10-26 17:09:11 +08:00

45 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
},
};
}