虚拟列表原理及其实现
虚拟列表(Virtual List)是按需显示的一种技术实现,即只对可视区域中的列表内容进行渲染,区域外的列表数据不渲染或者部分渲染。
长列表数据场景下有些交互不宜使用分页方式加载列表数据,如果渲染全部数据非常消耗性能导致卡顿(主要是 Recalculate Style 和 Layout 阶段的性能消耗)。使用虚拟列表实现按需渲染能极大减少渲染内容,极高的提升渲染性能。
使用姿势
这里 rc-virtual-list
为例,详细使用查看 文档 及其 示例 。
const MyItem: React.FC<Item> = ({ src, content }, ref) => { return ( <div ref={ref}> <img src={src} /> <p>{content}</p> </div> ); }; const ForwardMyItem = React.forwardRef(MyItem); const Demo = () => { return ( <React.StrictMode> <div> <List ref={listRef} data={data} height={500} itemHeight={30} // 用于兜底计算的 Item 高度 itemKey="id" onScroll={onScroll} > {item => <ForwardMyItem {...item} />} </List> </div> </React.StrictMode> ); };
注意点
- 如果子项存在动态高度或者高度不统一的情况,需要使用
React.forwardRef
转发ref
给子 DOM 元素。 - 列表项之间不要存在上下间距(
margin-top
、margin-bottom
)。
以上两点如果没有做到,调用组件的 scrollTo(scrollConfig)
方法进行滚动时都会导致滚动位置异常,具体原因可以在后面内容中找到。
实现
这里仅介绍垂直方向的虚拟列表实现,且为了清晰实现思路,示例中源码均做了删减或者改造。
虚拟列表布局
下面是 rc-virtual-list 生成的虚拟列表 DOM 结构:
<div class="rc-virtual-list-holder"> <div class="scroll-list"> <div class="rc-virtual-list-holder-inner" > <div class="list-item">2</div> <div class="list-item">3</div> <div class="list-item">4</div> <div class="list-item">5</div> </div> </div> </div>
可视区域容器(红色):即
div.rc-virtual-list-holder
,它的作用是限定可视区域,通过overflow-y: hidden
使得超出可视区域的列表项不可见(灰色区域)。同时它也是滚动容器。
滚动内容(绿色):即
div.scroll-list
(我自定义的 className)。它的高度height
= 可视区域内的列表项真实高度总和 + 可视区域外列表项的临时高度总和,即为滚动容器的scrollHeight
。- 绿色区域内的虚线矩形表示可视区域外的列表项,因为还没有渲染所以高度只是一个临时兜底高度。实线矩形表示当前渲染的列表项,它们的高度是真实高度。
- 设置临时高度保证了相对准确的内容高度,从而使得可视区容器能够滚动,并且保证滚动条的正确位置。
展示容器(蓝色):即
div.rc-virtual-list-holder-inner
,它的作用是用来装载可视区域内需要真实渲染的列表项。由于真实渲染的列表项(如上图的 2~5)总高度往往不会恰好等于可视区高度,所以展示容器的的高度通常会大于可视区域(蓝色矩形大于红色矩形)。
实现虚拟滚动
保证滚动内容高度大于滚动容器的前提下(即 scrollHeight
> clientHeight
),只需要改变滚动容器的 scrollTop
属性即可实现内容的上下滚动。下面是简化后的用于手动触发虚拟滚动的 syncScrollTop
方法:
const [scrollTop, setScrollTop] = useState(0); function syncScrollTop(newTop: number) { setScrollTop(origin => { // 改变滚动容器的 scrollTop 属性 componentRef.current.scrollTop = newTop; return newTop; }); }
当用户触发滚动事件时(如滑动鼠标滚轮),由于虚拟列表只展示部分 Item,且滚动后才能计算展示内容,快速滚动将出现白屏闪烁,所以需要在可视区域外额外渲染一定数量的 Item 作为滚动缓冲单元,额外渲染的缓冲单元越多,越不容易产生滚动白屏,但过多又失去了按需显示的意义,所以如果直接快速拖拽滚动条缓冲单元也不管用。
而 rc-virtual-list
的做法是直接阻止用户滚动,通过劫持用户事件并设置 scrollTop
的值来实现滚动。这样做的好处是所有滚动都通过虚拟滚动方法来控制,保证可视区域的 Item 渲染后再控制 scrollTop
跳至滚动位置。因此也不再需要额外渲染缓冲条目。为了避免快速拖拽滚动导致的白屏, rc-virtual-list
把原生的滚动条也干掉了,实现了一个假的滚动条代替,通过 syncScrollTop
实现拖拽滚动逻辑:
<ScrollBar ref={scrollBarRef} ... onScroll={(newScrollTop: number) => syncScrollTop(newScrollTop)} />
由于 onScroll
事件是只能在滚动发生后才会触发的 UIEvent
,因而无法在 onScroll
里调用 preventDefault
,所以选择劫持滚动容器上 onScroll
事件的前置事件 onWheel
,在 onWheel
中改变 scrollTop
。
function onWheel({ deltaY }: WheelEvent) { event.preventDefault(); // 实际代码使用了 requestAnimationFrame 将一帧内的滚动事件合并。 componentRef.current.scrollTop += deltaY; } useLayoutEffect(() => { componentRef.current.addEventListener('wheel', onWheel); return () => { componentRef.current.removeEventListener('wheel', onWheel); }; }, [useVirtual]);
计算可视区渲染数据
继续拿出这张图:
只要能计算出 startIndex
和 endIndex
即可得到需要渲染的数据:
const visibleData = data.slice(startIndex, endIndex + 1);
将 visibleData
渲染后填充到展示容器(蓝色矩形),设置展示容器 transform
的 translateY = startOffset
即可保证按需渲染的列表项展示在可视区域内。
const visibleData = data.slice(startIndex, endIndex + 1); const startOffset = getTotalHeight(0, startIndex - 1); const listChildren = useChildren(visibleData, renderChildrenFn); <Component className="rc-virtual-list-holder" style={componentStyle} ref={componentRef} > <Filler height={scrollHeight} offset={startOffset} ref={fillerInnerRef} > {listChildren} </Filler> </Component>
假设列表中所有 Item 的高度都已知的情况下(或假设每个 Item 的高度都一致), startOffset
则为 0 ~ startIndex - 1
的高度总和。遍历所有 Item ,当累计高度 >= 滚动容器的 scrollTop
时,当前 index
即为 startIndex
。剩下的 endIndex
、 startOffset
、 scrollHeight
都可以轻易的计算出来,下面是简化后的代码:
const { scrollHeight, startIndex, endIndex, startOffset } = useMemo(() => { let itemTop = 0; let startIndex: number; let startOffset: number; let endIndex: number; const dataLen = data.length; for (let i = 0; i < dataLen; i += 1) { const item = data[i]; // 假设已经根据 key 缓存了 Item 的高度。 const key = getKey(item); const cacheHeight = heights.get(key) ?? itemHeight; // 当前元素下边框到内容顶部的距离 const currentItemBottom = itemTop + cacheHeight; // 得到 startIndex if (currentItemBottom >= scrollTop && startIndex === undefined) { startIndex = i; startOffset = itemTop; } // 得到 endIndex,内容需要超出可视区域的下边框 if (currentItemBottom > scrollTop + height && endIndex === undefined) { endIndex = i; } // 计算出 scrollHeight itemTop = currentItemBottom; } return { scrollHeight: itemTop, startIndex, endIndex, startOffset, }; }, [scrollTop, data, height]);
如果列表所有 Item 的高度都相等且固定不变时, heights.get(key) = itemHeight
,完成以上就已经基本实现了虚拟列表的功能。
高度不一致
但实际情况是 Item 的高度通常是不一致的,这时候就需要收集 Item 的高度。实际做法是 Item 挂载时根据 key 缓存 Item 的实例,顺便收集 Item 的高度。如果不能获取到 DOM 元素则不能收集到实际高度,只能使用 itemHeight
进行兜底计算,这也是 注意点-1 中需要使用 React.forwardRef
转发 ref
的原因。
const instanceRef = useRef(new Map<React.Key, HTMLElement>()); function setInstanceRef(item: T, instance: HTMLElement) { const key = getKey(item); const origin = instanceRef.current.get(key); if (instance) { instanceRef.current.set(key, instance); collectHeight(key, instance); // 收集高度 } else { instanceRef.current.delete(key); } } const listChildren = useChildren(visibleData, renderChildrenFn,setInstanceRef);
每次执行 collectHeight
收集高度时意味着有 Item 挂载,展示容器(蓝色矩形)的高度会发生改变,此时就需要重新计算 startOffset
使得展示容器偏移到正确的位置,渲染数据也要重新计算。 rc-virtual-list
的做法是每次执行 collectHeight
都会更新 heightUpdatedMark += 1
, heightUpdatedMark
变更则会引发 startOffset
等变量的重新计算。
const [setInstanceRef, collectHeight, heights, heightUpdatedMark] = useHeights(getKey); const { scrollHeight, startIndex, endIndex, startOffset } = useMemo(() => { // ... const cacheHeight = heights.get(key) ?? itemHeight; // ... }, [scrollTop, data, height, heightUpdatedMark]);
动态高度
除了高度不一致,还存在动态高度的情况,比如 Item 中存在一个<img />
元素,图片加载会发起网络请求,此时就不能保证 Item 挂载时图片是加载好了,也就不能保证收集的高度是准确的。
const MyItem: React.FC<Item> = ({ src, content }, ref) => { return ( <div ref={ref}> <img src={src} /> <p>{content}</p> </div> ); };
其实动态高度与高度不统一的问题可以一起解决,办法是 Item 挂载时缓存实例至 instanceRef
,使用 ResizeObserver
监视展示容器的元素大小变更。
const Filler = React.forwardRef((props: FillerProps, ref: React.Ref<HTMLDivElement>) => { // 监视元素大小变更 const onResize = ({ offsetHeight }) => { if (offsetHeight && props.collectHeight) { props.collectHeight(); // 收集高度 } } // ... return ( <div style={outerStyle}> <ResizeObserver onResize={onResize}> <div style={innerStyle} className="rc-virtual-list-holder-inner" ref={ref}> {props.children} </div> </ResizeObserver> </div> ); });
变更后执行 collectHeight
方法批量收集高度,核心逻辑就是遍历 instanceRef
中已缓存的 Item 实例,收集元素的 offsetHeight
属性作为 Item 的高度并缓存下来。由于 offsetHeight
并不包含 margin
,所以这就是 注意点-2 中存在外边距会导致滚动位置异常的原因。
由于 collectHeight
方法是一个批量收集动作,为避免频繁的重复收集,通过创建微任务对 currentId
进行比对,仅事件循环中最近一次方法调用时收集。
const instanceRef = useRef(new Map<React.Key, HTMLElement>()); const heightsRef = useRef(new CacheMap()); const heightUpdateIdRef = useRef(0); function collectHeight() { heightUpdateIdRef.current += 1; const currentId = heightUpdateIdRef.current; Promise.resolve().then(() => { if (currentId !== heightUpdateIdRef.current) return; instanceRef.current.forEach((element, key) => { if (element && element.offsetParent) { const htmlElement = findDOMNode<HTMLElement>(element); const { offsetHeight } = htmlElement; if (heightsRef.current.get(key) !== offsetHeight) { heightsRef.current.set(key, htmlElement.offsetHeight); } } }); }); // ... }
滚动到指定 Item
rc-virtual-list
提供了一个 scrollTo
方法用来滚动到指定的位置。
type ScrollConfig = | { index: number; align?: ScrollAlign; offset?: number; } | { key: React.Key; align?: ScrollAlign; offset?: number; }; type scrollTo = (arg: number | ScrollConfig) => void
number
即指定的 scrollTop
。当传入 ScrollConfig
时,本质还是找到目标 Item 的 scrollTop
。唯一值得注意的是如果目标 Item 未曾渲染则计算时拿不到缓存高度,只能使用 itemHeight
作为兜底高度进行计算。可以使用 needCollectHeight = true
标记这种情况,滚动后收集高度。而滚动也会重试三次以避免动态高度导致的抖动。省略大量代码:
// useScrollTo.tsx const syncScroll = (times: number, targetAlign?: 'top' | 'bottom') => { if (times < 0) return; // ... let stackTop = 0; let itemTop = 0; // 目标 Item 上边框距离顶部的距离 let itemBottom = 0; // 目标 Item 下边框距离顶部的距离 const maxLen = Math.min(data.length, index); for (let i = 0; i <= maxLen; i += 1) { const key = getKey(data[i]); itemTop = stackTop; const cacheHeight = heights.get(key); itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); stackTop = itemBottom; if (i === index && cacheHeight === undefined) { needCollectHeight = true; } } // ... 根据不同的对齐方式计算出 targetTop syncScrollTop(targetTop); // 使用 requestAnimationFrame 合并滚动 scrollRef.current = raf(() => { if (needCollectHeight) { collectHeight(); } syncScroll(times - 1, newTargetAlign); }); }; // 重试三次 syncScroll(3);
参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论