mini react-window(二) 实现可知变化高度虚拟列表

2022-10-20 11:59:39 浏览数 (1)

上一小节我们了解了固定高度的滚动列表实现,因为是固定高度所以容器总高度和每个元素的 sizeoffset 很容易得到,这种场景也适合我们常见的大部分场景,例如新闻列表左图右文、会话消息这种。但是也有一些场景是例如有图片,我们的高度是一种,没有是另一种,这种情况也适合一些常见场景即高度可控,本小节我们看下不同子项高度情况下容器的总高度和每个元素的 sizeoffset 如何计算得到。

思路分析

  1. 对于容器总高度来说,因为每个字元素高度不定,而每次也只是渲染可视区内几个元素,所以不能直接写死,我们开始可以先预估一个总高度,最少元素是可以滚动起来的,但我们得到真实的子元素高度后,我们可以动态计算容器总高度,即容器总高度 = 测量过的真是的高度 预估的高度;
  2. 对于单个元素来说,因为我们会传入每个元素的计算方法,所以当元素出现在可视区域内时,我们算出当前元素的 size 和 offset,同时需要把计算过的元素存储起来,避免重复计算,这时我们需要一个索引字段记录那些元素被计算过,索引需要从头开始计算。当前元素下一个元素的 offset 值为当前元素的 offset size。
image.pngimage.png

react-window 库实现效果

代码语言:txt复制
// src/variable-size-list.js

// 固定高度列表

import { VariableSizeList } from "react-window";
import "./index.css";

// 这里给出每个元素的高度计算方式,可以根据自己的业务需求按条件计算,这里简单给出随机获取
const rowSizes = new Array(1000).fill(true).map(() => 25   Math.round(Math.random() * 55))
const getItemSize = index => rowSizes[index]

function Row({ index, style }) {
  return (
    <div className={index % 2 ? "odd" : "even"} style={style}>
      Row {index}
    </div>
  );
}

function App() {
  return (
    <VariableSizeList
      className="list"
      height={200}
      width={200}
      itemSize={getItemSize}
      itemCount={1000}
    >
      {Row}
    </VariableSizeList>
  );
}
export default App;
image.pngimage.png

可以看到每个元素的高度是不同的,对应的 offset 偏移量也没有规律,滚动效果与固定高度的类似,只是渲染可视区域内的元素,上下多渲染两个,避免快速滚动白屏。

image.pngimage.png

实现 VariableSizeList

通过上一小节,我们已经把通用的代码逻辑放到了 createListComponent.js 中了,我们按照上面分析的思路一步步实现

组件模板

代码语言:javascript复制
const VariableSizeList = createListComponent({
  getEstimatedTotalSize: () => 0,
  getItemSize: () => 0,
  getItemOffset: () => 0,
  getStartIndexForOffset: () => 0,
  getEndIndexForOffset: () => 0,
  initInstanceProps: /////
})

初始化属性

大家可以看到这里新加了一个 initInstanceProps 方法,通过上面的实现分析我们知道,我们需要缓存计算过的元素的信息,也要预估起始的元素高度和容器总高度,所以该方法是我们初始化信息用的

代码语言:javascript复制
const DEFAULT_ESTIMATED_SIZE = 50 // 默认高度50
...
initInstanceProps: (props) => {
  const { estimatedItemSize } = props; // 预估的条目高度计算总高度用, 可穿入,不穿入的话我们可以定义默认值
  const instanceProps = {
    estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_SIZE,
    itemMetadataMap: {}, // 记录每个条目的信息 {[index]: {size: 每个索引对应的条目高度,offset: 每个索引对应的 top 值}}
    lastMeasuredIndex: -1, // 渲染过程中真实的测量每个条目的高度,就是计算每个条目真实的 offset 和 size。这个字段就是我们用来记录那条数据被渲染过了,计算过的可以直接用缓存的值
  };

  return instanceProps;
}

这里改造通用的方法,传入初始化方法,新增即可,不会对 FixedSizeList 组件产生副作用

代码语言:javascript复制
import React from "react";

function createListComponent({
  ...
  initInstanceProps
}) {
  // 返回类组件
  return class extends React.Component {
    // 初始化实例属性,接收 props 属性
    instanceProps = initInstanceProps && initInstanceProps(this.props)

    getItemStyle = (i) => {
      const style = {
        position: "absolute",
        width: "100%",
        height: getItemSize(this.props, i, this.instanceProps),
        top: getItemOffset(this.props, i, this.instanceProps),
      };
      return style;
    };

    getRangeToRender = () => {
      ...
      // 这里需要使用 initProps 计算初始索引和结束索引,需要用到 itemMetadataMap 缓存和 lastMeasuredIndex 属性
      const startIndex = getStartIndexForOffset(this.props, scrollOffset, this.instanceProps)
      const endIndex = getEndIndexForOffset(this.props, startIndex, scrollOffset,this.instanceProps)
      return [Math.max(0, startIndex - overscanCount), Math.min(itemCount - 1, endIndex   overscanCount)]
    }
    render() {
      ...
      const contentStyle = {
        width: "100%",
        height: getEstimatedTotalSize(this.props, this.instanceProps),//传入属性,用来动态计算容器总高度
      };
      ...
    }
  };
}

export default createListComponent;

计算起始索引

代码语言:javascript复制
getStartIndexForOffset: (props, scrollOffset, instanceProps) => {
  return findNearestItem(props, scrollOffset, instanceProps)
},

那我们如何能获取到可视区域的开始的索引呢?因为我们定义了 lastMeasuredIndex 用来记录已经缓存的索引,我们正常的使用都是从上到下滚动,即从 0 开始,所以当我们从 0 索引开始计算到某一个元素的 offset 值超过滚动的 scrollTop 时,我们就获得了可视区域内的第一个索引值,即开始索引

代码语言:javascript复制
const findNearestItem = (props, scrollOffset, instanceProps) => {
  const {lastMeasuredIndex} = instanceProps // 获取上一次计算到的缓存索引
  for(let i = 0; i<=lastMeasuredIndex;i  ) { // 从索引 0 开始循环
    // 处理每个元素的 size 和 offset
    const currentOffset = getItemMetadata(props, i, instanceProps,).offset
    // currentOffset 当前条目的 top,
    if (currentOffset >= scrollOffset) {
      return i // 可视区域内 ,起始索引
    }
  }
  // 没有的话默认 0
  return 0
}

这时我们来计算一下获取单个元素的属性值

代码语言:javascript复制
// 获取每个条目对应的元数据  {index: {size, offset}}
const getItemMetadata = (props, index, instanceProps) => {
  // itemsize 自己穿入的函数
  const {itemSize} = props
  const {itemMetadataMap, lastMeasuredIndex} = instanceProps
  /// 当前获取的条目 比上一次测量过的条索引 大,说明此词条目没有测量过(都是从上往下滚动的), 不知道 offset  和 size
  if (index > lastMeasuredIndex) {// 没有缓存过
    // 通过上一个测量过的条目 计算当前的条目的 offset
    let offset = 0
    if (lastMeasuredIndex >= 0) { // lastMeasuredIndex 之前的索引做过缓存
      const itemMetadata = itemMetadataMap[lastMeasuredIndex]
      offset = itemMetadata.offset   itemMetadata.size // 下一条的 offset 值
    }
    for(let i =lastMeasuredIndex   1; i<=index;i  ) {
      let size = itemSize(i)
      // 此条目对应的高度size 和 刚计算的 offset 值存储
      itemMetadataMap[i] = {
        offset,
        size
      }
      offset  = size // 下一个条目的offset 是 当前的offset   size
    }
    // 重新定义
    instanceProps.lastMeasuredIndex = index
  }
  // 虽然返回的事当前索引的信息,但是其实 <= index 的元素信息都已经被计算存储了
  return itemMetadataMap[index]
}

getItemMetadata 方法我们要好好看一下,计算每一个元素的 sizeoffset,对应的剩下的几个方法都需要该方法进行计算

计算结束索引

结束索引需要从开始索引开始计算,在可视区域高度内,两种情况: 一种是到了最后一个元素,一种是计算到的 offset 值超出可视区高度和起始索引下一个元素的偏移量时可以得到结束索引:

代码语言:javascript复制
getEndIndexForOffset: (props, startIndex, scrollOffset, instanceProps) => {
  // 拿到可视区域的高度和元素数量
  const {height, itemCount} = props
  // 获取开始索引对应的元数据 ,开始索引的 offset 和 size
  const itemMetadata = getItemMetadata(props, startIndex, instanceProps)
  // 最大的 offset 值
  const maxOffset = itemMetadata.offset   height
  // startIndex 下一个元素的 offset 值
  let offset = itemMetadata.offset   itemMetadata.size

  let stopIndex = startIndex
  // 因为不确定可是区域内多少元素,所以需要从当前开始每次加下一个元素进行计算
  while(stopIndex < itemCount-1 && offset<maxOffset) {
    stopIndex  
    offset  = getItemMetadata(props, stopIndex, instanceProps).size // 加每个条目高度
  }
  // 当超出总数量或者 offset 偏移量超出 maxOffset 时,抛出
  return stopIndex
},

计算当前元素 size 和 offset

代码语言:javascript复制
getItemSize: (props, index, instanceProps) => {
  return getItemMetadata(props, index, instanceProps).size
},
getItemOffset: (props, index, instanceProps) => {
  return getItemMetadata(props, index, instanceProps).offset
},

动态计算容器高度

前面我们分析过,我们先预估一个整体的滚动高度,然后根据实际计算的子元素高度和再去重新计算:

代码语言:javascript复制
// 计算或者预估内容总高度  撑起来,出现滚动条
const getEstimatedTotalSize = ({ itemCount }, { estimatedItemSize, lastMeasuredIndex ,itemMetadataMap}) => {
  // 测量过的真是高度   未测量的预估高度
  let totalSizeOfMeasuredItems = 0 // 测量过的总高度
  if (lastMeasuredIndex >= 0) {
    const itemMetadata = itemMetadataMap[lastMeasuredIndex]
    // 我们只需要知道最后一个测量的元素即可知道实际测量的偏移量
    totalSizeOfMeasuredItems = itemMetadata.offset   itemMetadata.size
  }
  const numUnMeasuredItems = itemCount - lastMeasuredIndex - 1; // 未测量过的条目数量
  const totalSizeOfUnmesuredItems = numUnMeasuredItems * estimatedItemSize; // 未测量过的条目的总高度
  
  // 总高度 = 实际测量过的高度   预估的高度
  return totalSizeOfUnmesuredItems   totalSizeOfMeasuredItems;
};

看下我们自己实现的效果:

image.pngimage.png
image.pngimage.png

优化

我们在查找起始索引的时候使用的线性遍历,从索引 0 开始计算,这样很容易理解,在官方库里这里使用的二分查找,一个是 O(n), 一个是 O(logn), 我们这里把线性查找换成二分如下:

代码语言:javascript复制
const findNearestItem = (props, scrollOffset, instanceProps) => {
  const {lastMeasuredIndex} = instanceProps
  // 这里是 从 0 开始到 lastMeasuredIndex 进行分割查找,每次查找会少一半
  return findNearestItemBinarySearch(props, instanceProps, lastMeasuredIndex, 0, scrollOffset)
}

const findNearestItemBinarySearch = (props, instanceProps, high, low, offset) => {
  while(low <= high) {
    const middle = low   Math.floor((high-low) / 2)
    const currentOffset = getItemMetadata(props, middle, instanceProps).offset
    if (currentOffset === offset) {
      return middle
    } else if (currentOffset < offset) {
      low = middle   1
    } else if (currentOffset > offset) {
      high = middle - 1
    }
  }
  if (low > 0) {
    return low - 1
  } else {
    return 0
  }
}

本小节我们实现了可计算高度的虚拟列表,比固定高度的实现稍微复杂,但是思路容易理解,感兴趣的小伙伴可以自己动手实现一下,下一小节我们继续实现其他场景下的滚动列表,如有问题欢迎留言讨论。

0 人点赞