arco-design虚拟滚动代码解读

import React, {
  useEffect,
  useImperativeHandle,
  useRef,
  useMemo,
  useState,
  ReactNode,
  CSSProperties,
  UIEvent,
  useReducer,
  ReactElement,
  cloneElement,
  isValidElement,
  ComponentState,
  PropsWithoutRef,
} from 'react';
import ResizeObserver from 'resize-observer-polyfill';
import lodashThrottle from 'lodash/throttle';
import {
  Key,
  getValidScrollTop,
  getCompareItemRelativeTop,
  getItemAbsoluteTop,
  getItemRelativeTop,
  getNodeHeight,
  getRangeIndex,
  getScrollPercentage,
  GHOST_ITEM_KEY,
  getLongestItemIndex,
  getLocationItem,
} from './utils/itemUtil';
import { raf, caf } from '../../_util/raf';
import { isNumber, supportRef } from '../../_util/is';
import { callbackOriginRef, findDOMNode } from '../../_util/react-dom';
// import usePrevious from '../../_util/hooks/usePrevious';
import { findListDiffIndex, getIndexByStartLoc } from './utils/algorithmUtil';
import Filler from './Filler';
// import useStateWithPromise from '../../_util/hooks/useStateWithPromise';
// import useIsFirstRender from '../../_util/hooks/useIsFirstRender';
// import useForceUpdate from '../../_util/hooks/useForceUpdate';
// import ResizeObserver from '../../_util/resizeObserver';
import useIsomorphicLayoutEffect from '../../_util/hooks/useIsomorphicLayoutEffect';

function usePrevious<T>(value: PropsWithoutRef<T> | ComponentState) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

function useIsFirstRender() {
  const isFirst = useRef<boolean>(true);
  useEffect(() => {
    isFirst.current = false;
  }, []);
  return isFirst.current;
}
function useForceUpdate() {
  const [, dispatch] = useReducer((v) => v + 1, 0);
  return dispatch;
}

function useStateWithPromise<T>(defaultVal: T): [T, (updater: any) => Promise<T>] {
  const [state, setState] = useState({
    value: defaultVal,
    resolve: (e) => {
      // eslint-disable-next-line no-unused-expressions
      e;
    },
  });

  useEffect(() => {
    state.resolve(state.value);
  }, [state]);

  return [
    state.value,
    (updater) => {
      return new Promise((resolve) => {
        setState((prevState) => {
          let nextVal = updater;
          if (typeof updater === 'function') {
            nextVal = updater(prevState.value);
          }
          return {
            value: nextVal,
            resolve,
          };
        });
      });
    },
  ];
}

export type RenderFunc<T> = (
  item: T,
  index: number,
  props: { style: React.CSSProperties; itemIndex: number }
) => ReactNode;

type Status = 'NONE' | 'MEASURE_START' | 'MEASURE_DONE';

export interface VirtualListProps<T>
  extends Omit<React.HTMLAttributes<any>, 'children' | 'onScroll'> {
  children: RenderFunc<T>;
  data: T[];
  /* Viewable area height (`2.11.0` starts support `string` type such as `80%`) */
  height?: number | string;
  /* The element height used to calculate how many elements are actually rendered */
  itemHeight?: number;
  /* HTML tags for wrapping */
  wrapper?: string | React.FC<any> | React.ComponentClass<any>;
  /* Threshold of the number of elements that auto enable virtual scrolling, use `null` to disable virtual scrolling */
  threshold?: number | null;
  /* Whether it's static elements of the same height */
  isStaticItemHeight?: boolean;
  /* Key of the specified element, or function to get the key */
  itemKey?: Key | ((item: T, index: number) => Key);
  /* Whether need to measure longest child element */
  measureLongestItem?: boolean;
  /* Configure the default behavior related to scrolling */
  scrollOptions?: ScrollIntoViewOptions;
  needFiller?: boolean;
  /** Custom filler outer style */
  outerStyle?: CSSProperties;
  innerStyle?: CSSProperties;
  onScroll?: (event: UIEvent<HTMLElement>, info: { index: number }) => void;
  wrapperChild?: string | React.FC<any> | React.ComponentClass<any>;
}

export type AvailableVirtualListProps = Pick<
  VirtualListProps<any>,
  | 'height'
  | 'itemHeight'
  | 'threshold'
  | 'isStaticItemHeight'
  | 'scrollOptions'
  | 'onScroll'
  | 'wrapperChild'
>;

interface RelativeScroll {
  itemIndex: number;
  relativeTop: number;
}

interface VirtualListState {
  status: Status;
  startIndex: number;
  endIndex: number;
  itemIndex: number;
  itemOffsetPtg: number;
  startItemTop: number;
  scrollTop: number;
}

export type VirtualListHandle = {
  dom: HTMLElement;
  scrollTo: (
    arg:
      | number
      | {
          index: number;
          options?: ScrollIntoViewOptions;
        }
      | {
          key: Key;
          options?: ScrollIntoViewOptions;
        }
  ) => void;
};

// map for height of each element
type ItemHeightMap = { [p: string]: number };

// height of the virtual element, used to calculate total height of the virtual list
const DEFAULT_VIRTUAL_ITEM_HEIGHT = 32;
const KEY_VIRTUAL_ITEM_HEIGHT = `__virtual_item_height_${Math.random().toFixed(5).slice(2)}`; // 所有itme高度的平均值

// after collecting the real height of the first screen element, calculate the virtual ItemHeight to trigger list re-rendering
// 收集第一个屏幕元素的实际高度后,计算虚拟ItemHeight触发列表重新渲染
const useComputeVirtualItemHeight = (refItemHeightMap: React.MutableRefObject<ItemHeightMap>) => {
  const forceUpdate = useForceUpdate(); // 触发state执行更新的标记
  const { current: heightMap } = refItemHeightMap;

  useEffect(() => {
    // virtual item height should be static as possible, otherwise it is easy to cause jitter
    if (Object.keys(heightMap).length && !heightMap[KEY_VIRTUAL_ITEM_HEIGHT]) {
      heightMap[KEY_VIRTUAL_ITEM_HEIGHT] = Object.entries(heightMap).reduce(
        (sum, [, currentHeight], currentIndex, array) => {
          const nextSum = sum + currentHeight;
          return currentIndex === array.length - 1 ? Math.round(nextSum / array.length) : nextSum;
        },
        0
      );
      forceUpdate();
    }
  }, [Object.keys(heightMap).length]);
};

// cache the constructed results of child nodes to avoid redrawing of child nodes caused by re-construction during drawing
const useCacheChildrenNodes = (children: VirtualListProps<any>['children']) => {
  const refCacheMap = useRef<{ [key: number]: ReactNode }>({});
  const refPrevChildren = useRef(children); // children 生成子元素的函数

  useEffect(() => {
    refPrevChildren.current = children;
  }, [children]);

  // children change means state of parent component is updated, so clear cache
  if (children !== refPrevChildren.current) {
    refCacheMap.current = {};
  }

  return (item, index, props) => {
    if (!refCacheMap.current.hasOwnProperty(index)) {
      refCacheMap.current[index] = children(item, index, props);
    }
    return refCacheMap.current[index];
  };
};

interface ResizeProps {
  throttle?: boolean;
  onResize?: (entry: ResizeObserverEntry[]) => void;
  children?: React.ReactNode;
  getTargetDOMNode?: () => any;
}

class ResizeObserverComponent extends React.Component<ResizeProps> {
  resizeObserver: ResizeObserver;

  rootDOMRef: any;

  getRootElement = () => {
    const { getTargetDOMNode } = this.props;
    return findDOMNode(getTargetDOMNode?.() || this.rootDOMRef, this);
  };

  getRootDOMNode = () => {
    return this.getRootElement();
  };

  componentDidMount() {
    console.log('ResizeObserverComponent componentDidMount');

    if (!React.isValidElement(this.props.children)) {
      console.warn('The children of ResizeObserver is invalid.');
    } else {
      this.createResizeObserver();
    }
  }

  componentDidUpdate() {
    if (!this.resizeObserver && this.getRootElement()) {
      this.createResizeObserver();
    }
  }

  componentWillUnmount = () => {
    if (this.resizeObserver) {
      this.destroyResizeObserver();
    }
  };

  createResizeObserver = () => {
    const { throttle = true } = this.props;
    const onResize = (entry) => {
      this.props.onResize?.(entry);
    };
    const resizeHandler = throttle ? lodashThrottle(onResize) : onResize;
    let firstExec = true; // 首次监听时,立即执行一次 onResize,之前行为保持一致,避免布局类组件出现闪动的情况
    console.log('1===');
    this.resizeObserver = new ResizeObserver((entry) => {
      if (firstExec) {
        firstExec = false;
        console.log('2===');
        onResize(entry);
      }
      resizeHandler(entry);
    });
    const targetNode = this.getRootElement();
    targetNode && this.resizeObserver.observe(targetNode as Element);
  };

  destroyResizeObserver = () => {
    this.resizeObserver && this.resizeObserver.disconnect();
    this.resizeObserver = null;
  };

  render() {
    const { children } = this.props;

    if (supportRef(children) && isValidElement(children) && !this.props.getTargetDOMNode) {
      return cloneElement(children as ReactElement, {
        ref: (node) => {
          this.rootDOMRef = node;

          callbackOriginRef(children, node);
        },
      });
    }
    this.rootDOMRef = null;
    return this.props.children;
  }
}

const VirtualList: React.ForwardRefExoticComponent<
  VirtualListProps<any> & React.RefAttributes<VirtualListHandle>
> = React.forwardRef((props: VirtualListProps<any>, ref) => {
  const {
    style,
    className,
    children,
    data = [],
    itemKey,
    threshold = 100,
    wrapper: WrapperTagName = 'div',
    height: propHeight = '100vh',
    isStaticItemHeight = true,
    itemHeight: propItemHeight,
    measureLongestItem,
    scrollOptions,
    onScroll,
    needFiller = true,
    outerStyle,
    innerStyle,
    wrapperChild: WrapperChildTagName = React.Fragment,
    ...restProps
  } = props;
  // Compatible with setting the height of the list through style.maxHeight
  const styleListMaxHeight = (style && style.maxHeight) || propHeight; // table最大高度

  const refItemHeightMap = useRef<ItemHeightMap>({}); // 每个table子项的高度Map
  const [stateHeight, setStateHeight] = useState(200); // table的默认高度
  const renderChild = useCacheChildrenNodes(children); // 渲染子项,但是会缓存table item的每个子项的渲染结果

  useComputeVirtualItemHeight(refItemHeightMap); // 计算虚拟ItemHeight 也就是 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]

  // Elements with the same height, the height of the item is based on the first rendering
  // 具有相同高度的元素,项目的高度基于第一次渲染
  const itemCount = data.length; // 数据总数
  const itemHeight =
    propItemHeight ||
    refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT] ||
    DEFAULT_VIRTUAL_ITEM_HEIGHT; // item的高度 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]是所有item的平均高度
  const viewportHeight = isNumber(styleListMaxHeight) ? styleListMaxHeight : stateHeight; // table的高度
  const itemCountVisible = Math.ceil(viewportHeight / itemHeight); // table的高度最多可以放几个item (一页面可以放几个item)
  const itemTotalHeight = itemHeight * itemCount; // 内容的总高度
  const isVirtual =
    threshold !== null && itemCount >= threshold && itemTotalHeight > viewportHeight; // 是否需要虚拟滚动

  const refList = useRef<HTMLElement>(null); // table子项外层包的div
  const refRafId = useRef(null);
  const refLockScroll = useRef(false);
  const refIsVirtual = useRef(isVirtual);

  // The paddingTop of the record scrolling list is used to correct the scrolling distance
  // 记录滚动列表的paddingTop用于校正滚动距离
  const scrollListPadding = useMemo<{ top: number; bottom: number }>(() => {
    if (refList.current) {
      const getPadding = (property) =>
        +window.getComputedStyle(refList.current)[property].replace(/\D/g, '');
      return {
        top: getPadding('paddingTop'),
        bottom: getPadding('paddingBottom'),
      };
    }

    return { top: 0, bottom: 0 };
  }, [refList.current]);

  // status 数据更新状态,startIndex渲染数据的开始位置  endIndex渲染数据的结束位置
  const [state, setState] = useStateWithPromise<VirtualListState>({
    // measure status
    status: 'NONE',
    // render range info
    startIndex: 0,
    endIndex: 0,
    itemIndex: 0,
    itemOffsetPtg: 0,
    // scroll info
    startItemTop: 0,
    scrollTop: 0,
  });

  const prevData = usePrevious(data) || []; // ref保存数据源
  const isFirstRender = useIsFirstRender(); // 是否第一次渲染

  const getItemKey = (item, index) => {
    return typeof itemKey === 'function'
      ? itemKey(item, index)
      : typeof itemKey === 'string'
      ? item[itemKey]
      : item.key || index;
  };

  const getItemKeyByIndex = (index, items = data) => {
    if (index === items.length) {
      return GHOST_ITEM_KEY;
    }
    const item = items[index];
    return item !== undefined ? getItemKey(item, index) : null;
  };

  const getCachedItemHeight = (key: Key): number => {
    return refItemHeightMap.current[key] || itemHeight;
  };

  const internalScrollTo = (relativeScroll: RelativeScroll): void => {
    const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = relativeScroll;
    const { scrollHeight, clientHeight } = refList.current;
    const originScrollTop = state.scrollTop;
    const maxScrollTop = scrollHeight - clientHeight;

    let bestSimilarity = Number.MAX_VALUE;
    let bestScrollTop: number = null;
    let bestItemIndex: number = null;
    let bestItemOffsetPtg: number = null;
    let bestStartIndex: number = null;
    let bestEndIndex: number = null;
    let missSimilarity = 0;

    for (let i = 0; i < maxScrollTop; i++) {
      const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i);
      const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight });
      const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
        scrollPtg,
        itemCount,
        itemCountVisible
      );

      if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) {
        const locatedItemRelativeTop = getItemRelativeTop({
          itemHeight: getCachedItemHeight(getItemKeyByIndex(itemIndex)),
          itemOffsetPtg,
          clientHeight,
          scrollPtg,
        });

        const compareItemTop = getCompareItemRelativeTop({
          locatedItemRelativeTop,
          locatedItemIndex: itemIndex,
          compareItemIndex,
          startIndex,
          endIndex,
          itemHeight,
          getItemKey: getItemKeyByIndex,
          itemElementHeights: refItemHeightMap.current,
        });

        const similarity = Math.abs(compareItemTop - compareItemRelativeTop);
        if (similarity < bestSimilarity) {
          bestSimilarity = similarity;
          bestScrollTop = scrollTop;
          bestItemIndex = itemIndex;
          bestItemOffsetPtg = itemOffsetPtg;
          bestStartIndex = startIndex;
          bestEndIndex = endIndex;

          missSimilarity = 0;
        } else {
          missSimilarity += 1;
        }
      }

      if (missSimilarity > 10) {
        break;
      }
    }

    if (bestScrollTop !== null) {
      refLockScroll.current = true;
      refList.current.scrollTop = bestScrollTop;

      setState({
        ...state,
        status: 'MEASURE_START',
        scrollTop: bestScrollTop,
        itemIndex: bestItemIndex,
        itemOffsetPtg: bestItemOffsetPtg,
        startIndex: bestStartIndex,
        endIndex: bestEndIndex,
      });
    }

    refRafId.current = raf(() => {
      refLockScroll.current = false;
    });
  };

  // Record the current element position when the real list is scrolled, and ensure that the position is correct after switching to the virtual list
  const rawListScrollHandler = (event) => {
    const { scrollTop: rawScrollTop, clientHeight, scrollHeight } = refList.current;
    const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight);

    const scrollPtg = getScrollPercentage({
      scrollTop,
      clientHeight,
      scrollHeight,
    });

    const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);

    setState({
      ...state,
      scrollTop,
      itemIndex: index,
      itemOffsetPtg: offsetPtg,
    });

    event && onScroll?.(event, { index });
  };

  // 滚动计算
  // Modify the state and recalculate the position in the next render
  const virtualListScrollHandler = (event, isInit = false) => {
    // Do NOT use refList.current.scrollHeight
    // We should use Filler's height as total scroll height
    // Filler's translate style may make refList.current.scrollHeight larger than Filler's height
    const scrollHeight = itemTotalHeight; // 总高度
    const { scrollTop: rawScrollTop, clientHeight } = refList.current; // rawScrollTop 滚动具体  clientHeight容器高度
    const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight); // 计算滚动距离,最大滚动距离不能超过scrollHeight - clientHeight(因为不能超出去)

    // Prevent jitter
    if (!isInit && (scrollTop === state.scrollTop || refLockScroll.current)) {
      return;
    }
    // table已经滚动的距离占总共可以滚动距离的比例
    const scrollPtg = getScrollPercentage({
      scrollTop,
      clientHeight,
      scrollHeight,
    });
    // 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标 itemIndex==当前item下标 , itemOffsetPtg==偏移量, startIndex==渲染数据开始下标, endIndex==渲染数据结束下标
    const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
      scrollPtg,
      itemCount,
      itemCountVisible
    );

    setState({
      ...state,
      scrollTop,
      itemIndex,
      itemOffsetPtg,
      startIndex,
      endIndex,
      status: 'MEASURE_START',
    });

    event && onScroll?.(event, { index: itemIndex });
  };

  useEffect(() => {
    return () => {
      refRafId.current && caf(refRafId.current);
    };
  }, []);

  // rerender when the number of visible elements changes
  useEffect(() => {
    if (refList.current) {
      if (isFirstRender) {
        // 第一次渲染scrollTop为0
        refList.current.scrollTop = 0;
      }

      // 第一次渲染时候主动触发一次滚动计算
      virtualListScrollHandler(null, true);
    }
  }, [itemCountVisible]);

  // Handle additions and deletions of list items or switching the virtual state
  useEffect(() => {
    if (!refList.current) return;

    let changedItemIndex: number = null;
    const switchTo = refIsVirtual.current !== isVirtual ? (isVirtual ? 'virtual' : 'raw') : '';

    refIsVirtual.current = isVirtual;

    if (viewportHeight && prevData.length !== data.length) {
      const diff = findListDiffIndex(prevData, data, getItemKey);
      changedItemIndex = diff ? diff.index : null;
    }

    // No need to correct the position when the number of elements in the real list changes
    if (switchTo || (isVirtual && changedItemIndex)) {
      const { clientHeight } = refList.current;
      const locatedItemRelativeTop = getItemRelativeTop({
        itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex, prevData)),
        itemOffsetPtg: state.itemOffsetPtg,
        scrollPtg: getScrollPercentage({
          scrollTop: state.scrollTop,
          scrollHeight: prevData.length * itemHeight,
          clientHeight,
        }),
        clientHeight,
      });

      if (switchTo === 'raw') {
        let rawTop = locatedItemRelativeTop;
        for (let index = 0; index < state.itemIndex; index++) {
          rawTop -= getCachedItemHeight(getItemKeyByIndex(index));
        }

        refList.current.scrollTop = -rawTop;
        refLockScroll.current = true;
        refRafId.current = raf(() => {
          refLockScroll.current = false;
        });
      } else {
        internalScrollTo({
          itemIndex: state.itemIndex,
          relativeTop: locatedItemRelativeTop,
        });
      }
    }
  }, [data, isVirtual]);

  useIsomorphicLayoutEffect(() => {
    // 这里加上debugger会造成不会触发ResizeObserver的onResize,因为它加上debugger之后默认个数就会被渲染出来。
    setTimeout(() => {
      if (state.status === 'MEASURE_START' && refList.current) {
        const { scrollTop, scrollHeight, clientHeight } = refList.current;
        const scrollPtg = getScrollPercentage({
          scrollTop,
          scrollHeight,
          clientHeight,
        });

        // Calculate the top value of the first rendering element
        // 计算第一个渲染元素的顶值
        let startItemTop = getItemAbsoluteTop({
          scrollPtg,
          clientHeight,
          scrollTop: scrollTop - (scrollListPadding.top + scrollListPadding.bottom) * scrollPtg,
          itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex)),
          itemOffsetPtg: state.itemOffsetPtg,
        });
        for (let index = state.itemIndex - 1; index >= state.startIndex; index--) {
          startItemTop -= getCachedItemHeight(getItemKeyByIndex(index));
        }

        setState({
          ...state,
          startItemTop,
          status: 'MEASURE_DONE',
        });
      }
    }, 0);
  }, [state]);

  useImperativeHandle<any, VirtualListHandle>(
    ref,
    () => ({
      dom: refList.current,
      getRootDOMNode: () => refList.current,
      // Scroll to a certain height or an element
      scrollTo: (arg) => {
        refRafId.current && caf(refRafId.current);
        refRafId.current = raf(() => {
          if (!refList.current) return;

          if (typeof arg === 'number') {
            refList.current.scrollTop = arg;
            return;
          }

          const index =
            'index' in arg
              ? arg.index
              : 'key' in arg
              ? data.findIndex((item, index) => getItemKey(item, index) === arg.key)
              : 0;
          const item = data[index];

          if (!item) {
            return;
          }

          let align: ScrollIntoViewOptions['block'] =
            typeof arg === 'object' && arg.options?.block
              ? arg.options.block
              : scrollOptions?.block || 'nearest';
          const { clientHeight, scrollTop } = refList.current;

          if (isVirtual && !isStaticItemHeight) {
            if (align === 'nearest') {
              const { itemIndex, itemOffsetPtg } = state;
              if (Math.abs(itemIndex - index) < itemCountVisible) {
                let itemTop = getItemRelativeTop({
                  itemHeight: getCachedItemHeight(getItemKeyByIndex(itemIndex)),
                  itemOffsetPtg,
                  clientHeight,
                  scrollPtg: getScrollPercentage(refList.current),
                });

                if (index < itemIndex) {
                  for (let i = index; i < itemIndex; i++) {
                    itemTop -= getCachedItemHeight(getItemKeyByIndex(i));
                  }
                } else {
                  for (let i = itemIndex; i < index; i++) {
                    itemTop += getCachedItemHeight(getItemKeyByIndex(i));
                  }
                }

                // When the target element is within the field of view, exit directly
                if (itemTop < 0 || itemTop > clientHeight) {
                  align = itemTop < 0 ? 'start' : 'end';
                } else {
                  return;
                }
              } else {
                align = index < itemIndex ? 'start' : 'end';
              }
            }

            setState({
              ...state,
              startIndex: Math.max(0, index - itemCountVisible),
              endIndex: Math.min(itemCount - 1, index + itemCountVisible),
            }).then(() => {
              const itemHeight = getCachedItemHeight(getItemKey(item, index));

              internalScrollTo({
                itemIndex: index,
                relativeTop:
                  align === 'start'
                    ? 0
                    : (clientHeight - itemHeight) / (align === 'center' ? 2 : 1),
              });
            });
          } else {
            const indexItemHeight = getCachedItemHeight(getItemKeyByIndex(index));
            let itemTop = 0;
            for (let i = 0; i < index; i++) {
              itemTop += getCachedItemHeight(getItemKeyByIndex(i));
            }
            const itemBottom = itemTop + indexItemHeight;
            const itemMiddle = itemTop + indexItemHeight / 2;

            // If item is visible, skip scrolling
            if (itemMiddle > scrollTop && itemMiddle < clientHeight + scrollTop) {
              return;
            }

            if (align === 'nearest') {
              if (itemTop < scrollTop) {
                align = 'start';
              } else if (itemBottom > scrollTop + clientHeight) {
                align = 'end';
              }
            }

            const viewportHeight = clientHeight - indexItemHeight;
            refList.current.scrollTop =
              itemTop - (align === 'start' ? 0 : viewportHeight / (align === 'center' ? 2 : 1));
          }
        });
      },
    }),
    [data, itemHeight, state]
  );

  const renderChildren = (list, startIndex: number) => {
    return list.map((item, index) => {
      const originIndex = startIndex + index;
      const node = renderChild(item, originIndex, {
        style: {},
        itemIndex: index,
      }) as React.ReactElement;
      const key = getItemKey(item, originIndex);
      return React.cloneElement(node, {
        key,
        ref: (ele: HTMLElement) => {
          const { current: heightMap } = refItemHeightMap;
          // Minimize the measurement of element height as much as possible to avoid frequent triggering of browser reflow
          // Method getNodeHeight get the clientHeight from the DOM referred by React ref. If result is wrong, check the ref of this element
          if (
            ele &&
            state.status === 'MEASURE_START' &&
            (!isStaticItemHeight || heightMap[key] === undefined)
          ) {
            if (isStaticItemHeight) {
              if (!heightMap[KEY_VIRTUAL_ITEM_HEIGHT]) {
                heightMap[KEY_VIRTUAL_ITEM_HEIGHT] = getNodeHeight(ele, true);
              }
              heightMap[key] = heightMap[KEY_VIRTUAL_ITEM_HEIGHT];
            } else {
              heightMap[key] = getNodeHeight(ele, true);
            }
          }
        },
      });
    });
  };

  // Render the widest element to provide the maximum width of the container initially
  const refLongestItemIndex = useRef<number>(null);

  // Don't add `renderChild` to the array dependency, it will change every time when rerender
  useEffect(() => {
    refLongestItemIndex.current = null;
  }, [data]);

  const renderLongestItem = () => {
    if (measureLongestItem) {
      const index =
        refLongestItemIndex.current === null
          ? getLongestItemIndex(data)
          : refLongestItemIndex.current;
      const item = data[index];

      refLongestItemIndex.current = index;

      return item ? (
        <div style={{ height: 1, overflow: 'hidden', opacity: 0 }}>
          {renderChild(item, index, { style: {} })}
        </div>
      ) : null;
    }
    return null;
  };

  // 打断点不会触发ResizeObserver的onResize,等他第一次渲染出来的时候如果还断点中那么就不会触发onResize,他会默认第一次触发,但是页面有断点时候,不知道为什么它就不触发了。
  return (
    <ResizeObserverComponent
      onResize={() => {
        if (refList.current && !isNumber(styleListMaxHeight)) {
          const { clientHeight } = refList.current;
          setStateHeight(clientHeight);
        }
      }}
      getTargetDOMNode={() => refList.current}
    >
      <WrapperTagName
        ref={refList}
        style={{
          overflowY: 'auto',
          overflowAnchor: 'none',
          ...style,
          maxHeight: styleListMaxHeight,
        }}
        className={className}
        onScroll={isVirtual ? virtualListScrollHandler : rawListScrollHandler}
        {...restProps}
      >
        {isVirtual ? (
          <>
            <Filler
              height={itemTotalHeight}
              outerStyle={outerStyle}
              innerStyle={innerStyle}
              offset={state.status === 'MEASURE_DONE' ? state.startItemTop : 0}
            >
              <WrapperChildTagName>
                {renderChildren(data.slice(state.startIndex, state.endIndex + 1), state.startIndex)}
              </WrapperChildTagName>
            </Filler>
            {renderLongestItem()}
          </>
        ) : needFiller ? (
          <Filler height={viewportHeight} outerStyle={outerStyle} innerStyle={innerStyle}>
            <WrapperChildTagName>{renderChildren(data, 0)}</WrapperChildTagName>
          </Filler>
        ) : (
          <WrapperChildTagName>{renderChildren(data, 0)}</WrapperChildTagName>
        )}
      </WrapperTagName>
    </ResizeObserverComponent>
  );
});

VirtualList.displayName = 'VirtualList';

export default VirtualList;


const styleListMaxHeight = (style && style.maxHeight) || propHeight; // table最大高度

  const refItemHeightMap = useRef<ItemHeightMap>({}); // 每个table子项的高度Map
  const [stateHeight, setStateHeight] = useState(200); // table的默认高度
  const renderChild = useCacheChildrenNodes(children); // 渲染子项,但是会缓存table item的每个子项的渲染结果

  useComputeVirtualItemHeight(refItemHeightMap); // 计算虚拟ItemHeight 也就是 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]

  // Elements with the same height, the height of the item is based on the first rendering
  // 具有相同高度的元素,项目的高度基于第一次渲染
  const itemCount = data.length; // 数据总数
  const itemHeight =
    propItemHeight ||
    refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT] ||
    DEFAULT_VIRTUAL_ITEM_HEIGHT; // item的高度 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]是所有item的平均高度
  const viewportHeight = isNumber(styleListMaxHeight) ? styleListMaxHeight : stateHeight; // table的高度
  const itemCountVisible = Math.ceil(viewportHeight / itemHeight); // table的高度最多可以放几个item (一页面可以放几个item)
  const itemTotalHeight = itemHeight * itemCount; // 内容的总高度
  const isVirtual =
    threshold !== null && itemCount >= threshold && itemTotalHeight > viewportHeight; // 是否需要虚拟滚动

  const refList = useRef<HTMLElement>(null); // table子项外层包的div
 // 记录滚动列表的paddingTop用于校正滚动距离, 滚动要把组件本身的top算进去
  const scrollListPadding = useMemo<{ top: number; bottom: number }>(() => {
    if (refList.current) {
      const getPadding = (property) =>
        +window.getComputedStyle(refList.current)[property].replace(/\D/g, '');
      return {
        top: getPadding('paddingTop'),
        bottom: getPadding('paddingBottom'),
      };
    }

    return { top: 0, bottom: 0 };
  }, [refList.current]);

这里为什么是top,是因为Filler组件的内部实现决定的。

第一次渲染完成之后执行

 useEffect(() => {
    debugger;
    if (refList.current) {
      if (isFirstRender) {
        // 第一次渲染scrollTop为0
        refList.current.scrollTop = 0;
      }
      // 第一次渲染时候主动触发一次滚动计算
      virtualListScrollHandler(null, true);
    }
  }, [itemCountVisible]);

 const virtualListScrollHandler = (event, isInit = false) => {
    const scrollHeight = itemTotalHeight; // 总高度
    const { scrollTop: rawScrollTop, clientHeight } = refList.current; 
// rawScrollTop 滚动具体  clientHeight容器高度
    const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight); // 计算滚动距离,最大滚动距离不能超过scrollHeight - clientHeight(因为不能超出去)

    // Prevent jitter
    if (!isInit && (scrollTop === state.scrollTop || refLockScroll.current)) {
      return;
    }
    // table已经滚动的距离占总共可以滚动距离的比例
    const scrollPtg = getScrollPercentage({
      scrollTop,
      clientHeight,
      scrollHeight,
    });
    // 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标 itemIndex==当前item下标 , itemOffsetPtg==偏移量, startIndex==渲染数据开始下标, endIndex==渲染数据结束下标
    const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
      scrollPtg,
      itemCount,
      itemCountVisible
    );

    setState({
      ...state,
      scrollTop,
      itemIndex,
      itemOffsetPtg,
      startIndex,
      endIndex,
      status: 'MEASURE_START',
    });

    event && onScroll?.(event, { index: itemIndex });
  };
/**
 * 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标
 */
export function getRangeIndex(scrollPtg: number, itemCount: number, visibleCount: number) {
  const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);

  const beforeCount = Math.ceil(scrollPtg * visibleCount);
  const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);

  return {
    itemIndex: index,
    itemOffsetPtg: offsetPtg,
    startIndex: Math.max(0, index - beforeCount),
    endIndex: Math.min(itemCount - 1, index + afterCount),
  };
}

/**
 * 根据滚动条当前的滚动百分比,计算出基准元素
 * 在基准元素的上方和下方渲染可见区域的其他元素
 */
export function getLocationItem(scrollPtg: number, total: number): LocationItemResult {
  const itemIndex = Math.floor(scrollPtg * total);
  const itemTopPtg = itemIndex / total;
  const offsetPtg = (scrollPtg - itemTopPtg) / (1 / total);

  return {
    index: itemIndex,
    // scrollPtg >= itemTopPtg,计算结果为元素应当补充的滚动距离相对自身高度的偏移量
    offsetPtg: Number.isNaN(offsetPtg) ? 0 : offsetPtg,
  };
}
理解getLocationItem:

scrollPtg是滚动距离占总高度的百分比,所以scrollPtg * total就是当前滚动具体对应的item下标

const itemIndex = Math.floor(scrollPtg * total);

这里使用Math.floor,那么计算出来的itemIndex就要比实际的要小,因为实际滚动的距离会有小数点

当前滚动到的item下标占总个数的百分比

const itemTopPtg = itemIndex / total;

同理itemTopPtg计算出来的也是偏小

const offsetPtg = (scrollPtg - itemTopPtg) / (1 / total); 等价于

const offsetPtg = (scrollPtg - itemTopPtg) * total;

计算出偏差几个item

const beforeCount = Math.ceil(scrollPtg * visibleCount);

const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);

理解:

这段代码用于计算虚拟滚动列表中可见区域之前和之后的元素数量。虚拟滚动是一种优化技术,用于提高长列表的渲染性能,特别是在移动设备上。通过只渲染当前可视区域内的元素,而不是整个列表,可以显著减少 DOM 操作的数量,从而提高性能。

代码解释

在这段代码中:

  • scrollPtg 表示滚动比例,即当前滚动位置占整个可滚动区域的比例。
  • visibleCount 表示当前可视区域内可以显示的元素数量。

作用

  1. 计算 beforeCount:

    • beforeCount 计算的是当前可视区域之前(即未进入可视区域的部分)应渲染的元素数量。
    • Math.ceil(scrollPtg * visibleCount) 计算了滚动比例与可视元素数量的乘积,并向上取整,确保至少渲染一个完整的元素。
  2. 计算 afterCount:

    • afterCount 计算的是当前可视区域之后(即超出可视区域的部分)应渲染的元素数量。
    • Math.ceil((1 - scrollPtg) * visibleCount) 计算了剩余比例与可视元素数量的乘积,并向上取整,同样确保至少渲染一个完整的元素。

目的

  • 提高性能:

    • 通过仅渲染当前可视区域附近的元素,可以减少 DOM 操作的数量,从而提高性能。
    • 特别是在长列表中,这种方法可以显著减少内存消耗和渲染时间。
  • 平滑滚动体验:

    • 通过预加载可视区域前后的一些元素,可以确保用户在滚动时看到平滑的过渡效果。
    • 这样做可以避免用户在快速滚动时出现空白区域或闪烁现象。

示例代码

下面是一个简单的示例,展示了如何使用 beforeCount 和 afterCount 来实现虚拟滚动列表:

import React, { useState, useEffect, useRef } from 'react';

function VirtualList({ items }) {
  const containerRef = useRef(null);
  const [scrollPtg, setScrollPtg] = useState(0);
  const [visibleCount, setVisibleCount] = useState(10); // 假设每个元素的高度相同

  useEffect(() => {
    const container = containerRef.current;
    const handleScroll = () => {
      const scrollTop = container.scrollTop;
      const scrollHeight = container.scrollHeight;
      const clientHeight = container.clientHeight;

      setScrollPtg(scrollTop / (scrollHeight - clientHeight));
    };

    container.addEventListener('scroll', handleScroll);
    return () => container.removeEventListener('scroll', handleScroll);
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    setVisibleCount(container.clientHeight / 50); // 假设每个元素的高度为 50px
  }, []);

  const beforeCount = Math.ceil(scrollPtg * visibleCount);
  const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);

  const startIndex = Math.max(0, beforeCount - visibleCount);
  const endIndex = Math.min(items.length, startIndex + visibleCount + afterCount);

  return (
    <div ref={containerRef} style={{ height: `${items.length * 50}px`, overflow: 'auto' }}>
      {items.slice(startIndex, endIndex).map((item, index) => (
        <div key={index} style={{ height: '50px' }}>
          {item}
        </div>
      ))}
    </div>
  );
}

export default function App() {
  const items = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);

  return (
    <div>
      <VirtualList items={items} />
    </div>
  );
}

总结

这段代码中的 beforeCount 和 afterCount 用于计算虚拟滚动列表中可视区域前后应渲染的元素数量。通过这种方式,可以确保只渲染当前可视区域附近的元素,从而提高性能并提供流畅的滚动体验。

因为刚开始滚动时候上面需要预渲染的元素为0,所以就需要用scrollPtg * visibleCount去控制,不能设置死。

startIndex: Math.max(0, index - beforeCount),这样计算就包含了上面预渲染元素

endIndex: Math.min(itemCount - 1, index + afterCount), 包含了下面需要预渲染元素,但是不能超过总数。

计算完成之后设置

setState({
      ...state,
      scrollTop,
      itemIndex,
      itemOffsetPtg,
      startIndex,
      endIndex,
      status: 'MEASURE_START',
});

 附加:

在 useIsomorphicLayoutEffect 中添加 debugger 语句时,确实会影响到 ResizeObserver 的触发。这是因为 debugger 语句会导致浏览器暂停执行,这可能会影响到某些事件的触发,包括 ResizeObserver 的回调。

原因分析
  1. 执行暂停:

    • 当 debugger 语句被执行时,浏览器会暂停执行当前脚本,直到开发者手动继续执行。
    • 这意味着在暂停期间,浏览器不会继续执行后续的 JavaScript 代码,包括 ResizeObserver 的回调。
  2. 事件队列:

    • ResizeObserver 的回调是在微任务队列中执行的。
    • 当浏览器暂停执行时,微任务队列中的回调也不会被执行,因此 ResizeObserver 的回调不会被触发。
解决方案
  1. 移除 debugger 语句:

    • 最简单的方法是直接移除 debugger 语句,这样就不会影响到 ResizeObserver 的正常工作。
  2. 使用 console.log 替代:

    • 可以使用 console.log 替代 debugger 来输出调试信息,这样不会导致执行暂停。
  3. 使用 DevTools 的条件断点:

    • 如果需要在特定条件下调试,可以使用 Chrome DevTools 的条件断点功能。
    • 在 DevTools 中设置条件断点,只在满足特定条件时暂停执行。
  4. 使用 setTimeout 模拟异步:

    • 如果需要在 debugger 语句之后继续执行 ResizeObserver 的回调,可以使用 setTimeout 来模拟异步行为。
    •  useIsomorphicLayoutEffect(() => {
          // 这里加上debugger会造成不会触发ResizeObserver的onResize,因为它加上debugger之后默认个数就会被渲染出来。
          setTimeout(() => {
            debugger;
            if (state.status === 'MEASURE_START' && refList.current) {
              const { scrollTop, scrollHeight, clientHeight } = refList.current;
              const scrollPtg = getScrollPercentage({
                scrollTop,
                scrollHeight,
                clientHeight,
              });
      
              // Calculate the top value of the first rendering element
              // 计算第一个渲染元素的顶值
              let startItemTop = getItemAbsoluteTop({
                scrollPtg,
                clientHeight,
                scrollTop: scrollTop - (scrollListPadding.top + scrollListPadding.bottom) * scrollPtg,
                itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex)),
                itemOffsetPtg: state.itemOffsetPtg,
              });
              for (let index = state.itemIndex - 1; index >= state.startIndex; index--) {
                startItemTop -= getCachedItemHeight(getItemKeyByIndex(index));
              }
      
              setState({
                ...state,
                startItemTop,
                status: 'MEASURE_DONE',
              });
            }
          }, 0);
        }, [state]);

    • 这样可以确保 debugger 语句之后的代码不会阻塞 ResizeObserver 的回调。

实现思路

1 获取item的高度

2

根据容器高度viewportHeight计算铺满容器可以放下几个itemCountVisible,同时可以计算总高度 const itemTotalHeight = itemHeight * itemCount;

根据容器滚动的距离scrollTop来计算当前滚动到了第几个item,计算完成之后对总数据进行截取{renderChildren(data.slice(state.startIndex, state.endIndex + 1), state.startIndex)} 渲染item。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值