@kookapp/virtual-layout-engine
v0.0.1
Published
KookApp Virtual Layout Engine
Keywords
Readme
Virtual Layout Engine
与 UI 框架、数据来源、渲染方式完全解耦的虚拟化滚动抽象系统。
核心特性
- 三种坐标系分离:id/index(DataProvider) ↔ index/offset(LayoutModel)↔ 流程控制(Core)
- SizeStore 统一尺寸管理:支持估计值→真实值的平滑过渡
- 锚点修正策略:Core 内置默认策略 + 可配置 + Driver 保留最终控制权
- 批量测量回调:默认批量测量 + 保留手动调用能力
- 完全解耦:各层职责明确,易于测试和扩展
架构概览
┌─────────────────────────────┐
│ Renderer Layer │ DOM / Canvas / 移动端
│ (DomPaddingRenderer) │
└──────────────▲──────────────┘
│ VirtualLayoutResult
┌──────────────┴──────────────┐
│ Virtual Layout Core │ 核心计算引擎
│ + SizeStore │
└──────────────▲──────────────┘
│ sizeResolver
┌──────────────┴──────────────┐
│ Layout Model Layer │ index ↔ offset 映射
│ (FixedSizeListModel / │
│ DynamicSizeListModel) │
└──────────────▲──────────────┘
│ id ↔ index 查询
┌──────────────┴──────────────┐
│ Data Provider Layer │ id ↔ index 映射
│ (StaticDataProvider) │
└─────────────────────────────┘快速开始
1. 固定尺寸列表
import {
VirtualScrollDriver,
FixedSizeListModel,
StaticDataProvider,
DomPaddingRenderer,
} from '@/models/virtualLayoutEngine'
// 1. 准备数据
const ids = Array.from({ length: 10000 }, (_, i) => `item-${i}`)
const dataProvider = new StaticDataProvider({ ids })
// 2. 创建布局模型
const layoutModel = new FixedSizeListModel({
itemSize: 50, // 每项高度 50px
totalLength: ids.length,
})
// 3. 创建渲染器
const renderer = new DomPaddingRenderer({
container: document.getElementById('scroll-container')!,
renderItem: (id, data, index) => {
const div = document.createElement('div')
div.textContent = `Item ${index}: ${id}`
div.style.height = '50px'
return div
},
})
// 4. 创建驱动器
const driver = new VirtualScrollDriver({
container: document.getElementById('scroll-container')!,
dataProvider,
layoutModel,
renderer,
defaultEstimatedSize: 50,
})
// 5. 监听事件
driver.on('visibleRangeChange', (result) => {
console.log('可见项:', result.visibleItems.length)
})
// 6. 滚动到指定位置
driver.scrollToId('item-100', 'center')2. 动态尺寸列表
import {
VirtualScrollDriver,
DynamicSizeListModel,
StaticDataProvider,
DomPaddingRenderer,
SizeStore,
} from '@/models/virtualLayoutEngine'
// 1. 准备数据(带估计尺寸)
const dataProvider = new StaticDataProvider({
ids: Array.from({ length: 1000 }, (_, i) => `msg-${i}`),
estimatedSize: (id) => {
// 根据内容长度估计高度
return 60 + Math.random() * 100
},
})
// 2. 创建 SizeStore(Core 会自动创建,这里仅为说明)
// const sizeStore = new SizeStore({
// dataProvider,
// defaultEstimatedSize: 80,
// })
// 3. 创建动态布局模型
const layoutModel = new DynamicSizeListModel({
sizeResolver: sizeStore, // 由 Core 提供
totalLength: 1000,
})
// 4. 创建渲染器
const renderer = new DomPaddingRenderer({
container: containerEl,
renderItem: (id, data, index) => {
const div = document.createElement('div')
div.innerHTML = `
<div style="padding: 10px; border-bottom: 1px solid #eee">
<h3>Message ${index}</h3>
<p>${'Lorem ipsum '.repeat(Math.floor(Math.random() * 20))}</p>
</div>
`
return div
},
})
// 5. 创建驱动器(会自动处理尺寸测量和锚点修正)
const driver = new VirtualScrollDriver({
container: containerEl,
dataProvider,
layoutModel,
renderer,
defaultEstimatedSize: 80,
overscan: 300, // 缓冲区 300px
})3. 异步数据加载(无限滚动)
import {
VirtualScrollDriver,
DynamicSizeListModel,
AsyncDataProvider,
DomPaddingRenderer,
} from '@/models/virtualLayoutEngine'
// 1. 创建异步数据提供者
const dataProvider = new AsyncDataProvider({
totalCount: 10000, // 总数据量
// 数据加载函数
loadData: async (startIndex, count) => {
const response = await fetch(
`/api/messages?start=${startIndex}&count=${count}`
)
const messages = await response.json()
return messages.map((data) => ({ id: data.id, data }))
},
// 估计尺寸(用于未加载项)
estimatedSize: (id) => {
return id.toString().startsWith('placeholder-') ? 80 : null
},
pageSize: 50, // 每次加载 50 条
})
// 2. 创建渲染器(支持骨架屏)
const renderer = new DomPaddingRenderer({
container: containerEl,
renderItem: (id, data, index) => {
const div = document.createElement('div')
// 数据未加载:显示骨架屏
if (!data || !dataProvider.isLoaded(index)) {
div.innerHTML = '<div class="skeleton-item">Loading...</div>'
return div
}
// 数据已加载:显示真实内容
div.innerHTML = `<div>${data.content}</div>`
return div
},
})
// 3. 创建驱动器
const driver = new VirtualScrollDriver({
container: containerEl,
dataProvider,
layoutModel: new DynamicSizeListModel({
sizeResolver: null, // 由 Core 提供
totalLength: 10000,
}),
renderer,
defaultEstimatedSize: 80,
overscan: 500, // 增大缓冲区,减少加载次数
})
// 4. 监听加载事件
dataProvider.on('loadMoreStart', () => {
console.log('加载中...')
})
dataProvider.on('loadMoreEnd', (success) => {
console.log('加载完成:', success)
})核心概念
三种坐标系
ID 空间:稳定标识符(如消息 ID、用户 ID)
- 由 DataProvider 维护 id ↔ index 映射
- 支持动态插入、删除、重排
Index 空间:逻辑顺序位置(0, 1, 2, ...)
- 由 LayoutModel 维护 index ↔ offset 映射
- 可变的逻辑索引
Offset 空间:主轴方向的空间位置(像素)
- 由 LayoutModel 负责计算
- Core 组织查询流程
异步数据处理流程
数据未加载时:
1️⃣ DataProvider 返回占位符 ID
2️⃣ Renderer 渲染骨架屏(使用估计尺寸)
3️⃣ DataProvider 自动触发加载
数据加载中:
4️⃣ 显示加载状态
5️⃣ 系统继续流畅运行(不阻塞)
数据加载后:
6️⃣ DataProvider 触发 'dataChanged' 事件
7️⃣ Driver 重新计算可见范围
8️⃣ Renderer 渲染真实内容
9️⃣ 测量真实尺寸 → 更新 SizeStore
🔟 Core 计算锚点修正 → Driver 应用修正
✅ 视觉位置保持稳定💡 详细说明请阅读:异步数据处理流程文档
getItemSize 协作流程
1️⃣ DataProvider 可选提供 estimatedSize(id)
↓
2️⃣ Core 创建 SizeStore,提供 sizeResolver 给 LayoutModel
↓
3️⃣ LayoutModel 通过 sizeResolver 查询尺寸
↓
4️⃣ Renderer 渲染后测量真实尺寸
↓
5️⃣ Renderer 批量回调 Core: onItemsMeasured([{id, size}])
↓
6️⃣ Core 更新 SizeStore
↓
7️⃣ SizeStore 触发 'sizeChanged' 事件
↓
8️⃣ Core 计算锚点修正建议
↓
9️⃣ Driver 决定是否应用修正并更新视图锚点修正策略
默认策略(DefaultAnchorStrategy):
- 保持可见区起始项的视觉位置不变
- 适用于大多数场景
智能策略(SmartAnchorStrategy):
- 根据尺寸变化位置智能选择锚点
- 更精确的控制
无修正策略(NoAnchorStrategy):
- 不进行任何修正
- 用于调试或手动控制场景
import { SmartAnchorStrategy } from '@/models/virtualLayoutEngine'
const driver = new VirtualScrollDriver({
// ...
anchorStrategy: new SmartAnchorStrategy(),
})API 文档
VirtualScrollDriver
构造选项:
interface VirtualScrollDriverOptions {
container: HTMLElement // 容器元素
dataProvider: IDataProvider // 数据提供者
layoutModel: IUILayoutModel // 布局模型
renderer: IVirtualRenderer // 渲染器
defaultEstimatedSize: number // 默认估计尺寸
overscan?: number // 缓冲区大小(默认 200px)
anchorStrategy?: IAnchorStrategy // 锚点策略(默认 DefaultAnchorStrategy)
}方法:
scrollToId(id, align?): 滚动到指定 IDscrollToIndex(index, align?): 滚动到指定索引scrollToOffset(offset): 滚动到指定偏移量smoothScrollToId(id, align?): 平滑滚动到 IDsmoothScrollToIndex(index, align?): 平滑滚动到索引smoothScrollToOffset(offset): 平滑滚动到偏移量measureItems(ids?): 手动触发测量getVisibleRange(): 获取当前可见范围destroy(): 清理资源
事件:
visibleRangeChange: 可见范围变化scroll: 滚动事件measureComplete: 测量完成error: 错误事件
LayoutModel
FixedSizeListModel:固定尺寸列表
new FixedSizeListModel({
itemSize: 50, // 单项尺寸
totalLength: 1000, // 总长度
paddingTop?: 0, // 顶部 padding
})DynamicSizeListModel:动态尺寸列表
new DynamicSizeListModel({
sizeResolver, // 尺寸解析器(由 Core 提供)
totalLength: 1000, // 总长度
paddingTop?: 0, // 顶部 padding
})DataProvider
StaticDataProvider:静态数据
new StaticDataProvider({
ids: VS_ID[], // ID 列表
dataMap?: Map<VS_ID, T>, // 数据映射
estimatedSize?: (id) => number, // 估计尺寸函数
})AsyncDataProvider:异步数据(无限滚动)
new AsyncDataProvider({
totalCount: number, // 总数据量
loadData: (start, count) => Promise<Array<{id, data}>>, // 加载函数
estimatedSize?: (id) => number, // 估计尺寸函数
pageSize?: number, // 每页大小(默认 50)
prefetchThreshold?: number, // 预加载阈值(默认 10)
})
// 方法
dataProvider.loadRange(start, end) // 加载指定范围
dataProvider.hasMore() // 是否还有更多数据
dataProvider.isLoaded(index) // 检查是否已加载
// 事件
dataProvider.on('loadMoreStart', () => {})
dataProvider.on('loadMoreEnd', (success) => {})性能优化
- 批量测量:默认启用,减少重排次数
- 前缀和缓存:动态尺寸场景下按需计算
- overscan 配置:平衡流畅性和性能
- ResizeObserver:自动响应容器尺寸变化
📚 文档
测试
# 运行单元测试
npm test
# 运行特定测试
npm test -- SizeStore.test.ts许可证
MIT
