iter(g187): 新增虚拟列表组件

This commit is contained in:
HIsheR
2026-10-26 17:05:34 +08:00
parent a3943ecb19
commit 20ff9d4349
12 changed files with 249 additions and 11 deletions
+1
View File
@@ -67,3 +67,4 @@ export { mentions } from './mentions.js';
export { treeSelect } from './treeSelect.js';
export { link } from './link.js';
export { avatarGroup } from './avatarGroup.js';
export { virtualList } from './virtualList.js';
+15
View File
@@ -876,3 +876,18 @@ export interface AvatarGroupProps {
}
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
/*** 虚拟列表入参:固定行高,只渲染可视窗口 + 缓冲条 ***/
export interface VirtualListProps {
items: unknown[]; // 全量数据(不切片,由组件按滚动位置取窗口)
itemHeight: number; // 每行固定高度像素(虚拟化前提:行高一致)
height: number; // 容器可视高度像素(超出即容器内滚动)
overscan?: number; // 上下各多渲染的缓冲条数;缺省 5(滚动更平滑)
renderItem: (item: unknown, index: number) => unknown; // 行内容渲染器:返回字符串/DOM/响应式值
}
/*** 虚拟列表句柄 ***/
export interface VirtualListHandle {
el: HTMLElement; // 列表根节点(本身即滚动容器)
scrollTo: (index: number) => void; // 滚动到指定行下标(越界自动钳制)
}
+44
View File
@@ -0,0 +1,44 @@
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();
},
};
}
+49 -2
View File
@@ -81,7 +81,8 @@
"mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value",
"treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起",
"link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报",
"avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名"
"avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名",
"virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄"
],
"keyTypes": [
"ButtonProps",
@@ -3507,7 +3508,53 @@
"整组是展示型元素:要有点击行为就在外面包 button 或 link,不要给单个头像加交互",
"size 与单个 avatar 一致,混排时把并排 avatar 与 avatarGroup 传同一个 size,视觉才对得上"
]
},
{
"id": "virtualList",
"title": "虚拟列表 VirtualList",
"demo": "sec-vlist",
"props": [
{
"n": "items",
"t": "unknown[]",
"r": 1,
"d": "全量数据(不切片,由组件按滚动位置取窗口)"
},
{
"n": "itemHeight",
"t": "number",
"r": 1,
"d": "每行固定高度像素;虚拟化前提是行高一致"
},
{
"n": "height",
"t": "number",
"r": 1,
"d": "容器可视高度像素,超出即容器内滚动"
},
{
"n": "overscan",
"t": "number",
"d": "上下各多渲染的缓冲条数;缺省 5,滚动更平滑",
"df": "5"
},
{
"n": "renderItem",
"t": "(item, index) => unknown",
"r": 1,
"d": "行内容渲染器:返回字符串 / DOM / 响应式值"
}
],
"example": "const vlist = virtualList({ items: users, itemHeight: 44, height: 320, renderItem: (u) => u.name }); vlist.scrollTo(500);",
"steps": [
"itemHeight 必须与每行真实高度一致:行高由组件钉死,行内容超高会溢出,请保持单行或内部滚动",
"height 是容器可视高度(像素),列表在容器内滚动,不会撑开页面",
"renderItem 第二参是原始下标(全量数据中的位置),不是窗口内序号",
"滚回某条用句柄 scrollTo(index),越界自动钳制;DOM 里只存在可视窗口的行,勿按全量下标查行节点",
"数据整体变化(换数据源)请销毁重建实例:items 是创建时快照,不响应外部数组突变",
"滚动事件内部已 rAF 合帧,滚动回调无需节流"
]
}
]
}
}
}
+32
View File
@@ -4723,3 +4723,35 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); }
transition: none;
}
}
/* —— 虚拟列表 —— */
.ua-vlist {
position: relative;
overflow-y: auto; /* 唯一滚动源:容器内滚动 */
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
overscroll-behavior: contain; /* 滚到头不带动页面滚动 */
}
.ua-vlist-phantom {
position: absolute;
top: 0;
left: 0;
width: 1px; /* 只撑高度,不占布局宽度 */
}
.ua-vlist-window {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform; /* 平移层独立合成,滚动不掉帧 */
}
.ua-vlist-item {
display: flex;
align-items: center;
box-sizing: border-box;
padding: 0 var(--ua-space-3); /* 行高由行内样式按 itemHeight 钉死 */
}
.ua-vlist-item + .ua-vlist-item {
border-top: 1px solid color-mix(in srgb, var(--ua-color-border) 60%, transparent);
}