VirtualList 虚拟列表
VirtualList 只渲染视口附近的项,一万行的列表开销和一屏差不多。每一项可以是任意高度,也可以随时变高变矮(图片加载完、消息展开):每项渲染时都会测量,高度按 key 记住。上方的项加载、变高或变矮时,你正在看的内容不会动。
设置 anchor="end" 就是聊天消息流:一开始停在底部,在底部时跟随新消息;往上加载更早的消息时保持你的位置;可以跳到任意已加载的项。
演示
基础用法
给列表一个高度和一个估计的项高(itemSize),真实高度会被测量。scrollToKey 能把一项准确地停在指定位置,即使一路上大部分高度都是估计值。
聊天
anchor="end" 停在底部,在底部时跟随新消息。往上滚就不再跟随:新消息不会再挪动视图。更早的消息在上方加载(onStartReached),即使它们比估计的高,也不会挪动你正在读的内容;这里的图片没有预知尺寸,加载完才变高。Send 调用 followEnd(),自己发的消息总会滚到可见处。Jump to #321 加载一条未加载消息前后的一段并跳过去;这时列表知道后面还有更新的消息(hasNewer),所以不跟随,往下滚时再加载(onEndReached)。
测试台
照 vueuc 虚拟列表 demo 做的滚动行为测试台:10 万行、高度不一。可以按下标或 key 跳转(四种对齐、平滑或不平滑);往上 / 往下追加;在某行后插入(0.3 秒展开)或删除某行(收起);让某行变高;改视口内或全部行的高度;隐藏再显示列表;切换 start / end 锚点。下面的数字随滚动实时更新。除非你自己滚动,视口顶部的内容都不应该移动。
工作方式
- 高度按 key 缓存,往上加载、裁剪、滚出去再滚回来都不丢。偏移用 Fenwick 树计算:改一项高度、按偏移找项都是 O(log n)。
- 任何时刻滚动位置只有一个管理者。 列表维护一个「位置」:某一项和它离视口顶部的距离、底部(跟随中),或跳转目标。你在阅读(锚定)时,浏览器自带的滚动锚定是开着的:视口上方的内容变高变矮时,浏览器在布局阶段就调整了滚动偏移;列表在量完尺寸之后(绘制之前)检查,发现偏移已经被浏览器调过,就接受它的结果,不再重复修正。只有浏览器没做的才由列表补上(偏移为 0 时浏览器不锚定;浏览器不支持时)。跟随底部或跳转期间,列表在滚动容器上关掉浏览器的锚定,自己设置偏移。占位元素永远不会被选作锚点。测试台里能看到两边各做了多少次。
- 跟随状态只由用户的滚动改变(滚轮、触摸、键盘、拖动滚动条)。布局变化、新消息、屏幕键盘都不会开启或关闭它。视口变矮(键盘、地址栏)时,跟随中的列表保持在底部,否则保持当前看到的项不动。
- 跳转每帧重新瞄准,直到目标连续两帧不动(最多一秒),所以途中测量出的高度不会让它停偏。远距离的平滑滚动从目标附近开始。用户一滚动就取消跳转。
- 页面里只有渲染出来的项,所以浏览器的页内查找只能找到这些。
属性
| 名称 | 类型 | 默认值 | 说明 | 版本 |
|---|---|---|---|---|
items | T[] | (必填) | 按显示顺序排列的项。 | 未发布 |
renderItem | (item: T, index: number) => ReactNode | (必填) | 渲染一项,高度会被测量;保持这个函数稳定(useCallback)。 | 未发布 |
keyField | string | key | 存放稳定唯一 key 的字段,高度按 key 记住。 | 未发布 |
itemSize | number | (item: T, index: number) => number | 48 | 尚未测量的项的估计高度,单位 px。 | 未发布 |
overscan | number | 400 | 视口上下各多渲染的距离,单位 px。 | 未发布 |
anchor | start | end | start | end 停在底部,并在底部时跟随新项。 | 未发布 |
hasNewer | boolean | false | 最后一项不是最新的:不跟随,底部也不是实时的末尾。 | 未发布 |
initialKey | string | number | undefined | 首次显示的项,代替顶部(start)或底部(end)。 | 未发布 |
initialAlign | start | center | end | nearest | center | initialKey 的对齐方式。 | 未发布 |
endThreshold | number | 100 | 离底部多近仍算在底部,单位 px。 | 未发布 |
reachThreshold | number | 200 | 离边缘多近触发 onStartReached / onEndReached,单位 px。 | 未发布 |
onStartReached | () => void | undefined | 接近顶部:加载更早的项。每个首项只触发一次,往上加载后会重新生效。 | 未发布 |
onEndReached | () => void | undefined | 接近底部:加载更新的项。每个末项只触发一次。 | 未发布 |
onAtEndChange | (atEnd: boolean) => void | undefined | 是否在底部(用来显示「回到最新」按钮)。 | 未发布 |
onVisibleRangeChange | (range: VirtualListVisibleRange) => void | undefined | 视口内的项变化(不含 overscan)。 | 未发布 |
deferCorrectionsWhileTouching | boolean | false | 手指按住或惯性滚动期间先攒着高度修正,停下再应用(iOS 在写入滚动偏移时会中断惯性)。 | 未发布 |
header | ReactNode | undefined | 第一项上方、滚动容器内的内容。 | 未发布 |
footer | ReactNode | undefined | 最后一项下方、滚动容器内的内容。 | 未发布 |
itemClassName | string | undefined | 每项外层包裹元素的类名。 | 未发布 |
size | small | medium | medium | 滚动条粗细,同 ScrollArea。 | 未发布 |
trigger | hover | none | hover | 滚动条何时显示,同 ScrollArea。 | 未发布 |
每一项外面包一个带 data-key 和 data-index 的 div。列表也接受 className、style 和 aria-label。
VirtualList 方法
| 名称 | 类型 | 说明 | 版本 |
|---|---|---|---|
scrollToKey | (key, options?: { align?, smooth? }) => boolean | 把一项滚到可见处(align 默认 start)。key 未加载时返回 false。 | 未发布 |
scrollToIndex | (index, options?) => boolean | 同上,按下标。 | 未发布 |
scrollToStart | () => void | 回到第一项。 | 未发布 |
scrollToEnd | () => void | 到最后一项;end 列表重新开始跟随。 | 未发布 |
followEnd | () => void | 即使用户滚开了也跟随接下来的项,比如刚发送消息之后。 | 未发布 |
holdAnchor | () => void | 停止跟随并保持当前视图,在你自己引起布局变化之前调用(比如打开侧边面板)。 | 未发布 |
isAtEnd | () => boolean | 离底部不超过 endThreshold。 | 未发布 |
isFollowing | () => boolean | end 列表在新项到达或变高时会保持在底部。 | 未发布 |
getScrollElement | () => HTMLDivElement | null | 滚动容器。 | 未发布 |
getScrollStats | () => { written, accepted } | 诊断用:列表自己写入的滚动修正次数,以及接受浏览器滚动锚定、没有再写的次数。 | 未发布 |