简单的高性能虚拟列表1.0

简单的高性能虚拟列表1.0


简单的高性能虚拟列表1.0

仅渲染start-end直接的数据 使页面的节点保持尽可能的少,这样不会造成过重渲染重绘开销。

这种模式即使是百万级的数据量也可以做到实时渲染

容器层

.container {
  background-color: #323e52;
  width: 100%;
  height: 100%;
}

.tools {
  padding: 10px;
  color: #fff;
}

.item {
  min-height: 12px;
  background: #181f29;
  width: 100%;
  padding: 2px;
  margin: 2px;
  color: #fff;
  font-size: 12px;
}

import * as React from 'react';

import { randomData } from './helper';
import { IData, IListOption } from './type';

import { Scrollbars } from './Component';

import './index.css';

interface IProps {}
interface IStates {
  dataSource: IData[];
  range: number;
}

class VirtualList extends React.PureComponent<IProps, IStates> {
  private scrollRef: React.RefObject<Scrollbars<IData>> = React.createRef();
  constructor(props: IProps) {
    super(props);
    this.state = { dataSource: randomData(10000), range: 0 };
  }

  cellRender = (node: IData, { index }: IListOption) => {
    return (
      <div className="item">
        序号:{index}-{node.name}
      </div>
    );
  };

  handleScroll = (): void => {
    const { range } = this.state;
    this.scrollRef.current.scrollToRow(range);
  };

  render(): React.ReactNode {
    const { dataSource, range } = this.state;
    return (
      <div className="container">
        <div className="tools">
          <span>总数据量:{dataSource.length}</span>
          <input
            type="range"
            value={range}
            min={0}
            max={dataSource.length - 1}
            onChange={(e) => {
              this.setState({ range: Number(e.target.value) });
            }}
          />
          {range}
          <button onClick={this.handleScroll}>滚动到</button>
        </div>

        <Scrollbars
          ref={this.scrollRef}
          style={{ width: 200, height: 500 }}
          className="custom-virtual-list"
          itemHeight={40}
          dataSource={dataSource}
          cellRender={this.cellRender}
        />
      </div>
    );
  }
}

export default VirtualList;

滚动组件

.virtual-list-scroll {
  overflow: auto;
  position: relative;
  scroll-behavior: smooth;
  will-change: transform;
  background-color: #202735;
}
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; // 滚动的样式
  className?: string;
  itemHeight?: number;
  dataSource: T[]; // 数据
  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 phantomHeight = 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.phantomHeight = this.cachedPositions[this.cachedPositions.length - 1].bottom;
    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,
        dValue: 0,
      };
    }
  };

  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,
      phantomHeight,
      state: { cachedPositions },
      props: { className, style, dataSource, cellRender },
    } = this;

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

export default ScrollBar;

滚动区

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

import Cell from './Cell';
interface IProps<T> {
  contentHeight: number;
  dataSource: T[];
  start: number;
  end: number;
  cache: IListCachedPosition[];
  cellRender: (node: T, option: IListOption) => React.ReactNode;
}
interface IStates {}

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

export default Grid;

节点

.virtual-list-cell {
  width: 100%;
  position: absolute;
  display: flex;
}

import * as React from 'react';
import { IListCachedPosition } from '../type';

import './Cell.css';
interface IProps {
  index: number;
  cache: IListCachedPosition;
  onMount?: (dom: Element, index: number) => void;
}
interface IStates {}

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

export default Cell;

数据函数

interface IData {
  name: string;
  id: number;
}

interface IListOption {
  index: number;
}

interface IListCachedPosition {
  index: number; // 当前pos对应的元素的下标
  top: number; // 顶部位置
  bottom: number; // 底部位置
  height: number; // 元素高度
  dValue: number; // 高度是否和之前(estimate)存在不同
}

enum CompareResult {
  eq = 1,
  lt,
  gt,
}

export { IData, IListOption, IListCachedPosition, CompareResult };

import { CompareResult, IData } from './type';

function randomSingle(): IData {
  return { name: `${Math.round(Math.random() * 1e5)}`, id: Math.random() * 1e5 };
}

function randomData(seed: number): IData[] {
  return new Array(seed).fill(1).map(() => randomSingle());
}

function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;

  while (start <= end) {
    tempIndex = Math.floor((start + end) / 2);
    const midValue = list[tempIndex];
    const compareRes: CompareResult = compareFunc(midValue, value);

    if (compareRes === CompareResult.eq) {
      return tempIndex;
    }

    if (compareRes === CompareResult.lt) {
      start = tempIndex + 1;
    } else if (compareRes === CompareResult.gt) {
      end = tempIndex - 1;
    }
  }

  return tempIndex;
}

export { randomSingle, randomData, binarySearch };

上一篇:react延迟加载


下一篇:React开发入门