npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kookapp/virtual-layout-engine

v0.0.1

Published

KookApp Virtual Layout Engine

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)
})

核心概念

三种坐标系

  1. ID 空间:稳定标识符(如消息 ID、用户 ID)

    • 由 DataProvider 维护 id ↔ index 映射
    • 支持动态插入、删除、重排
  2. Index 空间:逻辑顺序位置(0, 1, 2, ...)

    • 由 LayoutModel 维护 index ↔ offset 映射
    • 可变的逻辑索引
  3. 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?): 滚动到指定 ID
  • scrollToIndex(index, align?): 滚动到指定索引
  • scrollToOffset(offset): 滚动到指定偏移量
  • smoothScrollToId(id, align?): 平滑滚动到 ID
  • smoothScrollToIndex(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) => {})

性能优化

  1. 批量测量:默认启用,减少重排次数
  2. 前缀和缓存:动态尺寸场景下按需计算
  3. overscan 配置:平衡流畅性和性能
  4. ResizeObserver:自动响应容器尺寸变化

📚 文档

测试

# 运行单元测试
npm test

# 运行特定测试
npm test -- SizeStore.test.ts

许可证

MIT