虚拟滚动:React 中的核心原理和基本实现

2025-06-07

虚拟滚动:React 中的核心原理和基本实现

作者:Denis Hilt✏️

什么是虚拟滚动?为什么我们需要它?假设您有一个包含 100,000 个或更多项目的数据集,您希望将其显示为不带分页的可滚动列表。渲染这么多行会污染 DOM,消耗过多内存,并降低应用的性能。

相反,您希望在给定时间内仅向用户显示一小部分数据。其他项目应通过顶部和底部填充元素模拟(虚拟化),这些元素为空,但具有提供一致滚动条参数所需的高度。每次用户滚动出可见项目集合时,内容都会重建:新项目会被获取并渲染,旧项目会被销毁,填充元素会重新计算,等等。

简而言之,这就是虚拟滚动的核心原理。在本教程中,我们将回顾基础知识,并学习如何创建可复用的 React 组件来解决最简单的虚拟滚动问题。

您可以在我的GitHub上查看完整的演示存储库,并且我已经在CodeSandbox中同步了一个应用程序以便在运行时使用它。

LogRocket 免费试用横幅

第一部分:基础设施

虽然虚拟滚动有各种各样的用例和需求,但今天我们将重点了解其核心原理,并构建一个小组件来满足一些非常基本的需求。让我们先定义一下开始的条件:

  • 我们想要虚拟化的数据集中的项目数量是已知且固定的
  • 单行高度恒定
  • 保证从我们的应用程序到滚动组件的同步数据流

任何界面开发的第一步都可以是设想它最终会如何使用。假设我们已经有一个名为 的组件VirtualScroller 要使用它,我们需要做三件事:

  1. 传递虚拟化设置
  2. 提供数据流机制
  3. 定义行模板
<VirtualScroller settings={SETTINGS} get={getData} row={rowTemplate}/>
Enter fullscreen mode Exit fullscreen mode

设置

我们可以将设置作为一组单独的 HTML 属性来提供,但我们将定义一个静态对象。它的字段应该决定所需的行为并反映初始条件。让我们从最小值开始(我们可以随时增加到maxIndex100,000)。

const SETTINGS = {
  minIndex: 1,
  maxIndex: 16,
  startIndex: 6,
  itemHeight: 20,
  amount: 5,
  tolerance: 2
}
Enter fullscreen mode Exit fullscreen mode

amounttolerance需要特别注意。amount定义了我们希望在视口中可见的项目数量。tolerance它决定了视口的出口,其中包含将被渲染但对用户不可见的其他项目。下图显示了SETTINGS对象的选定值,动画 gif 演示了初始状态在滚动时如何变化。


虚拟滚动中设置对象的选定值 图表展示了滚动时初始状态如何变化

彩色窗口包含真实数据行(初始为 4 到 12 行)。深蓝色区域表示视口的可见部分;其高度固定,等于amount* itemHeight。浅蓝色出口包含真实但不可见的行,因为它们位于视口之外。上下白色区域是两个空容器;它们的高度对应于我们不希望出现在 DOM 中的虚拟行。我们可以按如下方式计算虚拟行的初始数量。

(maxIndex - minIndex + 1) - (amount + 2 * tolerance) = 16 - 9 = 7
Enter fullscreen mode Exit fullscreen mode

七个行分为顶部的三行虚拟行和底部的四行虚拟行。

每次我们上下滚动时,图像都会发生变化。例如,如果我们滚动到最顶部(零)位置,视口的可见部分将包含 1 到 5 行,底部出口将包含 6 到 7 行,底部填充容器将虚拟 8 到 16 行,顶部填充容器将接受零高度,并且顶部出口将不存在。此类过渡的逻辑将在下面讨论,我们将VirtualScroller在第二部分中讨论组件。

数据流

我们定义了get属性,并将其VirtualScrollergetData值一起传递给组件。什么是getData?它是一个将数据集的一部分提供给 的方法VirtualScroller。滚动条将通过此方法请求数据,因此我们需要使用适当的参数对其进行参数化。我们将其命名offsetlimit

const getData = (offset, limit) => {
  const data = []
  const start = Math.max(SETTINGS.minIndex, offset)
  const end = Math.min(offset + limit - 1, SETTINGS.maxIndex)
  if (start <= end) {
    for (let i = start; i <= end; i++) {
      data.push({ index: i, text: `item ${i}` })
    }
  }
  return data
}
Enter fullscreen mode Exit fullscreen mode

getData(4, 9)调用表示我们希望从索引 4 开始接收 9 个项目。此特定调用与上图相关:需要 4 到 12 个项目才能在启动时用出口填充视口。借助Math.min和,我们将请求的数据部分限制在由最大/最小索引设置定义的数据集边界内。这也是我们生成项目的地方;一个项目是一个具有和属性Math.max的对象是唯一的,因为这些属性将参与行模板。indextextindex

我们可以从其他地方请求数据,甚至从远程数据源请求数据,而不是直接生成数据项。我们可以返回Promise处理异步数据源请求,但现在我们将专注于虚拟化而不是数据流,以使实现尽可能简单。

行模板

仅显示属性的非常简单的模板text可能如下所示:

const rowTemplate = item =>
  <div className="item" key={item.index}>
    { item.text }
  </div>
Enter fullscreen mode Exit fullscreen mode

行模板取决于应用的独特需求。其复杂程度可能有所不同,但必须与getData返回结果一致。行模板的item结构必须与每个列表项相同data。该key属性也是必需的,因为VirtualScroller创建行列表时,我们需要为元素提供稳定的标识。

让我们再看一下:

<VirtualScroller settings={SETTINGS} get={getData} row={rowTemplate}/>
Enter fullscreen mode Exit fullscreen mode

我们成功地将想要传递的三项内容传递给了VirtualScroller。这样,VirtualScroller就无需了解它正在处理的任何数据。这些信息将通过getrow属性从滚动条外部获取,这对于组件的可复用性至关重要。我们还可以把刚刚设置的滚动条属性的约定视为我们未来的组件 API。

第 2 部分:虚拟滚动组件

现在一半的工作已经完成,接下来进入第二阶段:构建一个虚拟滚动组件来满足上一节开发的 API。这听起来可能有点像画猫头鹰,但我保证,我们已经完成了一半。

使成为

回到上一节中的图像,很明显我们需要以下 DOM 元素:

  • height具有约束和overflow-y: auto样式的视口元素
  • 两个没有内容但具有动态heights 的填充元素
  • data用行模板包装的缓冲项目列表
render() {
  const { viewportHeight, topPaddingHeight, bottomPaddingHeight, data } = this.state
  return (
    <div className='viewport' style={{ height: viewportHeight }}>
      <div style={{ height: topPaddingHeight }}></div>
      { data.map(this.props.row) }
      <div style={{ height: bottomPaddingHeight }}></div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

render 方法可能如下所示。四个状态属性反映了我们对 DOM 结构的要求:三个高度和当前数据部分。此外,我们看到this.props.row,它只是从外部传递的行模板,因此data.map(this.props.row)将根据我们的 API 渲染当前数据项的列表。在添加滚动之前,我们需要定义状态属性。

状态

现在是时候初始化内部组件的状态了。让我们尝试实现一个纯函数,基于settings第一部分讨论的对象返回初始状态对象。除了在 r​​ender 中传入的四个状态属性外,我们还需要一些其他用于滚动的属性,这样当状态对象包含的 props 比 render 所需的多一点时,我们就不会感到惊讶。话虽如此,本部分的主要目标是强制在第一次渲染时绘制初始图片。

const setInitialState = ({
  minIndex, maxIndex, startIndex, itemHeight, amount, tolerance
}) => {
  // 1) height of the visible part of the viewport (px)
  const viewportHeight = amount * itemHeight
  // 2) total height of rendered and virtualized items (px)
  const totalHeight = (maxIndex - minIndex + 1) * itemHeight
  // 3) single viewport outlet height, filled with rendered but invisible rows (px)
  const toleranceHeight = tolerance * itemHeight
  // 4) all rendered rows height, visible part + invisible outlets (px)
  const bufferHeight = viewportHeight + 2 * toleranceHeight
  // 5) number of items to be rendered, buffered dataset length (pcs)
  const bufferedItems = amount + 2 * tolerance
  // 6) how many items will be virtualized above (pcs)
  const itemsAbove = startIndex - tolerance - minIndex
  // 7) initial height of the top padding element (px)
  const topPaddingHeight = itemsAbove * itemHeight
  // 8) initial height of the bottom padding element (px)
  const bottomPaddingHeight = totalHeight - topPaddingHeight
  // 9) initial scroll position (px)
  const initialPosition = topPaddingHeight + toleranceHeight
  // initial state object
  return {
    settings,
    viewportHeight,
    totalHeight,
    toleranceHeight,
    bufferHeight,
    bufferedItems,
    topPaddingHeight,
    bottomPaddingHeight,
    initialPosition,
    data: []
  }
}
Enter fullscreen mode Exit fullscreen mode

我们来看看更新后的图像:

React 中虚拟滚动组件的更新状态

计算 (8) 和 (9) 未在图中显示。滚动条在初始化时缓冲区中不会有任何项目;缓冲区将保持为空,直到第一个get方法调用返回非空结果。这也是我们将状态属性初始值设置[]为空数组的原因data。因此,视口最初应该只包含两个空的填充元素,并且底部元素应该填充顶部元素之后剩余的所有空间。因此,在我们的示例中, 的初始值为 320 - 60 = 260 (px) bottomPaddingHeight

最后,initialPosition确定滚动条启动时的位置。它应该与startIndex值一致,因此在我们的示例中,滚动条位置应固定在第六行顶部坐标。这对应于 60 + 40 = 100 (px) 的值。

初始化

状态的初始化放在滚动条组件构造函数中,同时创建视口元素引用,这是手动设置滚动位置所必需的。

constructor(props) {
  super(props)
  this.state = setInitialState(props.settings)
  this.viewportElement = React.createRef()
}
Enter fullscreen mode Exit fullscreen mode

这样,我们就可以用两个 padding 元素来初始化视口,其中的累积高度对应于我们要显示/虚拟化的所有数据的体积。此外,还应更新 render 方法以分配视口元素的引用。

  return (
    <div className='viewport'
         style={{ height: viewportHeight }}
         ref={this.viewportElement}
    > ... </div>
  )
Enter fullscreen mode Exit fullscreen mode

首次渲染完成并初始化 padding 元素后,立即将视口滚动条位置设置为其初始值。DidMount生命周期方法正是执行此操作的正确位置。

componentDidMount() {
  this.viewportElement.current.scrollTop = this.state.initialPosition
}
Enter fullscreen mode Exit fullscreen mode

滚动事件处理

现在我们必须处理滚动。runScroller负责获取data项目并调整填充元素。我们稍后会实现它,但首先让我们在渲染时将它与视口元素的scroll 事件绑定。

  return (
    <div className='viewport'
         style={{ height: viewportHeight }}
         ref={this.viewportElement}
         onScroll={this.runScroller}
    > ... </div>
  )
Enter fullscreen mode Exit fullscreen mode

DidMount方法在首次渲染完成后调用。将initialPosition值赋给视口的scrollTop属性将隐式调用该runScroller方法。这样,初始数据请求将自动触发。

还有一种极端情况,即初始滚动位置为 0 且scrollTop不会改变;这在技术上与minIndex等于 的情况相关startIndex。在这种情况下,runScroller应该显式调用 。

componentDidMount() {
  this.viewportElement.current.scrollTop = this.state.initialPosition
  if (!this.state.initialPosition) {
    this.runScroller({ target: { scrollTop: 0 } })
  }
}
Enter fullscreen mode Exit fullscreen mode

我们需要模拟event对象,但这scrollTop仅仅是runScroller处理程序需要处理的事情。现在我们到了最后一部分逻辑。

滚动事件处理程序

runScroller = ({ target: { scrollTop } }) => {
  const { totalHeight, toleranceHeight, bufferedItems, settings: { itemHeight, minIndex }} = this.state
  const index = minIndex + Math.floor((scrollTop - toleranceHeight) / itemHeight)
  const data = this.props.get(index, bufferedItems)
  const topPaddingHeight = Math.max((index - minIndex) * itemHeight, 0)
  const bottomPaddingHeight = Math.max(totalHeight - topPaddingHeight - data.length * itemHeight, 0)

  this.setState({
    topPaddingHeight,
    bottomPaddingHeight,
    data
  })
}
Enter fullscreen mode Exit fullscreen mode

runScroller滚动条组件的一个类属性(另请参阅我在tc39 仓库中创建的这个问题),它可以通过 访问其state它会根据作为参数传递的当前滚动位置以及在正文第一行解构的当前状态进行一些计算。第 2 行和第 3 行用于获取数据集的新部分,这部分内容将成为新的滚动条数据项缓冲区。第 4 行和第 5 行用于获取顶部和底部填充元素高度的新值。结果将传递给 ,更新视图。propsthisstaterender

关于数学,简单说几句。根据我们在第一部分中开发的 API,该get方法确实需要两个参数来回答以下问题。

  • 应请求多少个项目(limit参数,即bufferedItems)?
  • 结果数组(offset参数index)中的第一个索引应该是哪个?

计算时index会考虑顶部出口,从而减去toleranceHeight之前设置的值。除以 ,itemHeight我们得到的是 之前我们希望在缓冲区中排在第一位的行数index。加上 ,minIndex将行数转换为索引。滚动位置 ( scrollTop) 可以位于任意行的中间,因此可能不是 的倍数itemHeight。因此,我们需要对除法结果进行四舍五入——index结果必须是整数。

index在将 乘以已知的行高之前,需要通过若干行获取顶部填充元素的高度。该Math.max表达式确保结果不为负。我们可以将此保护措施转移到index步长(例如,index不能小于minIndex),但结果相同。另外值得注意的是,我们已经在getData实现中加入了这样的限制。

底部填充元素的高度考虑了为滚动缓冲区 ( data.length * itemHeight ) 检索到的新项目的高度。我认为在这个实现中它不能为负数,但目前我们先不考虑这个问题。逻辑非常简单,我们正努力专注于方法本身。因此,一些细节可能并非 100% 完美。

概括

前端开发中虚拟滚动工程的历史可以追溯到 2010 年代初,甚至可能更早。我个人的虚拟滚动之旅始于 2014 年。如今,我维护着两个 Angular-universe 仓库——angular-ui-scrollngx-ui-scroll——并且我用 React 开发了这个简单的演示。

我们刚刚实现的组件VirtualScroller可以虚拟化固定大小的数据集,假设行高是恒定的。它使用开发人员负责实现的特殊方法来消费数据。它还接受影响视图和行为的模板和静态设置属性。

本文并非绝对真理的来源;它只是一种方法,是众多适用于最简单情况的解决方案之一。市面上有很多基于各种框架(包括 React)构建的包罗万象的解决方案,但它们都有各自的局限性,没有一个能够真正涵盖所有可能的需求。

通过从头开始构建解决方案,您可以更有效地应用虚拟滚动技术。

说到要求,我们还能提出哪些其他发展来使我们的实施变得更好?

  • 检查所有输入参数,抛出有意义的错误
  • 默认设置——普通 lib 用户为什么要考虑tolerance
  • 缓存——不要两次请求相同的数据
  • 允许无限数据集 -min并且max索引可以是未知的
  • 异步数据流——滚动条必须等待数据才能更新状态
  • 动态数据源设置——例如,我们决定数据源准备提供 100 多个项目,那么为什么不增加呢maxIndex
  • 动态视口设置——我们可能想在飞行过程中改变视口的高度
  • 取消固定行高——如果我们不强制应用开发者同时提供项目及其相应的高度,这将是最具挑战性的要求之一
  • 让应用程序访问一些只读数据——缓冲区中当前有多少个项目,以及滚动器正在加载第一个/最后一个可见项目(如果是异步数据源)?
  • 提供操作滚动条运行时的方法——按需删除或添加项目(无需滚动)、重新加载视口、滚动到边框、滚动到特定索引、重新加载到索引
  • ​​新设置——滚动事件延迟(不要过于频繁地触发滚动器逻辑)、反向选项(滚动到顶部会导致索引增加)、无剪辑选项(虚拟滚动变成无限滚动)
  • 允许水平滚动模式
  • 动画钩子

这绝不是一份完整的清单,上面的大多数功能都有各自的特殊情况、不同的实现方法以及性能和可用性问题。更别提测试了。

此外,每个鼠标、触控板、手机和浏览器的行为都可能有所不同,尤其是在惯性方面。有时候我简直想哭。尽管虚拟滚动会带来各种挫败感,但开发它的过程也充满乐趣和成就感。所以,今天就开始吧,一起把虚拟滚动的大旗推向新时代!


全面了解生产 React 应用程序

调试 React 应用可能很困难,尤其是当用户遇到难以复现的问题时。如果您对监控和跟踪 Redux 状态、自动显示 JavaScript 错误以及跟踪缓慢的网络请求和组件加载时间感兴趣,请尝试 LogRocket。

替代文本

LogRocket就像 Web 应用的 DVR,可以记录 React 应用上发生的所有事件。您无需猜测问题发生的原因,而是可以汇总并报告问题发生时应用程序的状态。LogRocket 还可以监控应用的性能,并报告客户端 CPU 负载、客户端内存使用情况等指标。

LogRocket Redux 中间件包为您的用户会话增加了一层额外的可见性。LogRocket 会记录 Redux 存储中的所有操作和状态。

实现 React 应用程序调试方式的现代化 —开始免费监控。


虚拟滚动:React 中的核心原则和基本实现一文首先出现在LogRocket 博客上。

文章来源:https://dev.to/bnevilleoneill/virtual-scrolling-core-principles-and-basic-implementation-in-react-29aa
PREV
20 个(高级开发人员)C# 面试问题及答案(2023)
NEXT
从白天到夜晚——用 JavaScript 创建交互式调色板