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

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

思路分析

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

image.png

react-window 库实现效果

// 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.png

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

image.png

实现 VariableSizeList

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

组件模板

const VariableSizeList = createListComponent({
  getEstimatedTotalSize: () => 0,
  getItemSize: () => 0,
  getItemOffset: () => 0,
  getStartIndexForOffset: () => 0,
  getEndIndexForOffset: () => 0,
  initInstanceProps: /////
})

初始化属性

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

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 组件产生副作用

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;

计算起始索引

getStartIndexForOffset: (props, scrollOffset, instanceProps) => {
  return findNearestItem(props, scrollOffset, instanceProps)
},

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

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
}

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

// 获取每个条目对应的元数据  {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 值超出可视区高度和起始索引下一个元素的偏移量时可以得到结束索引:

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

getItemSize: (props, index, instanceProps) => {
  return getItemMetadata(props, index, instanceProps).size
},
getItemOffset: (props, index, instanceProps) => {
  return getItemMetadata(props, index, instanceProps).offset
},

动态计算容器高度

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

// 计算或者预估内容总高度  撑起来,出现滚动条
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.png

image.png

优化

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

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
  }
}

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

原文地址:https://cloud.tencent.com/developer/article/2137446

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


react 中的高阶组件主要是对于 hooks 之前的类组件来说的,如果组件之中有复用的代码,需要重新创建一个父类,父类中存储公共代码,返回子类,同时把公用属性...
我们上一节了解了组件的更新机制,但是只是停留在表层上,例如我们的 setState 函数式同步执行的,我们的事件处理直接绑定在了 dom 元素上,这些都跟 re...
我们上一节了解了 react 的虚拟 dom 的格式,如何把虚拟 dom 转为真实 dom 进行挂载。其实函数是组件和类组件也是在这个基础上包裹了一层,一个是调...
react 本身提供了克隆组件的方法,但是平时开发中可能很少使用,可能是不了解。我公司的项目就没有使用,但是在很多三方库中都有使用。本小节我们来学习下如果使用该...
mobx 是一个简单可扩展的状态管理库,中文官网链接。小编在接触 react 就一直使用 mobx 库,上手简单不复杂。
我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 pc 端可以使用分页进行渲染数限制,在移动端可以使用下拉加载更多。但是对于大量的列表渲染,特别像有实时数据...
本小节开始前,我们先答复下一个同学的问题。上一小节发布后,有小伙伴后台来信问到:‘小编你只讲了类组件中怎么使用 ref,那在函数式组件中怎么使用呢?’。确实我们...
上一小节我们了解了固定高度的滚动列表实现,因为是固定高度所以容器总高度和每个元素的 size、offset 很容易得到,这种场景也适合我们常见的大部分场景,例如...
上一小节我们处理了 setState 的批量更新机制,但是我们有两个遗漏点,一个是源码中的 setState 可以传入函数,同时 setState 可以传入第二...
我们知道 react 进行页面渲染或者刷新的时候,会从根节点到子节点全部执行一遍,即使子组件中没有状态的改变,也会执行。这就造成了性能不必要的浪费。之前我们了解...
在平时工作中的某些场景下,你可能想在整个组件树中传递数据,但却不想手动地通过 props 属性在每一层传递属性,contextAPI 应用而生。
楼主最近入职新单位了,恰好新单位使用的技术栈是 react,因为之前一直进行的是 vue2/vue3 和小程序开发,对于这些技术栈实现机制也有一些了解,最少面试...
我们上一节了了解了函数式组件和类组件的处理方式,本质就是处理基于 babel 处理后的 type 类型,最后还是要处理虚拟 dom。本小节我们学习下组件的更新机...
前面几节我们学习了解了 react 的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff 算法,了解如何优化和复用 dom 操作的,还有...
我们在之前已经学习过 react 生命周期,但是在 16 版本中 will 类的生命周期进行了废除,虽然依然可以用,但是需要加上 UNSAFE 开头,表示是不安...
上一小节我们学习了 react 中类组件的优化方式,对于 hooks 为主流的函数式编程,react 也提供了优化方式 memo 方法,本小节我们来了解下它的用...
开源不易,感谢你的支持,❤ star me if you like concent ^_^
hel-micro,模块联邦sdk化,免构建、热更新、工具链无关的微模块方案 ,欢迎关注与了解
本文主题围绕concent的setup和react的五把钩子来展开,既然提到了setup就离不开composition api这个关键词,准确的说setup是由...
ReactsetState的执行是异步还是同步官方文档是这么说的setState()doesnotalwaysimmediatelyupdatethecomponent.Itmaybatchordefertheupdateuntillater.Thismakesreadingthis.staterightaftercallingsetState()apotentialpitfall.Instead,usecom