前言
对于海量数据渲染起来很耗费性能,并且会造成页面卡顿,下面结合 ahooks 的 useVirtualList 源码,看一下如何实现一个虚拟列表。
结构
| 12
 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
| 12
 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
| 12
 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
| 12
 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
| 12
 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
| 12
 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
| 12
 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 监听滚动事件
| 12
 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,
 },
 );
 
 | 
| 12
 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 实现让滚动条滚动,计算出可显示的行数量加上缓冲区的数量,成为真正要渲染的列表。