react-window 原始碼淺析

Grewer發表於2022-03-01
這篇是 react-window 的原始碼閱讀, 因為此庫使用的是 flow, 所以會涉及一些特殊的東西, 類似 ts

使用

List

首先是 List 的使用:

import {FixedSizeList as List} from 'react-window';

const Row = ({index, style}) => (
    <div style={style}>Row {index}</div>
);

const App = () => (
    <List
        height={150}
        itemCount={1000}
        itemSize={35}
        width={300}
    >
        {Row}
    </List>
);

相對 react-virtual 的使用來說簡單了很多, 使用方便, 但是相對地, 暴露的也少了一點點

解析

首先它是在一整個 createListComponent 的基礎上來建立 List 的具體方法的:

const FixedSizeList = createListComponent({
    // ...
    // 這裡陳列幾個主要函式和他的具體作用


})

export default FixedSizeList;

這裡先說下 createListComponent 的大體方法:

export default function createListComponent({
                                                // 省略
                                            }) {
    return class List extends PureComponent {

        // 滾動至 scrollOffset 的位置
        scrollTo = (scrollOffset: number): void

        // 滾動至某一 item 上, 通過傳遞對應序號
        scrollToItem(index: number, align: ScrollToAlign = 'auto'): void

        // 快取引數
        _callOnItemsRendered: (
            overscanStartIndex: number,
            overscanStopIndex: number,
            visibleStartIndex: number,
            visibleStopIndex: number
        ) => void;

        // 通過 index 來獲取對應的style, 其中有, 長, 寬, left, top 等具體位置屬性, 同時這些屬性也有快取
        _getItemStyle: (index: number) => Object;

        // 獲取序號 ,   overscanStartIndex,overscanStopIndex, visibleStartIndex, visibleStopIndex
        _getRangeToRender(): [number, number, number, number]

        // 滾動時觸發對應回撥, 更新scrollOffset
        _onScrollHorizontal = (event: ScrollEvent): void

        // 同上
        _onScrollVertical = (event: ScrollEvent): void

        // 渲染函式
        render() {
        }
    }

}

createListComponent

下面我們就詳情的解析一下這個元件的方法:

export default function createListComponent({
  getItemOffset,
  getEstimatedTotalSize,
  getItemSize,
  getOffsetForIndexAndAlignment,
  getStartIndexForOffset,
  getStopIndexForStartIndex,
  initInstanceProps,
  shouldResetStyleCacheOnItemSizeChange,
  validateProps,
}) {
    //直接就返回一個 class 元件, 沒有閉包變數
  return class List extends PureComponent {
      //  初始化的時候獲取的 props 引數
    _instanceProps: any = initInstanceProps(this.props, this);
    //外部元素 ref 物件
    _outerRef: ?HTMLDivElement;
    // 用來存取 定時器的
    _resetIsScrollingTimeoutId: TimeoutID | null = null;

    // 預設的引數
    static defaultProps = {
      direction: 'ltr', //  方向
      itemData: undefined, // 每一個 item 的物件
      layout: 'vertical', // 佈局
      overscanCount: 2, // 上部和下部超出的 item 個數
      useIsScrolling: false, // 是否正在滾動
    };

    // 元件的 state
    state: State = {
      instance: this,
      isScrolling: false,
      scrollDirection: 'forward',
      scrollOffset:
        typeof this.props.initialScrollOffset === 'number'
          ? this.props.initialScrollOffset
          : 0, // 根據 props 來判斷
      scrollUpdateWasRequested: false,
    };

    // constructor
    constructor(props: Props<T>) {
      super(props);
    }

    //  props 到 state 的對映
    static getDerivedStateFromProps(
      nextProps: Props<T>,
      prevState: State
    ): $Shape<State> | null {
        // 這個函式具體的原始碼我們在下面說明
        // 對於 下一步收到的 props 和上一步的 state, 做出判斷
        // 如果收到的引數不規範則會報錯, 可以忽略
      validateSharedProps(nextProps, prevState);
      // validateProps 此方法是外部傳遞的, note 1
      validateProps(nextProps);
      return null;
    }

// 滾動至某一位置
    scrollTo(scrollOffset: number): void {
      // 確保 scrollOffset 大於 0
      scrollOffset = Math.max(0, scrollOffset);

      this.setState(prevState => {
        // 同樣地就 return
        if (prevState.scrollOffset === scrollOffset) {
          return null;
        }
        // 直接設定 scrollOffset
        return {
          // 滾動的方向
          scrollDirection:
            prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
          scrollOffset: scrollOffset,
          scrollUpdateWasRequested: true,
        };
        // 回撥
      }, this._resetIsScrollingDebounced);
    }

    // 方法同上, 作用是滾動至某一個 item 上面
    scrollToItem(index: number, align: ScrollToAlign = 'auto'): void {
      const { itemCount } = this.props;
      const { scrollOffset } = this.state;

      // 保證 index 在 0 和 item 最大值之間
      index = Math.max(0, Math.min(index, itemCount - 1));

      // 呼叫 scrollTo 方法, 引數是 getOffsetForIndexAndAlignment 的返回值
      // 此函式作用是通過 index 獲取對應 item 的偏移量, 最後通過偏移量滾動至對應的 item
      // 函式通過  createListComponent 的傳參獲取, 不同的 list/grid, 可能有不用的方案
      this.scrollTo(
        getOffsetForIndexAndAlignment(
          this.props,
          index,
          align,
          scrollOffset,
          this._instanceProps
        )
      );
    }

    // mount 所作的事情
    componentDidMount() {
      const { direction, initialScrollOffset, layout } = this.props;

      // initialScrollOffset 是數字且 _outerRef 正常
      if (typeof initialScrollOffset === 'number' && this._outerRef != null) {
        const outerRef = ((this._outerRef: any): HTMLElement);
        // TODO Deprecate direction "horizontal"
        if (direction === 'horizontal' || layout === 'horizontal') {
          outerRef.scrollLeft = initialScrollOffset;
        } else {
          outerRef.scrollTop = initialScrollOffset;
        }
      }

      this._callPropsCallbacks();
    }

    componentDidUpdate() {
      const { direction, layout } = this.props;
      const { scrollOffset, scrollUpdateWasRequested } = this.state;

      if (scrollUpdateWasRequested && this._outerRef != null) {
        const outerRef = ((this._outerRef: any): HTMLElement); // outerRef可以說是最外層元素的 ref 物件

        // 這裡因為版本問題 可能還會去除  direction 的 horizontal 判斷
        if (direction === 'horizontal' || layout === 'horizontal') {
          if (direction === 'rtl') {
            // 針對不同的型別 來左右滾動至最 scrollOffset 的偏移量
            switch (getRTLOffsetType()) {
              case 'negative':
                outerRef.scrollLeft = -scrollOffset;
                break;
              case 'positive-ascending':
                outerRef.scrollLeft = scrollOffset;
                break;
              default:
                const { clientWidth, scrollWidth } = outerRef;
                outerRef.scrollLeft = scrollWidth - clientWidth - scrollOffset;
                break;
            }
          } else {
            outerRef.scrollLeft = scrollOffset;
          }
        } else {
          // 針對上下的滾動
          outerRef.scrollTop = scrollOffset;
        }
      }

      // 呼叫此函式
      // 作用是:  快取節點, 滾動狀態等資料
      this._callPropsCallbacks();
    }

    // 元件離開時清空定時器
    componentWillUnmount() {
      if (this._resetIsScrollingTimeoutId !== null) {
        cancelTimeout(this._resetIsScrollingTimeoutId);
      }
    }

    // 渲染函式
    render() {
      const {
        children,
        className,
        direction,
        height,
        innerRef,
        innerElementType,
        innerTagName,
        itemCount,
        itemData,
        itemKey = defaultItemKey,
        layout,
        outerElementType,
        outerTagName,
        style,
        useIsScrolling,
        width,
      } = this.props;
      // 是否滾動
      const { isScrolling } = this.state;

      // direction "horizontal"  相容老資料
      const isHorizontal =
        direction === 'horizontal' || layout === 'horizontal';

      // 當滾動時的回撥, 針對不同方向
      const onScroll = isHorizontal
        ? this._onScrollHorizontal
        : this._onScrollVertical;

      // 返回節點的範圍 [真實起點, 真實終點]
      const [startIndex, stopIndex] = this._getRangeToRender();

      const items = [];
      if (itemCount > 0) {
        // 迴圈所有 item 數來建立 item, createElement 傳遞引數
        for (let index = startIndex; index <= stopIndex; index++) {
          items.push(
            createElement(children, {
              data: itemData,
              key: itemKey(index, itemData),
              index,
              isScrolling: useIsScrolling ? isScrolling : undefined,
              style: this._getItemStyle(index), // render 時獲取 style
            })
          );
        }
      }

      
      // getEstimatedTotalSize來自 父函式 props
      // 在專案被建立後讀取這個值,因此它們的實際尺寸(如果是可變的)被考慮在內
      const estimatedTotalSize = getEstimatedTotalSize(
        this.props,
        this._instanceProps
      );

      // 動態, 可配置性地建立元件
      return createElement(
        outerElementType || outerTagName || 'div',
        {
          className,
          onScroll,
          ref: this._outerRefSetter,
          style: {
            position: 'relative',
            height,
            width,
            overflow: 'auto',
            WebkitOverflowScrolling: 'touch',
            willChange: 'transform', // 提前優化, 相當於整體包裝
            direction,
            ...style,
          },
        },
        createElement(innerElementType || innerTagName || 'div', {
          children: items,
          ref: innerRef,
          style: {
            height: isHorizontal ? '100%' : estimatedTotalSize,
            pointerEvents: isScrolling ? 'none' : undefined,
            width: isHorizontal ? estimatedTotalSize : '100%',
          },
        })
      );
    }

    _callOnItemsRendered: (
      overscanStartIndex: number,
      overscanStopIndex: number,
      visibleStartIndex: number,
      visibleStopIndex: number
    ) => void;
    // 作用 , 快取最新的這四份資料
    _callOnItemsRendered = memoizeOne(
      (
        overscanStartIndex: number,
        overscanStopIndex: number,
        visibleStartIndex: number,
        visibleStopIndex: number
      ) =>
        ((this.props.onItemsRendered: any): onItemsRenderedCallback)({
          overscanStartIndex,
          overscanStopIndex,
          visibleStartIndex,
          visibleStopIndex,
        })
    );

    // 快取這 3 個資料
    _callOnScroll: (
      scrollDirection: ScrollDirection,
      scrollOffset: number,
      scrollUpdateWasRequested: boolean
    ) => void;
    _callOnScroll = memoizeOne(
      (
        scrollDirection: ScrollDirection,
        scrollOffset: number,
        scrollUpdateWasRequested: boolean
      ) =>
        ((this.props.onScroll: any): onScrollCallback)({
          scrollDirection,
          scrollOffset,
          scrollUpdateWasRequested,
        })
    );

    _callPropsCallbacks() {
      // 判斷來自 props 的 onItemsRendered是否是函式
      if (typeof this.props.onItemsRendered === 'function') {
        const { itemCount } = this.props;
        if (itemCount > 0) {
          // 總的數量大於 0 時
          // 從_getRangeToRender獲取節點的範圍
          const [
            overscanStartIndex, // 真實的起點
            overscanStopIndex, // 真實的終點
            visibleStartIndex, // 檢視的起點
            visibleStopIndex, // 檢視的終點
          ] = this._getRangeToRender();

          // 呼叫 _callOnItemsRendered, 更新快取
          this._callOnItemsRendered(
            overscanStartIndex,
            overscanStopIndex,
            visibleStartIndex,
            visibleStopIndex
          );
        }
      }

      // 如果傳遞了 onScroll 函式過來
      if (typeof this.props.onScroll === 'function') {
        const {
          scrollDirection,
          scrollOffset,
          scrollUpdateWasRequested,
        } = this.state;
        // 呼叫此函式, 作用同樣是快取資料
        this._callOnScroll(
          scrollDirection,
          scrollOffset,
          scrollUpdateWasRequested
        );
      }
    }

    // 在滾動時 lazy 地建立和快取專案的樣式,
    // 這樣 pure 元件的就可以防止重新渲染。
    // 維護這個快取,並傳遞一個props而不是index,
    // 這樣List就可以清除快取的樣式並在必要時強制重新渲染專案
    _getItemStyle: (index: number) => Object;
    _getItemStyle = (index: number): Object => {
      const { direction, itemSize, layout } = this.props;

      // 快取 , itemSize, layout, direction 有改變 也會造成快取清空
      const itemStyleCache = this._getItemStyleCache(
        shouldResetStyleCacheOnItemSizeChange && itemSize,
        shouldResetStyleCacheOnItemSizeChange && layout,
        shouldResetStyleCacheOnItemSizeChange && direction
      );

      let style;
      // 有快取則取快取, 注意 hasOwnProperty 和 in  [index] 的區別
      if (itemStyleCache.hasOwnProperty(index)) {
        style = itemStyleCache[index];
      } else {
        // getItemOffset 和 getItemSize 來自父函式 props
        const offset = getItemOffset(this.props, index, this._instanceProps);
        const size = getItemSize(this.props, index, this._instanceProps);

        const isHorizontal =
          direction === 'horizontal' || layout === 'horizontal';

        const isRtl = direction === 'rtl';
        const offsetHorizontal = isHorizontal ? offset : 0;

        // 快取 index:{} 至 itemStyleCache 物件

        itemStyleCache[index] = style = {
          position: 'absolute',
          left: isRtl ? undefined : offsetHorizontal,
          right: isRtl ? offsetHorizontal : undefined,
          top: !isHorizontal ? offset : 0,
          height: !isHorizontal ? size : '100%',
          width: isHorizontal ? size : '100%',
        };
      }

      return style;
    };

    _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache;
    _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({}));

    _getRangeToRender(): [number, number, number, number] {
      // 數量相關資料
      const { itemCount, overscanCount } = this.props;
      // 是否滾動, 滾動方向, 滾動距離
      const { isScrolling, scrollDirection, scrollOffset } = this.state;

      // 如果數量為 0  則 return
      if (itemCount === 0) {
        return [0, 0, 0, 0];
      }

      // 開始的x序號  getStartIndexForOffset 來源於 閉包傳遞, 通過距離來獲取序號 
      const startIndex = getStartIndexForOffset(
        this.props,
        scrollOffset,
        this._instanceProps
      );
      // 結束的序號, 作用同上, 但是獲取的是結束的序號
      const stopIndex = getStopIndexForStartIndex(
        this.props,
        startIndex,
        scrollOffset,
        this._instanceProps
      );

      // 超出的範圍的數量, 前, 後 兩個變數
      const overscanBackward =
        !isScrolling || scrollDirection === 'backward'
          ? Math.max(1, overscanCount)
          : 1;
      const overscanForward =
        !isScrolling || scrollDirection === 'forward'
          ? Math.max(1, overscanCount)
          : 1;

      // 最終返回資料, [開始的節點序號-超出的節點,結束的節點序號+超出的節點, 開始的節點序號, 結束的節點序號]
      return [
        Math.max(0, startIndex - overscanBackward),
        Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
        startIndex,
        stopIndex,
      ];
    }

    // 大體作用會和 _onScrollVertical 類似
    _onScrollHorizontal = (event: ScrollEvent): void => {
      const { clientWidth, scrollLeft, scrollWidth } = event.currentTarget;
      this.setState(prevState => {
        if (prevState.scrollOffset === scrollLeft) {
          // 如果滾動距離不變
          return null;
        }

        const { direction } = this.props;

        let scrollOffset = scrollLeft;
        if (direction === 'rtl') {
          // 根據方向確定滾動距離
          switch (getRTLOffsetType()) {
            case 'negative':
              scrollOffset = -scrollLeft;
              break;
            case 'positive-descending':
              scrollOffset = scrollWidth - clientWidth - scrollLeft;
              break;
          }
        }

        // 保證距離在範圍之內, 同時 Safari在越界時會有晃動
        scrollOffset = Math.max(
          0,
          Math.min(scrollOffset, scrollWidth - clientWidth)
        );

        return {
          isScrolling: true,
          scrollDirection:
            prevState.scrollOffset < scrollLeft ? 'forward' : 'backward',
          scrollOffset,
          scrollUpdateWasRequested: false,
        };
      }, this._resetIsScrollingDebounced);
    };

    // 同上 , 這裡就不多說了
    _onScrollVertical = (event: ScrollEvent): void => {
      const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
      this.setState(prevState => {
        if (prevState.scrollOffset === scrollTop) {
          return null;
        }

        const scrollOffset = Math.max(
          0,
          Math.min(scrollTop, scrollHeight - clientHeight)
        );

        return {
          isScrolling: true,
          scrollDirection:
            prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
          scrollOffset,
          scrollUpdateWasRequested: false,
        };
      }, this._resetIsScrollingDebounced);
    };

    _outerRefSetter = (ref: any): void => {
      const { outerRef } = this.props;

      this._outerRef = ((ref: any): HTMLDivElement);

      if (typeof outerRef === 'function') {
        outerRef(ref);
      } else if (
        outerRef != null &&
        typeof outerRef === 'object' &&
        outerRef.hasOwnProperty('current')
      ) {
        outerRef.current = ref;
      }
    };

    _resetIsScrollingDebounced = () => {
      // 避免同一時間多次呼叫 此函式, 起到一個節流的作用
      if (this._resetIsScrollingTimeoutId !== null) {
        cancelTimeout(this._resetIsScrollingTimeoutId);
      }

      // requestTimeout 是一個工具函式, 在延遲 IS_SCROLLING_DEBOUNCE_INTERVAL = 150 ms 之後執行, 類似 setTimeout, 但是為什麼不直接使用
      // 引出額外的問題 setTimeout和requestAnimationFrame 的區別, 有興趣的可以自行了解
      this._resetIsScrollingTimeoutId = requestTimeout(
        this._resetIsScrolling,
        IS_SCROLLING_DEBOUNCE_INTERVAL
      );
    };

    _resetIsScrolling = () => {
      // 執行的時候清空id
      this._resetIsScrollingTimeoutId = null;

      this.setState({ isScrolling: false }, () => {
        // 在狀態更新操作
        // 避免isScrolling的影響
        //  _getItemStyleCache 的具體作用, 他是一個經過 memoizeOne 過的函式
        // 而 memoizeOne 是來源於`memoize-one`倉庫 https://www.npmjs.com/package/memoize-one
        // 用處是快取最近的一個結果 而這裡是返回一個空物件
        // 在更新後清空 style
        this._getItemStyleCache(-1, null);
      });
    };
  };
}

FixedSizeList

這個元件就是通過 createListComponent 來建立的最終結果:


const FixedSizeList = createListComponent({
    // 前三個引數都十分簡單, 
    getItemOffset: ({itemSize}: Props<any>, index: number): number =>
        index * ((itemSize: any): number),

    getItemSize: ({itemSize}: Props<any>, index: number): number =>
        ((itemSize: any): number),

    getEstimatedTotalSize: ({itemCount, itemSize}: Props<any>) =>
        ((itemSize: any): number) * itemCount,

    //  通過  index 算出 offset 距離, 是一個比較 pure 的計算函式
  getOffsetForIndexAndAlignment: (
    { direction, height, itemCount, itemSize, layout, width }: Props<any>,
    index: number,
    align: ScrollToAlign,
    scrollOffset: number
  ): number => {
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
    const size = (((isHorizontal ? width : height): any): number);
    const lastItemOffset = Math.max(
      0,
      itemCount * ((itemSize: any): number) - size
    );
    const maxOffset = Math.min(
      lastItemOffset,
      index * ((itemSize: any): number)
    );
    const minOffset = Math.max(
      0,
      index * ((itemSize: any): number) - size + ((itemSize: any): number)
    );

//  針對不同的 align 變數 做出不同應對
    if (align === 'smart') {
      if (
        scrollOffset >= minOffset - size &&
        scrollOffset <= maxOffset + size
      ) {
        align = 'auto';
      } else {
        align = 'center';
      }
    }

    switch (align) {
      case 'start':
        return maxOffset;
      case 'end':
        return minOffset;
      case 'center': {
        const middleOffset = Math.round(
          minOffset + (maxOffset - minOffset) / 2
        );
        if (middleOffset < Math.ceil(size / 2)) {
          return 0; // 開始
        } else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
          return lastItemOffset;  //結束的位置
        } else {
          return middleOffset;
        }
      }
      case 'auto':
      default:
        if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
          return scrollOffset;
        } else if (scrollOffset < minOffset) {
          return minOffset;
        } else {
          return maxOffset;
        }
    }
  },

  getStartIndexForOffset: (
    { itemCount, itemSize }: Props<any>,
    offset: number
  ): number =>
    Math.max(
      0,
      Math.min(itemCount - 1, Math.floor(offset / ((itemSize: any): number)))
    ),

  // 獲取開始和結束的 index
  getStopIndexForStartIndex: (
    { direction, height, itemCount, itemSize, layout, width }: Props<any>,
    startIndex: number,
    scrollOffset: number
  ): number => {
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
    const offset = startIndex * ((itemSize: any): number);
    const size = (((isHorizontal ? width : height): any): number);
    const numVisibleItems = Math.ceil(
      (size + scrollOffset - offset) / ((itemSize: any): number)
    );
    return Math.max(
      0,
      Math.min(
        itemCount - 1,
        startIndex + numVisibleItems - 1
      )
    );
  },

    // 預設空
    initInstanceProps(props: Props<any>): any {
        // Noop
    },

    // 是否在滾動完畢後重置快取
    shouldResetStyleCacheOnItemSizeChange: true,

    // 驗證引數, 只在 dev 情況下有用估忽略
    validateProps: ({itemSize}: Props<any>): void => {
    },
});

通過前面 List demo 級別的呼叫, 我們就很容易來建立一個簡單的虛擬列表

擴充套件點

FixedSizeList 只是一種簡單的虛擬列表情況 在 react-window 中還會適配以下情況

  • VariableSizeList 可適配不同 item 的高度(寬度)的情況, 但是需要傳遞一個引數來給予資訊
  • FixedSizeGrid 支援雙向的滾動, 荷香縱向都是虛擬列表, 這種情況在 table 裡可能會多一點
  • VariableSizeGrid 不同高度(寬度)的雙向滾動虛擬列表

原理都是大同小異, 這裡就不過多說明

筆記倉庫

https://github.com/Grewer/rea...

參考

相關文章