iter(g187): 新增虚拟列表组件
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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; // 滚动到指定行下标(越界自动钳制)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 合帧,滚动回调无需节流"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user