『手写ahooks系列之useVirturalList』 一个hook如何解决虚拟列表?

最近在学习ahooks的源码,打算写些文章记录并分享,讲解ahooks中的一些方法是如何实现的,并实现一个mini版本。如果你对该文章或者系列有任何建议,欢迎打出来我们一起探讨。

前言

在本篇文章中,我们将通过注释逐行讲解 ahooks 中的 useVirtualList 方法的源码。useVirtualList 是一个非常实用的自定义 hooks,它可以帮助我们优化长列表的性能,通过虚拟滚动的方式,只渲染可视区域内的列表项,而不必渲染整个列表。

源码解读

虚拟列表的原理,其实2024年了现在大家也都比较清楚了,无非就是根据当前可视区域的大小和位置,动态地渲染列表中的部分内容而非一次性渲染整个列表

那么ahooks是如何把上述行为封装到一个hooks里的呢?

我们来看一下官方示例中的基础用法

ts 复制代码
  const containerRef = useRef(null);
  const wrapperRef = useRef(null);
  const originalList = useMemo(() => Array.from(Array(99999).keys()), []);
  const [list] = useVirtualList(originalList, {
    containerTarget: containerRef,
    wrapperTarget: wrapperRef,
    itemHeight: 60, // 下方div的height与marginBottom总和
    overscan: 10,
  });
  return (
    <>
      <div ref={containerRef} style={{ height: '300px', overflow: 'auto', border: '1px solid' }}>
        <div ref={wrapperRef}>
          {list.map((ele) => (
            <div key={ele.index} style={{ height: 52, marginBottom: 8 }}>
              Row: {ele.data}
            </div>
          ))}
        </div>
      </div>
    </>
  );

那么我们已经可以大致推测 useVirtualList 是如何实现的

  1. 根据容器的尺寸和滚动位置计算可见区域的起始索引和结束索引 :通过传入的 containerTargetwrapperTargetuseVirtualList 可以获取到容器元素和列表包裹元素的引用。然后根据容器的尺寸和滚动位置,计算出当前可见区域的起始索引和结束索引。
  2. 动态生成可见区域的列表项 :根据计算得到的可见区域的起始索引和结束索引,从原始列表数据中截取对应的数据,然后将这些数据渲染成列表项。在提供的示例中,通过遍历 list 数组,并将每个列表项渲染成一个 <div> 元素,其中 ele.data 包含列表项的数据,ele.index 包含列表项的索引。
  3. 利用预渲染区域进行性能优化 :在 useVirtualList 中,可以通过设置 overscan 参数来控制预渲染的区域大小。预渲染区域可以在用户滚动时提前渲染,以提升用户体验。比如说,设置overscan为5,就可以在距离顶部/底部还有5个元素的时候就开始提前进行渲染而不需要等待白屏了。
  4. 监听容器的滚动事件并及时更新列表内容 :通过 useEventListener 自定义 hook 监听容器的滚动事件,当滚动事件触发时,调用相应的更新函数,动态地更新可见区域的列表内容。

下面,是整体源码的解读,如果你想看简单的实现,也可以直接跳到动手实现的部分:

ts 复制代码
import { useEffect, useMemo, useState, useRef } from 'react';
import type { CSSProperties } from 'react';
import useEventListener from '../useEventListener'; // 导入自定义 hook,用于监听事件
import useLatest from '../useLatest'; // 导入自定义 hook,用于获取最新的数据
import useMemoizedFn from '../useMemoizedFn'; // 导入自定义 hook,用于记忆函数
import useSize from '../useSize'; // 导入自定义 hook,用于获取元素大小
import { getTargetElement } from '../utils/domTarget'; // 导入工具函数,用于获取目标元素
import type { BasicTarget } from '../utils/domTarget'; // 导入类型声明,用于目标元素的基本类型
import { isNumber } from '../utils'; // 导入工具函数,用于检查是否为数字
import useUpdateEffect from '../useUpdateEffect'; // 导入自定义 hook,用于在依赖更新后执行副作用

type ItemHeight<T> = (index: number, data: T) => number; // 定义列表项高度类型

export interface Options<T> {
  containerTarget: BasicTarget; // 容器元素的目标
  wrapperTarget: BasicTarget; // 列表包裹元素的目标
  itemHeight: number | ItemHeight<T>; // 列表项高度或计算函数
  overscan?: number; // 为了减少js计算带来的白屏的影响,设置预渲染元素的数量,默认为5
}

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); // 用于标记是否由 scrollTo 函数触发的滚动事件

  const [targetList, setTargetList] = useState<{ index: number; data: T }[]>([]); // 可见列表项数据的状态
  const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({}); // 列表包裹元素的样式状态

  // 计算可见列表项数量
  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; // 返回可见数量
  };

  // 计算偏移量
  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; // 返回偏移量
  };

  // 获取上部高度
  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;
  };

  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]); // 当列表变化时重新计算

  // 计算可见区域的列表项
  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(() => {
    const wrapper = getTargetElement(wrapperTarget) as HTMLElement; // 获取列表包裹元素
    if (wrapper) {
      Object.keys(wrapperStyle).forEach((key) => (wrapper.style[key] = wrapperStyle[key])); // 更新样式
    }
  }, [wrapperStyle]); // 当样式变化时重新执行

  // 当容器大小或列表变化时,重新计算可见区域的列表项
  useEffect(() => {
    if (!size?.width || !size?.height) {
      return;
    }
    calculateRange();
  }, [size?.width, size?.height, list]); // 当容器大小或列表变化时重新执行

  // 监听容器滚动事件
  useEventListener(
    'scroll',
    (e) => {
      if (scrollTriggerByScrollToFunc.current) { // 如果是 scrollTo 函数触发的滚动事件
        scrollTriggerByScrollToFunc.current = false;
        return;
      }
      e.preventDefault(); // 阻止默认滚动行为
      calculateRange(); // 计算可见区域的列表项
    },
    {
      target: containerTarget, // 监听容器元素
    },
  );

  // 滚动到指定索引位置的列表项
  const scrollTo = (index: number) => {
    const container = getTargetElement(containerTarget); // 获取容器元素
    if (container) {
      scrollTriggerByScrollToFunc.current = true; // 标记为 scrollTo 函数触发的滚动事件
      container.scrollTop = getDistanceTop(index); // 滚动到指定位置
      calculateRange(); // 计算可见区域的列表项
    }
  };

  return [targetList, useMemoizedFn(scrollTo)] as const; // 返回可见列表项数据和记忆化的 scrollTo 函数
};

export default useVirtualList;

动手实现

在大概了解了 useVirtualList 的源码之后,我们也可以尝试着自己不引入任何依赖,手动去实现一个mini版本。

我们只需要专注于计算可见区域的列表,返回当前的列表这一点即可,故不考虑动态高度之类的场景以得到简化的代码。

整理一下思路大概如下:

  1. 监听container滚动,并根据当前的滚动位置,以及overscan的预加载量计算可见的列表item
  2. 更新可见的list
  3. 根据传入的list和itemHeight去更新wrapper的高度以及上边距

实现的mini版本大概如下所示(源码仓库):

ts 复制代码
import { CSSProperties, useEffect, useMemo, useState } from "react";

// 定义虚拟列表的参数接口
interface VirtualListOptions {
  // 容器的引用
  containerTarget: React.MutableRefObject<HTMLElement | null>;
  // 包裹列表项的引用
  wrapperTarget: React.MutableRefObject<HTMLElement | null>;
  // 单个列表项的高度
  itemHeight: number;
  // 预加载数量
  overscan?: number;
}

// 定义虚拟列表项的接口
interface VirtualListItem<T> {
  // 在原始列表中的索引
  index: number;
  // 对应的数据
  data: T;
}

export function useVirtualList<T>(originalList: T[], options: VirtualListOptions): [VirtualListItem<T>[]] {
  const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;
  const [visibleList, setVisibleList] = useState<VirtualListItem<T>[]>(
    originalList.slice(0, 10).map((data, index) => ({
      index,
      data,
    })) || []
  );
  const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({}); // 列表包裹元素的样式状态

  const totalHeight = useMemo(() => originalList.length * itemHeight, [originalList]); // wrapper总高度
  const getOffset = (scrollTop: number) => Math.floor(scrollTop / itemHeight) + 1;
  const getDistanceTop = (index: number) => index * itemHeight;
  const getVisibleCount = (containerHeight: number) => Math.ceil(containerHeight / itemHeight);

  // 核心代码
  const updateListAndStyle = () => {
    if (containerTarget.current) {
      // 1. 监听container滚动,并根据当前的滚动位置,以及overscan的预加载量计算可见的列表item
      const { scrollTop, clientHeight } = containerTarget.current; // 获取容器滚动位置和高度

      const offset = getOffset(scrollTop); // 获取偏移量
      const visibleCount = getVisibleCount(clientHeight); // 获取可见列表项数量
      const start = Math.max(0, offset - overscan); // 结合overscan计算起始index
      const end = Math.min(originalList.length, offset + visibleCount + overscan); // 结合overscan计算结束index

      // 2. 根据传入的list和itemHeight去更新wrapper的高度以及上边距
      const offsetTop = getDistanceTop(start); // 获取上部高度
      setWrapperStyle({
        height: totalHeight - offsetTop + "px", // 设置高度
        marginTop: offsetTop + "px", // 设置上边距
      });

      // 3. 更新可见列表
      setVisibleList(
        originalList.slice(start, end).map((ele, index) => ({
          data: ele,
          index: index + start,
        }))
      );
    }
  };

  useEffect(() => {
    if (!containerTarget.current || !wrapperTarget.current) return;
    const container = containerTarget.current;
    container.addEventListener("scroll", updateListAndStyle);
    return () => container.removeEventListener("scroll", updateListAndStyle);
  }, [containerTarget, wrapperTarget, originalList, itemHeight]);

  useEffect(() => updateListAndStyle, []); // 挂载后更新一次,确保高度正确

  useEffect(() => {
    if (wrapperTarget.current) {
      // @ts-expect-error object key typical error
      Object.keys(wrapperStyle).forEach((key) => (wrapperTarget.current!.style[key] = wrapperStyle[key])); // 更新样式
    }
  }, [wrapperStyle]);

  // 返回当前可见的列表项
  return [visibleList];
}

效果预览:

总结

通过分析源码和示例,我们了解了虚拟列表的工作原理,以及如何利用预渲染区域进行性能优化。

在源码解读部分,我们逐步分析了 useVirtualList 方法的实现细节,包括计算可见区域的列表项、动态生成列表项、利用预渲染区域进行性能优化以及监听容器的滚动事件等。此外,我们还尝试手动实现了一个简化版的虚拟列表功能,通过自定义 Hook 来实现相似的功能。

虚拟列表的核心无外乎是计算可视区域的列表项,这是一切的基础。记住这点就好啦。

如果这篇文章对你有帮助的话,还请你不吝小手点一个免费的赞,这会给我很大的鼓励,谢谢你!

相关推荐
NiNg_1_23416 分钟前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河18 分钟前
CSS总结
前端·css
NiNg_1_23418 分钟前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦19 分钟前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
BigYe程普40 分钟前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
神之王楠1 小时前
如何通过js加载css和html
javascript·css·html
余生H1 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍1 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai1 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默1 小时前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch