跳到正文

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 时浏览器不锚定;浏览器不支持时)。跟随底部或跳转期间,列表在滚动容器上关掉浏览器的锚定,自己设置偏移。占位元素永远不会被选作锚点。测试台里能看到两边各做了多少次。
  • 跟随状态只由用户的滚动改变(滚轮、触摸、键盘、拖动滚动条)。布局变化、新消息、屏幕键盘都不会开启或关闭它。视口变矮(键盘、地址栏)时,跟随中的列表保持在底部,否则保持当前看到的项不动。
  • 跳转每帧重新瞄准,直到目标连续两帧不动(最多一秒),所以途中测量出的高度不会让它停偏。远距离的平滑滚动从目标附近开始。用户一滚动就取消跳转。
  • 页面里只有渲染出来的项,所以浏览器的页内查找只能找到这些。

属性

名称类型默认值说明版本
itemsT[](必填)按显示顺序排列的项。未发布
renderItem(item: T, index: number) => ReactNode(必填)渲染一项,高度会被测量;保持这个函数稳定(useCallback)。未发布
keyFieldstringkey存放稳定唯一 key 的字段,高度按 key 记住。未发布
itemSizenumber | (item: T, index: number) => number48尚未测量的项的估计高度,单位 px。未发布
overscannumber400视口上下各多渲染的距离,单位 px。未发布
anchorstart | endstartend 停在底部,并在底部时跟随新项。未发布
hasNewerbooleanfalse最后一项不是最新的:不跟随,底部也不是实时的末尾。未发布
initialKeystring | numberundefined首次显示的项,代替顶部(start)或底部(end)。未发布
initialAlignstart | center | end | nearestcenterinitialKey 的对齐方式。未发布
endThresholdnumber100离底部多近仍算在底部,单位 px。未发布
reachThresholdnumber200离边缘多近触发 onStartReached / onEndReached,单位 px。未发布
onStartReached() => voidundefined接近顶部:加载更早的项。每个首项只触发一次,往上加载后会重新生效。未发布
onEndReached() => voidundefined接近底部:加载更新的项。每个末项只触发一次。未发布
onAtEndChange(atEnd: boolean) => voidundefined是否在底部(用来显示「回到最新」按钮)。未发布
onVisibleRangeChange(range: VirtualListVisibleRange) => voidundefined视口内的项变化(不含 overscan)。未发布
deferCorrectionsWhileTouchingbooleanfalse手指按住或惯性滚动期间先攒着高度修正,停下再应用(iOS 在写入滚动偏移时会中断惯性)。未发布
headerReactNodeundefined第一项上方、滚动容器内的内容。未发布
footerReactNodeundefined最后一项下方、滚动容器内的内容。未发布
itemClassNamestringundefined每项外层包裹元素的类名。未发布
sizesmall | mediummedium滚动条粗细,同 ScrollArea。未发布
triggerhover | nonehover滚动条何时显示,同 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() => booleanend 列表在新项到达或变高时会保持在底部。未发布
getScrollElement() => HTMLDivElement | null滚动容器。未发布
getScrollStats() => { written, accepted }诊断用:列表自己写入的滚动修正次数,以及接受浏览器滚动锚定、没有再写的次数。未发布