前言
对于海量数据渲染起来很耗费性能,并且会造成页面卡顿,下面结合 ahooks 的 useVirtualList 源码,看一下如何实现一个虚拟列表。
结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const useVirtualList = <T = any>(list: T[], options: Options<T>) => { const {containerTarget, wrapperTarget, itemHeight, overscan = 5} = options; const itemHeightRef = useLatest(itemHeight); const size = useSize(containerTarget); const scrollTriggerByScrollToFunc = useRef(false); const [targetList, setTargetList] = useState<{ index: number; data: T }[]>([]); const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({}); const getVisibleCount = (containerHeight: number, fromIndex: number) => {}; const getOffset = (scrollTop: number) => {}; const getDistanceTop = (index: number) => {}; const totalHeight = useMemo(() => {}, [list]); const calculateRange = () => {} useUpdateEffect(() => {}, [wrapperStyle]);
useEffect(() => {}, [size?.width, size?.height, list]);
useEventListener(); const scrollTo = (index: number) => {};
return [targetList, useMemoizedFn(scrollTo)] as const; };
|
先来看下每个方法是做什么用的,后面再详细看实现
itemHeightRef:获取最新行高
size: 外部容器的宽高
scrollTriggerByScrollToFunc:是否使用跳转(在跳转时会用到)
targetList:真正需要渲染的列表
wrapperStyle:样式信息
getVisibleCount:计算可见的行数量
getOffset:计算滚动上去了多少项
getDistanceTop:根据index获取它上部的高度
totalHeight:总高度
calculateRange:计算并设置真正要渲染的列表
scrollTo:根据 index 跳转
其中最重要的就是 calculateRange 方法。
getVisibleCount
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const getVisibleCount = (containerHeight: number, fromIndex: number) => { if (isNumber(itemHeightRef.current)) { return Math.ceil(containerHeight / itemHeightRef.current); } let sum = 0; let endIndex = 0; for (let i = fromIndex; i < list.length; i++) { const height = itemHeightRef.current(i, list[i]); sum += height; endIndex = i; if (sum >= containerHeight) { break; } } return endIndex - fromIndex; };
|
getOffset
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const getOffset = (scrollTop: number) => { if (isNumber(itemHeightRef.current)) { return Math.floor(scrollTop / itemHeightRef.current) + 1; } let sum = 0; let offset = 0; for (let i = 0; i < list.length; i++) { const height = itemHeightRef.current(i, list[i]); sum += height; if (sum >= scrollTop) { offset = i; break; } } return offset + 1; };
|
getDistanceTop
1 2 3 4 5 6 7 8 9 10 11 12
| const getDistanceTop = (index: number) => { if (isNumber(itemHeightRef.current)) { const height = index * itemHeightRef.current; return height; } const height = list .slice(0, index) .reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]), 0); return height; };
|
totalHeight
1 2 3 4 5 6 7 8 9 10 11
| const totalHeight = useMemo(() => { if (isNumber(itemHeightRef.current)) { return list.length * itemHeightRef.current; } return list.reduce( (sum, _, index) => sum + (itemHeightRef.current as ItemHeight<T>)(index, list[index]), 0, ); }, [list]);
|
calculateRange
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const calculateRange = () => { const container = getTargetElement(containerTarget);
if (container) { const {scrollTop, clientHeight} = container; const offset = getOffset(scrollTop); const visibleCount = getVisibleCount(clientHeight, offset); const start = Math.max(0, offset - overscan); const end = Math.min(list.length, offset + visibleCount + overscan);
const offsetTop = getDistanceTop(start);
setWrapperStyle({ height: totalHeight - offsetTop + 'px', marginTop: offsetTop + 'px', }); setTargetList( list.slice(start, end).map((ele, index) => ({ data: ele, index: index + start, })), ); } };
|
计算的核心代码,通过该方法来计算真正要渲染的列表并让滚动条滚动。
useUpdateEffect
1 2 3 4 5 6 7 8
| useUpdateEffect(() => { const wrapper = getTargetElement(wrapperTarget) as HTMLElement; if (wrapper) { Object.keys(wrapperStyle).forEach((key) => (wrapper.style[key] = wrapperStyle[key])); } }, [wrapperStyle]);
|
只有在 wrapperStyle 变化时才触发,只有在重新计算的时候才改变了 wrapperStyle,主要作用是设置滚动条。
useEventListener 监听滚动事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| useEventListener( 'scroll', (e) => { if (scrollTriggerByScrollToFunc.current) { scrollTriggerByScrollToFunc.current = false; return; } e.preventDefault(); calculateRange(); }, { target: containerTarget, }, );
|
1 2 3 4 5 6 7 8 9 10 11
| const scrollTo = (index: number) => { const container = getTargetElement(containerTarget); if (container) { scrollTriggerByScrollToFunc.current = true; container.scrollTop = getDistanceTop(index); calculateRange(); } };
|
总结
可以看到主要实现就是通过监听滚动事件,计算滚动上去的高度 + margin 实现让滚动条滚动,计算出可显示的行数量加上缓冲区的数量,成为真正要渲染的列表。