ahooks源码之useVirtualList虚拟列表

前言

对于海量数据渲染起来很耗费性能,并且会造成页面卡顿,下面结合 ahooks 的 useVirtualList 源码,看一下如何实现一个虚拟列表。

结构

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

const getVisibleCount = (containerHeight: number, fromIndex: number) => {
    // 如果 itemHeightRef.current 是 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

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

const getDistanceTop = (index: number) => {
    // 如果 itemHeightRef.current 是 Number 类型,说明每项高度相同
    if (isNumber(itemHeightRef.current)) {
        const height = index * itemHeightRef.current;
        return height;
    }
    // 如果高度不同, itemHeightRef 应该是 function,通过 reduce 累加计算高度
    const height = list
        .slice(0, index)
        .reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]), 0);
    return height;
};

totalHeight

const totalHeight = useMemo(() => {
    // 如果每项等高,直接高度*数量
    if (isNumber(itemHeightRef.current)) {
        return list.length * itemHeightRef.current;
    }
    // 不等高再通过 reduce 累加计算高度
    return list.reduce(
        (sum, _, index) => sum + (itemHeightRef.current as ItemHeight<T>)(index, list[index]),
        0,
    );
}, [list]);

calculateRange

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

useUpdateEffect(() => {
    // 获取内部容器
    const wrapper = getTargetElement(wrapperTarget) as HTMLElement;
    if (wrapper) {
        // 添加样式
        Object.keys(wrapperStyle).forEach((key) => (wrapper.style[key] = wrapperStyle[key]));
    }
}, [wrapperStyle]);

只有在 wrapperStyle 变化时才触发,只有在重新计算的时候才改变了 wrapperStyle,主要作用是设置滚动条。

useEventListener 监听滚动事件

useEventListener(
    'scroll',
    (e) => {
        // 如果直接根据 index 跳转的,关闭跳转标志然后 return
        if (scrollTriggerByScrollToFunc.current) {
            scrollTriggerByScrollToFunc.current = false;
            return;
        }
        e.preventDefault();
        // 重新计算
        calculateRange();
    },
    {
        target: containerTarget,
    },
);

scrollTo 滚动跳转

const scrollTo = (index: number) => {
    // 获取外部容器
    const container = getTargetElement(containerTarget);
    if (container) {
        // 开启跳转标志
        scrollTriggerByScrollToFunc.current = true;
        // 设置滚动
        container.scrollTop = getDistanceTop(index);
        calculateRange();
    }
};

总结

可以看到主要实现就是通过监听滚动事件,计算滚动上去的高度 + margin 实现让滚动条滚动,计算出可显示的行数量加上缓冲区的数量,成为真正要渲染的列表。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值