@sprinkle2fog/virtual
v0.1.0
Published
Lightweight React virtual scrolling primitives.
Downloads
12
Readme
react-virtual
一个面向 React 的轻量虚拟滚动 npm package,目前提供三条能力线:
- 固定高度列表
- 已知可变高度列表
- 动态测量型可变高度列表
这里的“已知可变高度”指的是每一项高度可以不同,但高度值需要通过 getItemSize 提前给出。
“动态测量型可变高度”则通过 ResizeObserver 在渲染后采集真实高度,并在前方项高度变化时修正滚动锚点,尽量避免视口内容跳动。
当前提供:
- 固定高度列表的
useVirtualListhook - 已知可变高度列表的
useVariableVirtualListhook - 动态测量型可变高度列表的
useMeasuredVirtualListhook - 开箱即用的
VirtualList基础组件 - 开箱即用的
VariableVirtualList基础组件 - 开箱即用的
MeasuredVirtualList基础组件 scrollToOffset/scrollToIndex的 ref 控制能力MeasuredVirtualList的 sticky group / sticky header 能力- TypeScript 类型声明
tsupESM 产物构建
设计目标
- 对 React 版本不过度设限,
peerDependencies.react仅要求>=16.8.0 - 先把 package 结构、导出边界和最小 API 稳定下来
- 用足够清晰的实现验证虚拟滚动的核心抽象,再逐步扩展到更复杂场景
- 后续可继续扩展为 grid、更复杂的分组语义和更强的测量策略
开发
npm install
npm run check
npm run release:verify
npm run pack:check
npm testnpm run check 会顺序执行类型检查、测试和构建,适合作为本地提交前校验。
npm run build 会在构建完成后直接输出 src/index.ts、dist/index.js、gzip 和 brotli 的对齐体积报告。
npm run release:verify 会在发布前额外执行 npm 包名状态检测和打包清单检查。
npm run pack:check 会执行 npm pack --dry-run,用于确认最终发布到 npm 的文件集合是否符合预期。
仓库内的 release.yml 统一负责校验和发布:普通 push / PR 会执行测试与构建检查,tag push 或手动触发时会进一步检查 npm 包状态并使用 secrets.NPM_TOKEN 发布到 npm。
仓库内还提供了一个基础示例源码,位于 examples/basic.tsx,里面同时演示了固定高度、已知可变高度、动态测量型可变高度、sticky 分组头,以及聊天流常见的底部跟随能力。
使用示例
固定高度列表
import * as React from "react";
import { VirtualList, type VirtualListHandle } from "react-virtual";
const items = Array.from({ length: 10000 }, (_, index) => `Row ${index}`);
export function Demo() {
const listRef = React.useRef<VirtualListHandle>(null);
return (
<>
<button
onClick={() =>
listRef.current?.scrollToIndex({
index: 500,
align: "center",
behavior: "smooth"
})
}
>
Scroll to row 500
</button>
<VirtualList
ref={listRef}
items={items}
height={480}
itemHeight={40}
overscan={4}
renderItem={(item, index) => (
<div
style={{
alignItems: "center",
borderBottom: "1px solid #e5e7eb",
boxSizing: "border-box",
display: "flex",
height: "100%",
padding: "0 12px"
}}
>
{index}: {item}
</div>
)}
/>
</>
);
}已知可变高度列表
import * as React from "react";
import {
VariableVirtualList,
type VirtualListHandle
} from "react-virtual";
const items = Array.from({ length: 1000 }, (_, index) => ({
id: index,
label: `Row ${index}`,
size: 32 + (index % 4) * 12
}));
export function VariableDemo() {
const listRef = React.useRef<VirtualListHandle>(null);
return (
<VariableVirtualList
ref={listRef}
items={items}
height={480}
overscan={4}
getItemKey={(item) => item.id}
getItemSize={(item) => item.size}
renderItem={(item) => <div>{item.label}</div>}
/>
);
}动态测量型可变高度列表
import * as React from "react";
import {
MeasuredVirtualList,
type VirtualListHandle
} from "react-virtual";
const items = Array.from({ length: 1000 }, (_, index) => ({
id: index,
label: `Row ${index}`,
actualHeight: 28 + (index % 5) * 18
}));
export function MeasuredDemo() {
const listRef = React.useRef<VirtualListHandle>(null);
return (
<MeasuredVirtualList
ref={listRef}
items={items}
height={480}
overscan={4}
estimatedItemSize={48}
getItemKey={(item) => item.id}
renderItem={(item) => (
<div style={{ height: item.actualHeight }}>{item.label}</div>
)}
/>
);
}Sticky group / sticky header
import * as React from "react";
import { MeasuredVirtualList } from "react-virtual";
const items = [
{ id: "h-a", kind: "header", label: "A", height: 36 },
{ id: "a-1", kind: "row", label: "Alice", height: 40 },
{ id: "a-2", kind: "row", label: "Ava", height: 52 },
{ id: "h-b", kind: "header", label: "B", height: 36 },
{ id: "b-1", kind: "row", label: "Ben", height: 44 }
] as const;
export function StickyDemo() {
return (
<MeasuredVirtualList
items={items}
height={320}
estimatedItemSize={44}
stickyIndexes={[0, 3]}
renderStickyItem={(item) => (
<div style={{ height: item.height, fontWeight: 700 }}>{item.label}</div>
)}
renderItem={(item) => (
<div style={{ height: item.height }}>{item.label}</div>
)}
/>
);
}真正 inverted 的聊天流列表
import * as React from "react";
import {
MeasuredVirtualList,
type VirtualListHandle
} from "react-virtual";
const items = [
{ id: "m0", text: "Newest", height: 40 },
{ id: "m1", text: "Older", height: 52 },
{ id: "m2", text: "Oldest", height: 36 }
];
export function ChatDemo() {
const listRef = React.useRef<VirtualListHandle>(null);
return (
<MeasuredVirtualList
ref={listRef}
items={items}
height={320}
inverted
anchorBottom
estimatedItemSize={44}
getItemKey={(item) => item.id}
renderItem={(item) => (
<div style={{ height: item.height }}>{item.text}</div>
)}
/>
);
}API
useVirtualList(options)
适用于固定高度列表计算。
interface UseVirtualListOptions {
itemCount: number;
itemHeight: number;
viewportHeight: number;
scrollTop: number;
overscan?: number;
}返回值包含:
items: 当前需要渲染的虚拟项totalHeight: 整个列表的总高度startIndex/endIndex: 当前窗口对应的渲染区间paddingTop/paddingBottom: 当前渲染区间前后的占位高度
getScrollOffsetForIndex(options)
用于把某个索引换算成需要滚动到的 scrollTop。
type ScrollAlign = "auto" | "start" | "center" | "end";
interface GetScrollOffsetForIndexOptions {
index: number;
itemCount: number;
itemHeight: number;
viewportHeight: number;
currentScrollTop?: number;
align?: ScrollAlign;
}VirtualList
适用于快速接入基础虚拟列表。
主要 props:
items: 数据源height: 容器高度itemHeight: 单项固定高度overscan: 额外渲染的缓冲项数量initialScrollOffset: 初始滚动位置renderItem: 行渲染函数getItemKey: 自定义 key
VirtualList ref
interface VirtualListHandle {
scrollToOffset(options: {
offset: number;
behavior?: ScrollBehavior;
}): void;
scrollToBottom(options?: {
behavior?: ScrollBehavior;
}): void;
scrollToIndex(options: {
index: number;
align?: "auto" | "start" | "center" | "end";
behavior?: ScrollBehavior;
}): void;
getScrollOffset(): number;
getScrollElement(): HTMLDivElement | null;
}useVariableVirtualList(options)
适用于高度已知但每项大小不同的列表。
interface UseVariableVirtualListOptions {
itemCount: number;
viewportHeight: number;
scrollTop: number;
overscan?: number;
getItemSize: (index: number) => number;
}getVariableScrollOffsetForIndex(options)
用于把可变高度列表中的某个索引换算成需要滚动到的 scrollTop。
interface GetVariableScrollOffsetForIndexOptions {
index: number;
itemCount: number;
viewportHeight: number;
currentScrollTop?: number;
align?: ScrollAlign;
getItemSize: (index: number) => number;
}VariableVirtualList
适用于高度提前已知的 variable-size 列表。
主要 props:
items: 数据源height: 容器高度overscan: 额外渲染的缓冲项数量getItemSize: 返回每一项高度renderItem: 行渲染函数getItemKey: 自定义 key
useMeasuredVirtualList(options)
适用于初始只能估算高度、但渲染后会测量真实高度的列表。
interface UseMeasuredVirtualListOptions {
itemCount: number;
viewportHeight: number;
scrollTop: number;
overscan?: number;
inverted?: boolean;
estimatedItemSize: number | ((index: number) => number);
measuredSizes?: Partial<Record<number, number>>;
}getMeasuredScrollOffsetForIndex(options)
用于把动态测量列表中的某个索引换算成需要滚动到的 scrollTop。
interface GetMeasuredScrollOffsetForIndexOptions {
index: number;
itemCount: number;
viewportHeight: number;
currentScrollTop?: number;
align?: ScrollAlign;
inverted?: boolean;
estimatedItemSize: number | ((index: number) => number);
measuredSizes?: Partial<Record<number, number>>;
}MeasuredVirtualList
适用于初始估算高度、渲染后测量真实高度的 variable-size 列表。
主要 props:
items: 数据源height: 容器高度overscan: 额外渲染的缓冲项数量estimatedItemSize: 初始估算高度inverted: 启用真正的 inverted 视觉顺序,index 0位于底部anchorBottom: 当内容高度小于视口时,从底部开始排布内容followOutput: 当用户当前贴底时,追加内容后继续自动贴底bottomThreshold: 判断“是否视为贴底”的阈值,默认24stickyIndexes: 哪些索引应作为 sticky header/group 参与吸顶stickyItemStyle: sticky overlay 容器样式renderStickyItem: sticky overlay 的单独渲染函数,默认复用renderItemrenderItem: 行渲染函数getItemKey: 自定义 key
sticky 的行为规则是:
- 当前分组头会固定在顶部
- 下一组分组头接近顶部时,会把当前 sticky 头顶走
inverted模式下同样可用,但它依然是“吸顶”,不是吸底
聊天流场景
如果你在做聊天消息流或日志流,推荐在 MeasuredVirtualList 上开启:
<MeasuredVirtualList
anchorBottom
followOutput
bottomThreshold={24}
estimatedItemSize={48}
...
/>配合 ref.current?.scrollToBottom(),可以覆盖这两类常见行为:
- 内容较少时,消息从容器底部向上贴齐,而不是堆在顶部
- 用户当前停留在底部,新消息追加后继续贴底
- 底部附近项在真实测量后高度发生变化时,继续保持底部锚点
如果你希望“索引语义”本身就是 inverted 的,也就是:
index 0在视觉上位于底部- 更大的 index 越靠上
scrollToIndex({ index: 0, align: "end" })会滚到最新消息
可以进一步开启:
<MeasuredVirtualList
inverted
anchorBottom
estimatedItemSize={44}
...
/>这时更适合“消息数组本身按新到旧排序”的场景。
原理说明
1. 为什么需要虚拟滚动
普通列表会把所有数据一次性挂到 DOM 上。数据量一大,问题会非常明显:
- 首次渲染慢,因为创建了过多 DOM 节点
- 滚动卡顿,因为浏览器要维护庞大的布局树和绘制成本
- 内存占用高,因为屏幕外的节点其实也一直存在
虚拟滚动的核心思想很简单:用户看不到的行,就不要真的渲染。
例如一个高度为 480px 的容器、每项高度为 40px,屏幕里同一时刻最多只会看到 12 行左右。即使数据有 10000 条,也没必要渲染 10000 个 DOM 节点,只渲染“视口内 + 少量缓冲区”即可。
2. 固定高度列表的关键前提
这个版本只解决固定高度场景,也就是每一行高度都相同。
这个前提非常重要,因为它让每一项的位置都可以通过公式直接算出来,不需要真实测量 DOM:
itemTop = index * itemHeight
itemBottom = itemTop + itemHeight
totalHeight = itemCount * itemHeight一旦高度固定,滚动区间和索引区间之间就可以常数时间互相换算,这是这个实现保持简单和稳定的根本原因。
2.1 已知可变高度列表和固定高度的差别
可变高度列表里,下面这个公式不再成立:
itemTop = index * itemHeight因为第 index 项的起点,取决于它前面所有项的高度和:
itemTop = size[0] + size[1] + ... + size[index - 1]所以实现上需要先构建一组前缀偏移量:
offsets[0] = 0
offsets[1] = size[0]
offsets[2] = size[0] + size[1]这样才能做:
index -> offsetscrollTop -> nearest index
当前实现通过预先遍历一遍 getItemSize 来生成 offsets 和 sizes,再在此基础上做二分定位和区间计算,代码在 src/useVariableVirtualList.ts。
2.2 动态测量型可变高度列表的处理方式
动态测量列表和“已知可变高度”最大的差别是:真实高度在初次计算时并不知道。
所以当前实现采用两阶段策略:
- 先用
estimatedItemSize建立初始偏移表,保证列表可以立刻滚动和渲染。 - 渲染后通过
ResizeObserver获取真实高度,用真实值覆盖估算值,再重新计算偏移。
这意味着内部实际维护的是:
- 估算高度
- 已测量高度覆盖表
- 基于两者合成出来的最新 offsets/sizes
对应代码在 src/useMeasuredVirtualList.ts 和 src/MeasuredVirtualList.ts。
在当前版本里,MeasuredVirtualList 内部还额外引入了一个增量 measurement store,底层使用 Fenwick Tree 维护前缀高度和。这样当某一项被重新测量时,只需要对该项对应的高度差做一次 O(log n) 更新,而不需要在组件层每次都全量重建整张 offsets 表。实现位于 src/measurementStore.ts。
3. 如何从滚动位置算出要渲染哪些项
滚动容器会不断产生 scrollTop。拿到它之后,就可以计算当前视口覆盖了哪些索引。
基础公式如下:
visibleStart = floor(scrollTop / itemHeight)
visibleEnd = ceil((scrollTop + viewportHeight) / itemHeight) - 1含义分别是:
visibleStart: 视口顶部命中的第一项visibleEnd: 视口底部命中的最后一项
但真实渲染时通常不会只渲染这几项,因为用户继续滚动时容易出现白屏闪动,所以需要 overscan 缓冲区:
startIndex = max(0, visibleStart - overscan)
endIndex = min(itemCount - 1, visibleEnd + overscan)这就是 src/useVirtualList.ts 的核心逻辑。最终渲染量取决于“当前可见项数 + overscan”,而不是总数据量。
对于已知可变高度列表,思路相同,但计算手段不同:
- 先通过二分查找找到
scrollTop对应的起始项 - 再向后累加,直到覆盖整个视口
- 最后再加上
overscan
因此固定高度版本更偏公式换算,而可变高度版本更偏“前缀和 + 二分 + 局部扫描”。
动态测量版本则是在此基础上再多一层“估算值到真实值的收敛过程”。
当 inverted 开启时,同一套高度数据会按“反向视觉顺序”解释:
- 渲染顺序改成从高 index 到低 index
scrollTop对应的可视区间按 inverted 视觉顺序查找scrollToIndex按 inverted 的视觉位置换算偏移量
对于 sticky group,当前实现会额外维护一个“活动 sticky 项”:
- 先把
stickyIndexes对应项转换成当前视觉顺序下的start坐标。 - 找到“已经经过视口顶部”的最后一个 sticky 项,作为当前吸顶项。
- 如果下一个 sticky 项已经进入当前 sticky 的高度范围内,就给当前 sticky 一个负向
translateY,形成典型的 push-off 效果。
这个实现保持了分组头逻辑和虚拟窗口逻辑的解耦:列表本体仍然只负责渲染当前窗口,sticky 头则作为一层额外 overlay 单独计算和渲染。
4. 为什么总高度仍然要保持正确
如果只渲染可见项,但不保留整个列表应有的滚动高度,滚动条长度就会错误,用户也无法滚到后面的数据。
所以虚拟列表通常会保留一个“总高度正确的内层容器”:
totalHeight = itemCount * itemHeight外层容器负责滚动,内层容器负责撑开完整高度。这样浏览器会认为这里确实有一整个长列表,滚动条尺寸也是正确的。
5. 为什么渲染项使用绝对定位
当前实现里,真实渲染出来的行元素都放在一个 position: relative 的内层容器中,然后每一项用绝对定位摆到自己的理论位置:
style = {
position: "absolute",
width: "100%",
transform: `translateY(${itemTop}px)`
}这样做有两个目的:
- 不需要真的在 DOM 中插入前面那几千行占位节点
- 每个可见项都能被直接放到它在完整列表中的正确坐标
你可以把它理解成:DOM 里只保留少量真实节点,但这些节点“伪装”成自己位于完整长列表中的正确位置。
6. scrollToIndex 的原理
scrollToIndex 本质上不是直接滚到某个 DOM 节点,而是先把目标索引换算成应该滚动到的 scrollTop,再调用滚动容器的 scrollTo。
最基本的起始对齐:
offset = index * itemHeight如果需要不同对齐方式:
start => itemStart
end => itemEnd - viewportHeight
center => itemStart - (viewportHeight - itemHeight) / 2auto 则更像“尽量少滚动”:
- 如果目标项已经完全在视口里,不滚动
- 如果目标项在视口上方,滚到该项顶部
- 如果目标项在视口下方,滚到该项底部刚好进入视口
对应逻辑在 src/useVirtualList.ts 和 src/VirtualList.ts 中。
7. 这个实现的时间复杂度
在固定高度前提下:
- 区间计算是
O(1) - 渲染项生成是
O(k)
这里的 k 是“当前渲染项数量”,大约等于:
k ~= ceil(viewportHeight / itemHeight) + overscan * 2关键点在于,k 与 itemCount 无关。即使总数据是十万条,只要视口能看到的数量没变,单次渲染压力就大体稳定。
对于已知可变高度版本:
- 测量表构建是
O(n) - 起始索引定位是
O(log n) - 渲染项生成是
O(k)
这意味着它已经能支持 variable-size 场景,但还不是“超大规模动态测量列表”的最终形态。真正追求极致性能时,通常会继续引入增量缓存,而不是每次都完整重建偏移表。
动态测量版本会在“前方项高度变化”时做滚动锚点修正,也就是把变化量同步加回 scrollTop,尽量保持用户眼前内容不跳。这个逻辑在 src/MeasuredVirtualList.ts 中实现。
为了降低动态测量阶段的更新成本,组件内部现在使用:
- Fenwick Tree 维护前缀和
- 单项高度差增量更新
scrollTop -> index的对数级查找
所以组件层的热路径已经从“每次测量变化都完整重建偏移表”推进到了“更新和定位都尽量走增量结构”。
8. 当前动态测量方案的边界
虽然已经支持动态测量,但当前实现仍然是基础版,主要边界包括:
MeasuredVirtualList内部已用增量树结构,但基础 hook 仍然保持直观实现- 依赖
ResizeObserver - 已支持正向列表、sticky group、底部跟随和真正的 inverted 视觉顺序
anchorBottom解决的是“底部布局”;inverted解决的是“索引语义反转”- sticky 目前是单列 overlay 方案,还没有做嵌套分组、横向滚动或多列布局协调
9. 当前实现的边界与约束
这个版本适合:
- 大量数据
- 固定行高
- 高度提前已知的可变高度列表
- 单列列表
- 需要基础滚动控制
当前还不覆盖:
- 多列 grid 虚拟化
- 服务端渲染下的复杂首屏同步策略
- 项级别测量与回收策略
- 更复杂的 sticky 分层、嵌套 group 和 section tree
10. 代码结构
- src/useVirtualList.ts: 负责区间计算和索引到偏移量的换算
- src/useVariableVirtualList.ts: 负责已知可变高度列表的偏移量与渲染区间计算
- src/useMeasuredVirtualList.ts: 负责动态测量列表的估算高度、已测高度覆盖和区间计算
- src/measurementStore.ts: 负责动态测量组件内部的 Fenwick Tree 和增量前缀和维护
- src/VirtualList.ts: 负责滚动容器、绝对定位渲染、ref 控制方法
- src/VariableVirtualList.ts: 负责已知可变高度列表组件
- src/MeasuredVirtualList.ts: 负责动态测量、
ResizeObserver接入、sticky overlay 和锚点修正 - src/index.ts: 对外导出边界
后续演进建议
- 动态测量型可变高度的增量缓存和数据结构优化
- grid 虚拟化
- benchmark、真实 playground 和文档站点
