【解读 ahooks 源码系列】 Scene 篇(三)

前言

很久没更新了,本文是 ahooks 源码(v3.7.4)系列的第十三篇——【解读 ahooks 源码系列】 Scene 篇(三)

往期文章:

本文主要解读 useVirtualListusePagination 的源码实现

useVirtualList

提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。

基本用法

渲染大量数据

官方在线 Demo

import React, { useMemo, useRef } from 'react';
import { useVirtualList } from 'ahooks';

export default () => {
  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,
    overscan: 10,
  });
  return (
    <>
      <div ref={containerRef} style={{ height: '300px', overflow: 'auto', border: '1px solid' }}>
        <div ref={wrapperRef}>
          {list.map((ele) => (
            <div
              style={{
                height: 52,
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                border: '1px solid #e8e8e8',
                marginBottom: 8,
              }}
              key={ele.index}
            >
              Row: {ele.data}
            </div>
          ))}
        </div>
      </div>
    </>
  );
};

核心实现

虚拟列表不直接显示和渲染所有列表项,而是渲染可视区域内的一部分列表元素,来解决海量数据渲染时产生的卡顿问题

useVirtualList 实现原理:监听外面容器的 scroll 和 size 事件,当发生变化时,触发计算逻辑。

关于虚拟列表的原理在这就不详细展开了,这里直接看代码,该 hook 传入参数有 list 列表 和 options 选项, options 选项包括:

interface Options<T> {
  // 外面容器,支持 DOM 节点或者 Ref 对象
  containerTarget: BasicTarget;
  // 内部容器,支持 DOM 节点或者 Ref 对象
  wrapperTarget: BasicTarget;
  // 行高度,静态高度可以直接写入像素值,动态高度可传入函数
  itemHeight: number | ItemHeight<T>;
  // 视区上、下额外展示的 DOM 节点数量
  overscan?: number;
}

useVirtualList 执行后的返回结果:

// 返回 [当前需要展示的列表内容, 快速滚动到指定 index]
return [targetList, useMemoizedFn(scrollTo)] as const;

一、监听容器的 scroll 和 size 事件

// 监听外面容器的 scroll 事件
useEventListener(
  'scroll',
  (e) => {
    // 滚动由 scrollTo 函数触发,则不处理
    if (scrollTriggerByScrollToFunc.current) {
      scrollTriggerByScrollToFunc.current = false;
      return;
    }
    e.preventDefault();
    calculateRange(); // 触发计算逻辑
  },
  {
    target: containerTarget,
  },
);

// 当外面容器的 size 发生变化时触发计算逻辑
useEffect(() => {
  if (!size?.width || !size?.height) {
    return;
  }
  calculateRange();
}, [size?.width, size?.height, list]);

二、各个辅助方法的实现

getVisibleCount:计算可视区域内的列表项数量

const itemHeightRef = useLatest(itemHeight);

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:获取外面容器可视范围外上面的偏移(计算上面偏移能容下多少 DOM 节点)

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) => {
  // 列表项是静态高度
  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:总高度

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. 确定可视区能显示的列表项数量 visibleCount
  2. 向上滚动的当前位置开始下标与结束下标,确定显示的列表范围
  3. 确定每个元素的 top,当向上滑动时,确定当前的位置与最后元素的位置索引,根据当前位置与最后元素位置,渲染可视区域

计算逻辑:

  1. 根据外面容器的 scrollTop 属性计算滚过多少列表项个数,记为 offset
  2. 计算可视区的列表数量,记为 visibleCount
  3. 根据 overscan(视区上、下额外展示的 DOM 节点数量)计算出开始下标 start 和结束下标 end
  4. 设置内部容器总高度 = totalHeight(总高度) – offsetTop(开始下标到上方高度)
  5. 设置内部容器 marginTop 为 offsetTop(开始下标 start 获取其到最上方的距离)
  6. 使用开始下标 start 和结束下标 end 区间来更新列表
  • overscan 作用可以理解为缓冲,在可视区外额外展示的 DOM 节点数量,减少出现滚动过快导致白屏闪屏的现象发生

四、另外提供了 scrollTo 的方法

scrollTo 函数实现原理:传入滚动要滚动到列表项 index 索引,然后把前面所有项的高度累加,这个累加值就是该 index 距离顶部的距离,设置为外面容器的 scrollTop 属性值,触发重新计算逻辑

// 快速滚动到指定 index
const scrollTo = (index: number) => {
  const container = getTargetElement(containerTarget);
  if (container) {
    scrollTriggerByScrollToFunc.current = true;
    container.scrollTop = getDistanceTop(index); // 计算该 index 距离顶部的高度
    calculateRange(); // 触发计算逻辑
  }
};

完整源码

usePagination

usePagination 基于 useRequest 实现,封装了常见的分页逻辑。与 useRequest 不同的点有以下几点:

  1. service 的第一个参数为 { current: number, pageSize: number }
  2. service 返回的数据结构为 { total: number, list: Item[] }
  3. 会额外返回 pagination 字段,包含所有分页信息,及操作分页的函数。
  4. refreshDeps 变化,会重置 current 到第一页,并重新发起请求,一般你可以把 pagination 依赖的条件放这里

基本用法

默认用法与 useRequest 一致,但会多返回一个 pagination 参数,包含所有分页信息,及操作分页的函数。

官方在线 Demo

import { usePagination } from 'ahooks';
import { Pagination } from 'antd';
import Mock from 'mockjs';
import React from 'react';

interface UserListItem {
  id: string;
  name: string;
  gender: 'male' | 'female';
  email: string;
  disabled: boolean;
}

const userList = (current: number, pageSize: number) =>
  Mock.mock({
    total: 55,
    [`list|${pageSize}`]: [
      {
        id: '@guid',
        name: '@name',
        'gender|1': ['male', 'female'],
        email: '@email',
        disabled: false,
      },
    ],
  });

async function getUserList(params: {
  current: number;
  pageSize: number;
}): Promise<{ total: number; list: UserListItem[] }> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(userList(params.current, params.pageSize));
    }, 1000);
  });
}

export default () => {
  const { data, loading, pagination } = usePagination(getUserList);
  return (
    <div>
      {loading ? (
        <p>loading</p>
      ) : (
        <ul>
          {data?.list?.map((item) => (
            <li key={item.email}>
              {item.name} - {item.email}
            </li>
          ))}
        </ul>
      )}
      <Pagination
        current={pagination.current}
        pageSize={pagination.pageSize}
        total={data?.total}
        onChange={pagination.onChange}
        onShowSizeChange={pagination.onChange}
        showQuickJumper
        showSizeChanger
        style={{ marginTop: 16, textAlign: 'right' }}
      />
    </div>
  );
};

核心实现

usePagination 是基于 useRequest 实现的,它规定了 useRequest 第一个参数 service 的 TS 参数类型和 service 的返回结果类型。

// 响应类型
type Data = { total: number; list: any[] };
// 参数类型
type Params = [{ current: number; pageSize: number; [key: string]: any }, ...any[]];

type Service<TData extends Data, TParams extends Params> = (
  ...args: TParams
) => Promise<TData>;

const usePagination = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: PaginationOptions<TData, TParams> = {},
) => {
  // 默认分页数量为10,初次请求时的页数为1
  const { defaultPageSize = 10, defaultCurrent = 1, ...rest } = options;
  // 请求接口
  const result = useRequest(service, {
    defaultParams: [{ current: defaultCurrent, pageSize: defaultPageSize }],
    // refreshDeps 变化
    refreshDepsAction: () => {
      // refreshDeps 依赖变更后重置当前页数为1
      // eslint-disable-next-line @typescript-eslint/no-use-before-define
      changeCurrent(1);
    },
    ...rest, // useRequest 支持的其余参数
  });
  // ...
}

核心逻辑在 onChange 方法,当修改页码或每页条数时会触发该方法,在该方法会重新计算参数然后重新发起请求。

const usePagination = <TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  options: PaginationOptions<TData, TParams> = {},
) => {
  // ...

  /**
   * 页码或 pageSize 改变的回调
   * @param c currentPage 当前页码
   * @param p pageSize 每页条数
   */
  const onChange = (c: number, p: number) => {
    let toCurrent = c <= 0 ? 1 : c;
    const toPageSize = p <= 0 ? 1 : p;
    // 计算总页数
    const tempTotalPage = Math.ceil(total / toPageSize);
    // 当前页数大于总页数,则取最大值
    if (toCurrent > tempTotalPage) {
      toCurrent = Math.max(1, tempTotalPage); // 当前页数 Math.max 用1兜底异常数据
    }

    const [oldPaginationParams = {}, ...restParams] = result.params || [];
    // 使用最新的 current 和 pageSize 参数去请求
    result.run(
      {
        ...oldPaginationParams,
        current: toCurrent,
        pageSize: toPageSize,
      },
      ...restParams,
    );
  };

  // 改变当前页数
  const changeCurrent = (c: number) => {
    onChange(c, pageSize);
  };

  // 改变每页条数
  const changePageSize = (p: number) => {
    onChange(current, p);
  };

  return {
    ...result,
    // 分页信息与操作分页方法都集成到 pagination 对象
    pagination: {
      current,
      pageSize,
      total,
      totalPage,
      onChange: useMemoizedFn(onChange),
      changeCurrent: useMemoizedFn(changeCurrent),
      changePageSize: useMemoizedFn(changePageSize),
    },
  } as PaginationResult<TData, TParams>;
}

完整源码

原文链接:https://juejin.cn/post/7342393333623422991 作者:JackySummer

(0)
上一篇 2024年3月5日 上午10:06
下一篇 2024年3月5日 上午10:17

相关推荐

发表评论

登录后才能评论