如何实现一个可以加载10万+数据的长列表(一)

普通的长列表就是直接生成对应的dom元素节点,但10万加的数据需要生成10万加个元素。我们先来看看浏览器渲染一万条数据的性能:
在这里插入图片描述

从上图可以看到 React 渲染 10000 个节点数据使用了 2 秒时间。

黄色段 生成数据节点使用了 1.3秒
蓝色段 Layout计算 使用了0.7秒

初始化加载表单就需要两秒 显然不可接受。

在看一下优化后的长列表方案性能:
在这里插入图片描述
黄色段 生成节点 使用57毫秒
蓝色段 Layout计算 1.26毫秒

为什么差异如此之大。实际上是因为节点过多对于浏览器而言是有额外的计算开销的。

那么如何实现一个有效的长列表呢?

别急,我们接着讲。

在这里插入图片描述

为了节省节点数量,我们仅渲染可视区内部的节点。即开始索引结束索引之间的内容。通过控制开始索引和结束索引来控制和切换显示的数据消息。

通过 onScroll 事件来更新起始索引和结束索引。

具体代码如下:

 render(): React.ReactNode {
    const { start, end, itemHeight, dataSource, cellRender } = this.props;
    return (
      <>
        <div className="virtual-list-grid-base" style={{ height: dataSource.length * itemHeight }}>
          {dataSource.map((item, index) => {
            return (
              index >= start &&
              index <= end && (
                <Cell key={index} index={index} itemHeight={itemHeight}>
                  {cellRender(item, { index })}
                </Cell>
              )
            );
          })}
        </div>
      </>
    );
  }
handleScroll = (): void => {
    const {
      total,
      limit,
      originStartIdx,
      props: { overScan },
    } = this;

    const scrollTop = this.domRef.current.scrollTop;

    const currIndex = this.getStartIndex(scrollTop);

    if (originStartIdx !== currIndex) {
      this.originStartIdx = currIndex;
      this.startIndex = Math.max(currIndex - overScan, 0);
      this.endIndex = Math.min(currIndex + limit + overScan, total - 1);

      this.setState({
        scrollTop: scrollTop,
      });
    }
  }

如此便实现了一个简版的长列表渲染

代码

滚动区

import * as React from 'react';
import { binarySearch } from '../../helper';
import { CompareResult, IListCachedPosition, IListOption } from '../../type';
import Grid from './Grid';

import './ScrollBar.css';

interface IProps<T> {
  overScan?: number; // 超出的预加载数量
  style?: React.CSSProperties | { width: number; height: number }; // 滚动的样式
  className?: string;
  itemHeight?: number;
  dataSource: T[]; // 数据
  extra?: { [key: string]: any };
  cellRender: (node: T, option: IListOption) => React.ReactNode; // 自定义节点渲染
}

interface IStates<T> {
  scrollTop: number;
  cachedPositions: IListCachedPosition[];
}

/**
 * 虚拟滚动列表
 *
 * 仅显示可见区域的节点
 * TODO: 实现行高自适配
 */
class ScrollBar<T> extends React.Component<React.PropsWithChildren<IProps<T>>, IStates<T>> {
  private domRef: React.RefObject<HTMLDivElement> = React.createRef();
  private cachedPositions: IListCachedPosition[] = []; // 缓存的节点位置列表
  private startIndex = 0;
  private endIndex = 0;
  private originStartIdx = 0;
  private limit = 0;
  private total = 0;
  private estimateMinHeight = 25; // 预估节点高度

  static defaultProps = {
    overScan: 2,
  };

  constructor(props: IProps<T>) {
    super(props);
    this.total = props.dataSource.length;
    this.estimateMinHeight = props.itemHeight ?? this.estimateMinHeight;
    this.initCachedPositions();
    this.state = {
      cachedPositions: this.cachedPositions,
      scrollTop: 0,
    };
    this.originStartIdx = 0;
  }

  componentDidMount(): void {
    const contentHeight = this.domRef.current?.getBoundingClientRect().height;
    this.limit = Math.ceil(contentHeight / this.estimateMinHeight);
    this.startIndex = Math.max(this.originStartIdx - this.props.overScan, 0);
    this.endIndex = Math.min(this.originStartIdx + this.limit + this.props.overScan, this.total - 1);
    this.setState({ scrollTop: this.domRef.current.scrollTop });
  }

  // 初始化cachedPositions
  initCachedPositions = (): void => {
    const { estimateMinHeight } = this;

    this.cachedPositions = [];
    for (let i = 0; i < this.total; ++i) {
      this.cachedPositions[i] = {
        index: i,
        height: estimateMinHeight, // 先使用estimateHeight估计
        top: i * estimateMinHeight, // 同上
        bottom: (i + 1) * estimateMinHeight,
      };
    }
  };

  getStartIndex = (scrollTop = 0): number => {
    let idx = binarySearch<IListCachedPosition, number>(
      this.cachedPositions,
      scrollTop,
      (currentValue: IListCachedPosition, targetValue: number) => {
        const currentCompareValue = currentValue.bottom;
        if (currentCompareValue === targetValue) {
          return CompareResult.eq;
        }

        if (currentCompareValue < targetValue) {
          return CompareResult.lt;
        }

        return CompareResult.gt;
      },
    );

    const targetItem = this.cachedPositions[idx];

    // Incase of binarySearch give us a not visible data(an idx of current visible - 1)...
    if (targetItem.bottom < scrollTop) {
      idx += 1;
    }

    return idx;
  };

  handleScroll = (): void => {
    const {
      total,
      limit,
      originStartIdx,
      props: { overScan },
    } = this;

    const scrollTop = this.domRef.current.scrollTop;

    const currIndex = this.getStartIndex(scrollTop);

    if (originStartIdx !== currIndex) {
      this.originStartIdx = currIndex;
      this.startIndex = Math.max(currIndex - overScan, 0);
      this.endIndex = Math.min(currIndex + limit + overScan, total - 1);

      this.setState({
        scrollTop: scrollTop,
      });
    }
  };

  scrollToRow(index: number): void {
    this.domRef.current.scrollTo({ top: this.cachedPositions[index].top });
  }

  render(): React.ReactNode {
    const {
      startIndex,
      endIndex,
      props: { className, extra, style, dataSource, cellRender },
    } = this;

    return (
      <div
        ref={this.domRef}
        className={`virtual-list-scroll-base ${className}`}
        style={style}
        onScroll={this.handleScroll}
      >
        <Grid
          start={startIndex}
          end={endIndex}
          itemHeight={this.estimateMinHeight}
          dataSource={dataSource}
          extra={extra}
          cellRender={cellRender}
        />
      </div>
    );
  }
}

export default ScrollBar;

加载器

import * as React from 'react';

import { IListOption } from '../../type';

import Cell from './Cell';

interface IProps<T> {
  dataSource: T[];
  start: number; // 起始索引
  end: number; //结束索引
  itemHeight: number; // 节点高度
  extra?: { [key: string]: any };
  cellRender: (node: T, option: IListOption) => React.ReactNode;
}

/**
 * 网格类
 * TODO: 实现多行多列布局
 */
class Grid<T> extends React.PureComponent<IProps<T>> {
  render(): React.ReactNode {
    const { start, end, itemHeight, dataSource, cellRender } = this.props;
    return (
      <>
        <div className="virtual-list-grid-base" style={{ height: dataSource.length * itemHeight }}>
          {dataSource.map((item, index) => {
            return (
              index >= start &&
              index <= end && (
                <Cell key={index} index={index} itemHeight={itemHeight}>
                  {cellRender(item, { index })}
                </Cell>
              )
            );
          })}
        </div>
      </>
    );
  }
}

export default Grid;

节点

import * as React from 'react';

import './Cell.css';

interface IProps {
  index: number;
  itemHeight: number;
  onMount?: (dom: Element, index: number) => void;
}

/**
 * 当行类
 * 自定义的渲染
 */
class Cell extends React.PureComponent<React.PropsWithChildren<IProps>> {
  render(): React.ReactNode {
    const { children, index, itemHeight } = this.props;
    return (
      <div
        className="virtual-list-cell-base"
        id={`virtual-list-cell-base-${index}`}
        style={{ height: itemHeight, top: index * itemHeight }}
      >
        {children}
      </div>
    );
  }
}

export default Cell;

如上,我们就已经实现了一个固定节点高度的长列表了。

但很多情况节点并不是固定高度怎么办?

这个问题后面继续讲解。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值