『手写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 来实现相似的功能。

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

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

相关推荐
树上有只程序猿19 分钟前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
QTX187301 小时前
JavaScript 中的原型链与继承
开发语言·javascript·原型模式
黄毛火烧雪下1 小时前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox1 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞1 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行1 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758101 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周1 小时前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队2 小时前
Vue自定义指令最佳实践教程
前端·vue.js