Skip to content

问题:

  1. 固定高度的虚拟列表实现思路?
    1. 最外层盒子设置overflow:auto,relative定位,
    2. 内层盒子根据高度计算出总高度,
    3. 子元素根据滚动条高度计算startIndex以及endIndex,从而截取可视区域数据,
    4. 设置marginTop或top属性性将数据展示在可视区域;
  2. 不固定高度的虚拟列表实现思路?

item固定高度情况

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

interface VirtualItemProps {
  style: React.CSSProperties;
  children: React.ReactNode;
}

const VirtualItem: React.FC<VirtualItemProps> = ({ style, children }) => (
  <div style={style}>{children}</div>
);

interface VirtualListProps {
  height: number;
  itemSize: number;
  children: React.ReactNode;
}

const VirtualList: React.FC<VirtualListProps> = (props) => {
  const { children, height, itemSize } = props;

  const contentBoxStyle: React.CSSProperties = {
    height: itemSize * React.Children.count(children),
  };

  const [renderItems, setRenderItems] = useState<React.ReactNode[]>([]);

  function handleScroll(e) {
    console.log(e);
    const { scrollTop, offsetHeight } = e.currentTarget;
    const safeTop = scrollTop - offsetHeight > 0 ? scrollTop - offsetHeight : 0;
    const safeBottom =
      safeTop + offsetHeight * 3 >= (contentBoxStyle.height as number)
        ? contentBoxStyle.height
        : safeTop + offsetHeight * 3;
    const startIndex = Math.floor(safeTop / itemSize);
    const endIndex = Math.floor((safeBottom as number) / itemSize);

    console.log(startIndex, endIndex);

    setRenderItems(() =>
      React.Children.toArray(children)
        .slice(startIndex, endIndex)
        .map((child, index) => {
          console.log('child', child);
          const originalStyle = {
            ...((child as React.ReactElement)?.props?.style || {}),
          };

          return (
            <VirtualItem
              key={(child as React.ReactElement).key}
              style={{
                ...originalStyle,
                position: 'absolute',
                top: itemSize * (startIndex + index),
              }}
            >
              {(child as React.ReactElement).props.children}
            </VirtualItem>
          );
        }),
    );
  }

  useEffect(() => {
    setRenderItems(() =>
      React.Children.toArray(children)
        .slice(0, (height as number) / itemSize * 2)
        .map((child, index) => {
          console.log('child', child);
          const originalStyle = {
            ...((child as React.ReactElement)?.props?.style || {}),
          };

          return (
            <VirtualItem
              key={(child as React.ReactElement).key}
              style={{
                ...originalStyle,
                position: 'absolute',
                top: itemSize * index,
              }}
            >
              {(child as React.ReactElement).props.children}
            </VirtualItem>
          );
        }),
    );
  }, [])
  console.log('children', children);
  return (
    <div
      onScroll={handleScroll}
      style={{ height: height + 'px', overflowY: 'auto', position: 'relative' }}
    >
      <div style={contentBoxStyle}>{renderItems.map((item) => item)}</div>
    </div>
  );
};

export default VirtualList;

/**
 * 
 * 1. 盒子固定高度
 * 2. 盒子不固定高度;
 * 3. 子元素高度也不固定
 * 4. 考虑margin, gap间距问题
 * 5. 添加gap参数,避免用户自行设置间距样式(最后考虑)
 */

使用实例

typescript
<VirtualList height={200} itemSize={50}>
  {list.map((_, item) => {
    return (
      <div
        key={item + 'aaaa'}
        style={{
          width: '100%',
          height: '48px',
          background: '#f8f8f8',
          marginBottom: '2px',
          borderRadius: '3px',
          lineHeight: '32px',
          color: '#ccc',
          padding: '0 30px',
        }}
      >
        <div>{`${item}. ${_.desc}`}</div>
      </div>
    );
  })}
</VirtualList>

高度不固定

javascript
import React, { useState } from 'react';

// 元数据
const measuredData = {
  measuredDataMap: {},
  lastMeasuredItemIndex: -1,
};

const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
  let measuredHeight = 0;
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
  // 计算已经获取过真实高度的项的高度之和
  if (lastMeasuredItemIndex >= 0) {
    const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
  }
  // 未计算过真实高度的项数
  const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
  // 预测总高度
  const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
  return totalEstimatedHeight;
}

const getItemMetaData = (props, index) => {
  const { itemSize, itemEstimatedSize = 50 } = props;
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
  if (index > lastMeasuredItemIndex) {
    let offset = 0;
    // 计算当前能计算出来的最大offset值
    if (lastMeasuredItemIndex >= 0) {
      const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
      offset += lastMeasuredItem.offset + lastMeasuredItem.size;
    }
    // 计算直到index为止,所有未计算过的项
    for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
      const currentItemSize = itemSize ? itemSize(i) : itemEstimatedSize;
      measuredDataMap[i] = { size: currentItemSize, offset };
      offset += currentItemSize;
    }
    // 更新已计算的项的索引值
    measuredData.lastMeasuredItemIndex = index;
  }
  return measuredDataMap[index];
};

const getStartIndex = (props, scrollOffset) => {
  const { itemCount } = props;
  let index = 0;
  while (true) {
    const currentOffset = getItemMetaData(props, index).offset;
    if (currentOffset >= scrollOffset) return index;
    if (index >= itemCount) return itemCount;
    index++
  }
}

const getEndIndex = (props, startIndex) => {
  const { height, itemCount } = props;
  // 获取可视区内开始的项
  const startItem = getItemMetaData(props, startIndex);
  // 可视区内最大的offset值
  const maxOffset = startItem.offset + height;
  // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
  let offset = startItem.offset + startItem.size;
  // 结束索引
  let endIndex = startIndex;
  // 累加offset
  while (offset <= maxOffset && endIndex < (itemCount - 1)) {
    endIndex++;
    const currentItem = getItemMetaData(props, endIndex);
    offset += currentItem.size;
  }
  return endIndex;
};

const getRangeToRender = (props, scrollOffset) => {
  const { itemCount } = props;
  const startIndex = getStartIndex(props, scrollOffset);
  const endIndex = getEndIndex(props, startIndex);
  return [
    Math.max(0, startIndex - 2),
    Math.min(itemCount - 1, endIndex + 2),
    startIndex,
    endIndex,
  ];
};

class ListItem extends React.Component {
  constructor(props) {
    super(props);
    this.domRef = React.createRef();
    this.resizeObserver = null;
  }
  componentDidMount() {
    if (this.domRef.current) {
      const domNode = this.domRef.current.firstChild;
      const { index, onSizeChange } = this.props;
      this.resizeObserver = new ResizeObserver(() => {
        onSizeChange(index, domNode);
      });
      this.resizeObserver.observe(domNode);
    }
  }
  componentWillUnmount() {
    if (this.resizeObserver && this.domRef.current.firstChild) {
      this.resizeObserver.unobserve(this.domRef.current.firstChild);
    }
  }
  render() {
    const { index, style, ComponentType } = this.props;
    return (
      <div style={style} ref={this.domRef}>
        <ComponentType index={index} />
      </div>
    )
  }
}

const VariableSizeList = (props) => {
  const { height, width, itemCount, itemEstimatedSize = 50, children: Child } = props;
  const [scrollOffset, setScrollOffset] = useState(0);
  const [, setState] = useState({});

  const containerStyle = {
    position: 'relative',
    width,
    height,
    overflow: 'auto',
    willChange: 'transform'
  };

  const contentStyle = {
    height: estimatedHeight(itemEstimatedSize, itemCount),
    width: '100%',
  };

  const sizeChangeHandle = (index, domNode) => {
    const height = domNode.offsetHeight;
    const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
    const itemMetaData = measuredDataMap[index];
    itemMetaData.size = height;
    let offset = 0;
    for (let i = 0; i <= lastMeasuredItemIndex; i++) {
      const itemMetaData = measuredDataMap[i];
      itemMetaData.offset = offset;
      offset += itemMetaData.size;
    }
    setState({});
  }
    
  const getCurrentChildren = () => {
    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
    const items = [];
    for (let i = startIndex; i <= endIndex; i++) {
      const item = getItemMetaData(props, i);
      const itemStyle = {
        position: 'absolute',
        height: item.size,
        width: '100%',
        top: item.offset,
      };
      items.push(
        <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />
      );
    }
    return items;
  }

  const scrollHandle = (event) => {
    const { scrollTop } = event.currentTarget;
    setScrollOffset(scrollTop);
  }

  return (
    <div style={containerStyle} onScroll={scrollHandle}>
      <div style={contentStyle}>
        {getCurrentChildren()}
      </div>
    </div>
  );
};

export default VariableSizeList;

存在问题:

  1. 滚动时,滚动条和鼠标拖动的距离不一致,由于初始化高度为默认固定高度*itemCount,后期滚动条滚动时,计算当前偏移量,实际高度与默认高度不一致,所以滚动条会产生偏移;

Released under the MIT License.