ahooks 源码解读系列 - 6

783 阅读3分钟

这个系列是将 ahooks 里面的所有 hook 源码都进行解读,通过解读 ahooks 的源码来熟悉自定义 hook 的写法,提高自己写自定义 hook 的能力,希望能够对大家有所帮助。

为了和代码原始注释区分,个人理解部分使用 ///开头,此处和 三斜线指令没有关系,只是为了做区分。

往期回顾

useSelections

常见联动 checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。

/* eslint-disable no-shadow */
import { useState, useMemo } from 'react';

export default function useSelections<T>(items: T[], defaultSelected: T[] = []) {
  const [selected, setSelected] = useState<T[]>(defaultSelected);

  const selectedSet = useMemo(() => new Set<T>(selected), [selected]); /// 使用 set 作为选中项的容器,对象、简单值都可以放入

  const singleActions = useMemo(() => {
    const isSelected = (item: T) => selectedSet.has(item);

    const select = (item: T) => {
      selectedSet.add(item);
      return setSelected(Array.from(selectedSet)); /// setSelected 返回的是 undefined ,这个 return 比较迷惑
    };

    const unSelect = (item: T) => {
      selectedSet.delete(item);
      return setSelected(Array.from(selectedSet));
    };

    const toggle = (item: T) => {
      if (isSelected(item)) {
        unSelect(item);
      } else {
        select(item);
      }
    };

    return { isSelected, select, unSelect, toggle };
  }, [selectedSet]);

  const allActions = useMemo(() => {
    const selectAll = () => {
      items.forEach((o) => {
        selectedSet.add(o);
      });
      setSelected(Array.from(selectedSet));
    };

    const unSelectAll = () => {
      items.forEach((o) => {
        selectedSet.delete(o);
      });
      setSelected(Array.from(selectedSet));
    };

    const noneSelected = items.every((o) => !selectedSet.has(o)); /// 空数组永远返回真

    const allSelected = items.every((o) => selectedSet.has(o)) && !noneSelected; ///  && !noneSelected 是为了排除空数组

    const partiallySelected = !noneSelected && !allSelected;

    const toggleAll = () => (allSelected ? unSelectAll() : selectAll());

    return { selectAll, unSelectAll, noneSelected, allSelected, partiallySelected, toggleAll };
  }, [selectedSet, items]);

  return {
    selected,
    setSelected,
    ...singleActions,
    ...allActions,
  } as const;
}

useVirtualList

虚拟滚动列表的抽象实现

import { useEffect, useState, useMemo, useRef, MutableRefObject } from 'react';
import useSize from '../useSize';

export interface OptionType {
  itemHeight: number | ((index: number) => number);
  overscan?: number;
}

export default <T = any>(list: T[], options: OptionType) => {
  const containerRef = useRef<HTMLElement | null>();
  const size = useSize(containerRef as MutableRefObject<HTMLElement>);
  // 暂时禁止 cache
  // const distanceCache = useRef<{ [key: number]: number }>({});
  const [state, setState] = useState({ start: 0, end: 10 });
  const { itemHeight, overscan = 5 } = options;

  if (!itemHeight) {
    console.warn('please enter a valid itemHeight');
  }
  /// 计算当前容器内可视的列表项数量
  /// 如果列高度是固定值则直接相除向上取整,如果是函数则挨个计算出高度并计数
  const getViewCapacity = (containerHeight: number) => {
    if (typeof itemHeight === 'number') {
      return Math.ceil(containerHeight / itemHeight);
    }
    const { start = 0 } = state;
    let sum = 0;
    let capacity = 0;
    for (let i = start; i < list.length; i++) {
      const height = (itemHeight as (index: number) => number)(i);
      sum += height;
      if (sum >= containerHeight) {
        capacity = i;
        break;
      }
    }
    return capacity - start;
  };
  /// 计算指定滚动高度的项在列表中的偏移量(索引+1)
  const getOffset = (scrollTop: number) => {
    if (typeof itemHeight === 'number') {
      return Math.floor(scrollTop / itemHeight) + 1;
    }
    let sum = 0;
    let offset = 0;
    for (let i = 0; i < list.length; i++) {
      const height = (itemHeight as (index: number) => number)(i);
      sum += height;
      if (sum >= scrollTop) {
        offset = i;
        break;
      }
    }
    return offset + 1;
  };
  /// 计算当前列表应该渲染的项的索引范围
  const calculateRange = () => {
    const element = containerRef.current;
    if (element) {
      const offset = getOffset(element.scrollTop);
      const viewCapacity = getViewCapacity(element.clientHeight);

      const from = offset - overscan;
      const to = offset + viewCapacity + overscan;
      setState({
        start: from < 0 ? 0 : from,
        end: to > list.length ? list.length : to,
      });
    }
  };

  useEffect(() => {
    calculateRange();
  }, [size.width, size.height]);
  /// 列表所有项的高度和
  const totalHeight = useMemo(() => {
    if (typeof itemHeight === 'number') {
      return list.length * itemHeight;
    }
    return list.reduce((sum, _, index) => sum + itemHeight(index), 0);
  }, [list.length]);
  /// 计算指定索引项应该距离列表顶部的距离
  const getDistanceTop = (index: number) => {
    // 如果有缓存,优先返回缓存值
    // if (enableCache && distanceCache.current[index]) {
    //   return distanceCache.current[index];
    // }
    if (typeof itemHeight === 'number') {
      const height = index * itemHeight;
      // if (enableCache) {
      //   distanceCache.current[index] = height;
      // }
      return height;
    }
    const height = list.slice(0, index).reduce((sum, _, i) => sum + itemHeight(i), 0);
    // if (enableCache) {
    //   distanceCache.current[index] = height;
    // }
    return height;
  };
  /// 将指定索引项滚动到容器最上方
  const scrollTo = (index: number) => {
    if (containerRef.current) {
      containerRef.current.scrollTop = getDistanceTop(index);
      calculateRange();
    }
  };

  const offsetTop = useMemo(() => getDistanceTop(state.start), [state.start]);

  return {
    list: list.slice(state.start, state.end).map((ele, index) => ({
      data: ele,
      index: index + state.start,
    })), /// 根据当前计算出来的渲染索引范围返回需要实际渲染的项的列表,结果是实时计算出来的,所以每次重新渲染都会重新计算并触发列表项的重新渲染
    scrollTo,
    containerProps: {
      ref: (ele: any) => {
        containerRef.current = ele;
      },
      onScroll: (e: any) => {
        e.preventDefault();
        calculateRange(); /// 在容器内触发滚动事件时重新计算需要渲染的索引范围
      },
      style: { overflowY: 'auto' as const }, /// 1 设置了滚动 2 产生 bfc 避免了 margin 合并
    },
    wrapperProps: {
      style: {
        width: '100%',
        height: totalHeight - offsetTop,
        /// 使用 margin 和 height 来实现虚拟滚动,所以布局的时候要注意 margin 合并
        /// 也就是说实际渲染内容上部是 margin,下部是空白
        /// 如果使用 padding 来实现,在怪异盒模型下,height会包含padding,导致出问题
        marginTop: offsetTop,
      },
    },
  };
};

参考资料

以上内容由于本人水平问题难免有误,欢迎大家进行讨论反馈。