大數據列表渲染系列(二)極簡實現

本節,咱們實現一個極簡版的虛擬列表,固定尺寸的虛擬列表,麻雀雖小,倒是五臟俱全哦!html

需求

實現一個固定尺寸的虛擬渲染列表組件,props屬性以下:react

props: { 
    width: number; 
    height: number;
    itemCount: number;
    itemSize: number;
}

使用方式:app

const Row = (..args) => (<div className="Row"></div>);
<List className={"List"} width={300} height={300} itemCount={10000} itemSize={40}>
    {Row}
</List>

實現

什麼技術棧均可以,這裏項目使用的react,那就選用react來實現。dom

初始化項目

使用create-react-app初始化一個應用,而後啓動,清理掉demo的代碼。函數

虛擬列表

根據上一節的分析,咱們核心技術實現是 一個render渲染函數,用來渲染數據;一個onScroll函數監聽滾動事件,去更新 數據區間[startIndex, endIndex],而後從新render。大概僞代碼以下:this

class List extends React.PureComponent {
  state = {};
  render() {};
  onScroll() {};
}

接下來咱們進行細節填充實現,首先咱們須要根據數據渲染出第一屏初始化的dom,即要先實現render函數邏輯,咱們採用絕對定位的方式進行dom排版。code

render() {
    // 從props解析屬性
    const {
      children,
      width,
      height,
      itemCount,
      layout,
      itemKey = defaultItemKey,
    } = this.props;
    // 預留方向設定屬性
    const isHorizontal = layout === "horizontal";
    // 假設有一個函數_getRangeToRender能夠幫咱們計算出 渲染區間
    const [startIndex, stopIndex] = this._getRangeToRender();
    const items = [];
    if (itemCount > 0) {
      // 循環建立元素
      for (let index = startIndex; index <= stopIndex; index++) {
        items.push(
          createElement(children, {
            data: {},
            key: itemKey(index),
            index,
            style: this._getItemStyle(index), // 幫助計算dom的位置樣式
          })
        );
      }
    }
    // 假設getEstimatedTotalSize函數能夠幫助咱們計算出總尺寸
    const estimatedTotalSize = getEstimatedTotalSize(
      this.props,
    );
    return createElement(
      "div",
      {
        onScroll: this.onScroll,
        style: {
          position: "relative",
          height,
          width,
          overflow: "auto",
          WebkitOverflowScrolling: "touch",
          willChange: "transform",
        },
      },
      createElement("div", {
        children: items,
        style: {
          height: isHorizontal ? "100%" : estimatedTotalSize,
          pointerEvents: "none",
          width: isHorizontal ? estimatedTotalSize : "100%",
        },
      })
    );
}

OK,到了這裏render函數的邏輯就寫完了,是否是超級簡單。接下來咱們實現如下 render函數裏面使用到的輔助函數.orm

getEstimatedTotalSize

先看getEstimatedTotalSize計算總尺寸函數的實現:htm

// 總尺寸 = 總個數 * 每一個size 
export const getEstimatedTotalSize = ({ itemCount, itemSize }) =>
  itemSize * itemCount;

_getRangeToRender

計算須要渲染的數據區間函數實現索引

_getRangeToRender() {
    // overscanCount是緩衝區的數量,默認設置1
    const { itemCount, overscanCount = 1 } = this.props;
    // 已經滾動的距離,初始默認0
    const { scrollOffset } = this.state;

    if (itemCount === 0) {
      return [0, 0, 0, 0];
    }
    // 輔助函數,根據 滾動距離計算出 區間開始的索引
    const startIndex = getStartIndexForOffset(
      this.props,
      scrollOffset,
    );
    // 輔助函數,根據 區間開始的索引計算出 區間結束的索引
    const stopIndex = getStopIndexForStartIndex(
      this.props,
      startIndex,
      scrollOffset,
    );
    return [
      Math.max(0, startIndex - overscanCount),
      Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)),
      startIndex,
      stopIndex,
    ];
  }
}


// 計算區間開始索引,滾動距離 除以 每一個單元尺寸 就是  startIndex
export const getStartIndexForOffset = ({ itemCount, itemSize }, offset) =>
  Math.max(0, Math.min(itemCount - 1, Math.floor(offset / itemSize)));
// 計算區間結束索引,開始索引 + 可見區域size / itemSize 便可
export const getStopIndexForStartIndex = (
  { height, itemCount, itemSize, layout, width },
  startIndex,
  scrollOffset
) => {
  const isHorizontal = layout === "horizontal";
  const offset = startIndex * itemSize;
  const size = isHorizontal ? width : height;
  const numVisibleItems = Math.ceil((size + scrollOffset - offset) / itemSize);
  return Math.max(
    0,
    Math.min(
      itemCount - 1,
      startIndex + numVisibleItems - 1
    )
  );
};

計算元素位置 _getItemStyle

計算方式:根據index * itemSize 便可計算出position

_getItemStyle = (index) => {
    const { layout } = this.props;

    let style;

    const offset = index * itemSize;
    const size = itemSize;

    const isHorizontal = layout === "horizontal";
    
    const offsetHorizontal = isHorizontal ? offset : 0;
    style = {
      position: "absolute",
      left: offsetHorizontal,
      top: !isHorizontal ? offset : 0,
      height: !isHorizontal ? size : "100%",
      width: isHorizontal ? size : "100%",
    };

    return style;
  };

好了,到此位置,render函數的全部邏輯所有實現完畢了。

監聽滾動onScroll實現

最後一步,只須要監聽onScroll事件,更新 數據索引區間,咱們的功能就完善了

// 很是簡單,只是一個 setState操做,更新滾動距離便可
 _onScrollVertical = (event) => {
    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 {
        scrollOffset,
      };
    });
  };

完整代碼

class List extends PureComponent {
  _outerRef;

  static defaultProps = {
    layout: "vertical",
    overscanCount: 2,
  };

  state = {
    instance: this,
    scrollDirection: "forward",
    scrollOffset: 0,
  };
  render() {
    const {
      children,
      width,
      height,
      itemCount,
      layout,
      itemKey = defaultItemKey,
    } = this.props;

    const isHorizontal = layout === "horizontal";

    // 監聽滾動函數
    const onScroll = isHorizontal
      ? this._onScrollHorizontal
      : this._onScrollVertical;

    const [startIndex, stopIndex] = this._getRangeToRender();
    const items = [];
    if (itemCount > 0) {
      for (let index = startIndex; index <= stopIndex; index++) {
        items.push(
          createElement(children, {
            data: {},
            key: itemKey(index),
            index,
            style: this._getItemStyle(index),
          })
        );
      }
    }
    const estimatedTotalSize = getEstimatedTotalSize(
      this.props
    );
    return createElement(
      "div",
      {
        onScroll,
        style: {
          position: "relative",
          height,
          width,
          overflow: "auto",
          WebkitOverflowScrolling: "touch",
          willChange: "transform",
        },
      },
      createElement("div", {
        children: items,
        style: {
          height: isHorizontal ? "100%" : estimatedTotalSize,
          pointerEvents: "none",
          width: isHorizontal ? estimatedTotalSize : "100%",
        },
      })
    );
  }
  _onScrollHorizontal = (event) => {};
  _onScrollVertical = (event) => {
    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 {
        scrollOffset,
      };
    });
  };

  _getItemStyle = (index) => {
    const { layout } = this.props;

    let style;

    const offset = getItemOffset(this.props, index, this._instanceProps);
    const size = getItemSize(this.props, index, this._instanceProps);

    const isHorizontal = layout === "horizontal";

    const offsetHorizontal = isHorizontal ? offset : 0;
    style = {
      position: "absolute",
      left: offsetHorizontal,
      top: !isHorizontal ? offset : 0,
      height: !isHorizontal ? size : "100%",
      width: isHorizontal ? size : "100%",
    };

    return style;
  };

  // 計算出須要渲染的數據索引區間
  _getRangeToRender() {
    const { itemCount, overscanCount = 1 } = this.props;
    const { scrollOffset } = this.state;

    if (itemCount === 0) {
      return [0, 0, 0, 0];
    }

    const startIndex = getStartIndexForOffset(
      this.props,
      scrollOffset
    );
    const stopIndex = getStopIndexForStartIndex(
      this.props,
      startIndex,
      scrollOffset
    );

    return [
      Math.max(0, startIndex - overscanCount),
      Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)),
      startIndex,
      stopIndex,
    ];
  }
}
相關文章
相關標籤/搜索