ReactJS:建模双向无限滚动

Posted

技术标签:

【中文标题】ReactJS:建模双向无限滚动【英文标题】:ReactJS: Modeling Bi-Directional Infinite Scrolling 【发布时间】:2014-01-19 03:54:53 【问题描述】:

我们的应用程序使用无限滚动来浏览大量异构项目。有一些皱纹:

对于我们的用户来说,拥有 10,000 个项目的列表并且需要滚动 3k+ 是很常见的。 这些都是丰富的项目,所以在浏览器性能变得无法接受之前,我们只能在 DOM 中拥有几百个。 这些物品的高度各不相同。 这些项目可能包含图像,我们允许用户跳转到特定日期。这很棘手,因为用户可以跳转到列表中我们需要在视口上方加载图像的点,这会在加载时将内容向下推。未能处理这意味着用户可能会跳转到某个日期,但随后会被转移到更早的日期。

已知的、不完整的解决方案:

(react-infinite-scroll) - 这只是一个简单的“当我们触底时加载更多”组件。它不会剔除任何 DOM,因此它会在数千个项目上死亡。

(Scroll Position with React) - 显示在顶部插入时如何存储和恢复滚动位置在底部插入,但不能同时插入.

我不是在寻找完整解决方案的代码(尽管那会很棒。)相反,我正在寻找“反应方式”来模拟这种情况。滚动位置状态与否?我应该跟踪什么状态才能保留我在列表中的位置?当我滚动到所呈现内容的底部或顶部附近时,我需要保持什么状态才能触发新的渲染?

【问题讨论】:

【参考方案1】:

这是一个无限表和无限滚动场景的混合。我为此找到的最佳抽象如下:

概述

制作一个<List> 组件,该组件采用所有子元素的数组。由于我们不渲染它们,因此分配它们并丢弃它们真的很便宜。如果 10k 分配太大,您可以改为传递一个接受范围并返回元素的函数。

<List>
  thousandelements.map(function()  return <Element /> )
</List>

您的List 组件正在跟踪滚动位置,并且仅呈现视图中的子项。它在开头添加了一个大的空 div 来伪造之前未渲染的项目。

现在,有趣的部分是,一旦 Element 组件被渲染,您测量它的高度并将其存储在您的 List 中。这让您可以计算垫片的高度并知道应该在视图中显示多少元素。

图片

您是说,当图像加载时,它们会使所有内容“跳”下来。解决方案是在您的 img 标签中设置图像尺寸:&lt;img src="..." width="100" height="58" /&gt;。这样浏览器就不必等待下载它才能知道它要显示的大小。这需要一些基础设施,但确实值得。

如果您无法提前知道大小,则将onload 侦听器添加到您的图像中,并在加载时测量其显示尺寸并更新存储的行高并补偿滚动位置。

在随机元素处跳跃

如果您需要跳转到列表中的一个随机元素,这将需要一些滚动位置的技巧,因为您不知道中间元素的大小。我建议你做的是平均你已经计算的元素高度并跳转到最后一个已知高度的滚动位置+(元素数*平均值)。

由于这不准确,当您回到最后一个已知的良好位置时会引起问题。发生冲突时,只需更改滚动位置即可解决。这会稍微移动滚动条,但不会对他/她造成太大影响。

反应细节

您想为所有渲染的元素提供key,以便它们在渲染之间保持不变。有两种策略: (1) 只有 n 个键 (0, 1, 2, ... n),其中 n 是您可以显示和使用其位置模 n 的最大元素数。 (2) 每个元素有不同的键。如果所有元素共享相似的结构,最好使用 (1) 重用它们的 DOM 节点。如果他们不使用,则使用 (2)。

我只会有两个 React 状态:第一个元素的索引和正在显示的元素的数量。当前滚动位置和所有元素的高度将直接附加到this。当使用setState 时,您实际上是在进行重新渲染,这应该只在范围改变时发生。

这是一个使用我在此答案中描述的一些技术的无限列表example。这将是一些工作,但 React 绝对是实现无限列表的好方法 :)

【讨论】:

这是一个很棒的技术。谢谢!我让它在我的一个组件上工作。但是,我有另一个我想将其应用到的组件,但行没有一致的高度。我正在努力扩大您的示例以计算 displayEnd/visibleEnd 以考虑不同的高度......除非您有更好的主意? 我已经实现了这个,但遇到了一个问题:对我来说,我正在呈现的记录是有点复杂的 DOM,并且由于它们的 # ,加载它们是不谨慎的全部进入浏览器,所以我不时进行异步获取。出于某种原因,有时当我滚动并且位置跳得很远(比如我离开屏幕并返回)时,即使状态发生变化,ListBody 也不会重新渲染。任何想法为什么会这样?否则很好的例子! 你的 JSFiddle 当前抛出错误:Uncaught ReferenceError: generate is not defined 我已经制作了an updated fiddle,我认为它应该可以正常工作。有人愿意验证吗? @Meglio @ThomasModeneis 嗨,你能澄清一下第 151 行和第 152 行的计算,displayStart 和 displayEnd【参考方案2】:

看看http://adazzle.github.io/react-data-grid/index.html# 这看起来像一个强大且高性能的数据网格,具有类似 Excel 的功能和延迟加载/优化渲染(数百万行)以及丰富的编辑功能(MIT 许可)。 尚未在我们的项目中尝试,但很快就会这样做。

http://react.rocks/ 也是搜索此类内容的绝佳资源 在这种情况下,标签搜索很有帮助: http://react.rocks/tag/InfiniteScroll

【讨论】:

【参考方案3】:

我在为具有异构项目高度的单向无限滚动建模时遇到了类似的挑战,因此从我的解决方案中制作了一个 npm 包:

https://www.npmjs.com/package/react-variable-height-infinite-scroller

还有一个演示:http://tnrich.github.io/react-variable-height-infinite-scroller/

您可以查看逻辑的源代码,但我基本上遵循上述答案中概述的配方@Vjeux。我还没有解决跳转到特定项目的问题,但我希望尽快实现。

以下是当前代码的基本情况:

var React = require('react');
var areNonNegativeIntegers = require('validate.io-nonnegative-integer-array');

var InfiniteScoller = React.createClass(
  propTypes: 
    averageElementHeight: React.PropTypes.number.isRequired,
    containerHeight: React.PropTypes.number.isRequired,
    preloadRowStart: React.PropTypes.number.isRequired,
    renderRow: React.PropTypes.func.isRequired,
    rowData: React.PropTypes.array.isRequired,
  ,

  onEditorScroll: function(event) 
    var infiniteContainer = event.currentTarget;
    var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
    var currentAverageElementHeight = (visibleRowsContainer.getBoundingClientRect().height / this.state.visibleRows.length);
    this.oldRowStart = this.rowStart;
    var newRowStart;
    var distanceFromTopOfVisibleRows = infiniteContainer.getBoundingClientRect().top - visibleRowsContainer.getBoundingClientRect().top;
    var distanceFromBottomOfVisibleRows = visibleRowsContainer.getBoundingClientRect().bottom - infiniteContainer.getBoundingClientRect().bottom;
    var rowsToAdd;
    if (distanceFromTopOfVisibleRows < 0) 
      if (this.rowStart > 0) 
        rowsToAdd = Math.ceil(-1 * distanceFromTopOfVisibleRows / currentAverageElementHeight);
        newRowStart = this.rowStart - rowsToAdd;

        if (newRowStart < 0) 
          newRowStart = 0;
         

        this.prepareVisibleRows(newRowStart, this.state.visibleRows.length);
      
     else if (distanceFromBottomOfVisibleRows < 0) 
      //scrolling down, so add a row below
      var rowsToGiveOnBottom = this.props.rowData.length - 1 - this.rowEnd;
      if (rowsToGiveOnBottom > 0) 
        rowsToAdd = Math.ceil(-1 * distanceFromBottomOfVisibleRows / currentAverageElementHeight);
        newRowStart = this.rowStart + rowsToAdd;

        if (newRowStart + this.state.visibleRows.length >= this.props.rowData.length) 
          //the new row start is too high, so we instead just append the max rowsToGiveOnBottom to our current preloadRowStart
          newRowStart = this.rowStart + rowsToGiveOnBottom;
        
        this.prepareVisibleRows(newRowStart, this.state.visibleRows.length);
      
     else 
      //we haven't scrolled enough, so do nothing
    
    this.updateTriggeredByScroll = true;
    //set the averageElementHeight to the currentAverageElementHeight
    // setAverageRowHeight(currentAverageElementHeight);
  ,

  componentWillReceiveProps: function(nextProps) 
    var rowStart = this.rowStart;
    var newNumberOfRowsToDisplay = this.state.visibleRows.length;
    this.props.rowData = nextProps.rowData;
    this.prepareVisibleRows(rowStart, newNumberOfRowsToDisplay);
  ,

  componentWillUpdate: function() 
    var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
    this.soonToBeRemovedRowElementHeights = 0;
    this.numberOfRowsAddedToTop = 0;
    if (this.updateTriggeredByScroll === true) 
      this.updateTriggeredByScroll = false;
      var rowStartDifference = this.oldRowStart - this.rowStart;
      if (rowStartDifference < 0) 
        // scrolling down
        for (var i = 0; i < -rowStartDifference; i++) 
          var soonToBeRemovedRowElement = visibleRowsContainer.children[i];
          if (soonToBeRemovedRowElement) 
            var height = soonToBeRemovedRowElement.getBoundingClientRect().height;
            this.soonToBeRemovedRowElementHeights += this.props.averageElementHeight - height;
            // this.soonToBeRemovedRowElementHeights.push(soonToBeRemovedRowElement.getBoundingClientRect().height);
          
        
       else if (rowStartDifference > 0) 
        this.numberOfRowsAddedToTop = rowStartDifference;
      
    
  ,

  componentDidUpdate: function() 
    //strategy: as we scroll, we're losing or gaining rows from the top and replacing them with rows of the "averageRowHeight"
    //thus we need to adjust the scrollTop positioning of the infinite container so that the UI doesn't jump as we 
    //make the replacements
    var infiniteContainer = React.findDOMNode(this.refs.infiniteContainer);
    var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
    var self = this;
    if (this.soonToBeRemovedRowElementHeights) 
      infiniteContainer.scrollTop = infiniteContainer.scrollTop + this.soonToBeRemovedRowElementHeights;
    
    if (this.numberOfRowsAddedToTop) 
      //we're adding rows to the top, so we're going from 100's to random heights, so we'll calculate the differenece
      //and adjust the infiniteContainer.scrollTop by it
      var adjustmentScroll = 0;

      for (var i = 0; i < this.numberOfRowsAddedToTop; i++) 
        var justAddedElement = visibleRowsContainer.children[i];
        if (justAddedElement) 
          adjustmentScroll += this.props.averageElementHeight - justAddedElement.getBoundingClientRect().height;
          var height = justAddedElement.getBoundingClientRect().height;
        
      
      infiniteContainer.scrollTop = infiniteContainer.scrollTop - adjustmentScroll;
    

    var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
    if (!visibleRowsContainer.childNodes[0]) 
      if (this.props.rowData.length) 
        //we've probably made it here because a bunch of rows have been removed all at once
        //and the visible rows isn't mapping to the row data, so we need to shift the visible rows
        var numberOfRowsToDisplay = this.numberOfRowsToDisplay || 4;
        var newRowStart = this.props.rowData.length - numberOfRowsToDisplay;
        if (!areNonNegativeIntegers([newRowStart])) 
          newRowStart = 0;
        
        this.prepareVisibleRows(newRowStart , numberOfRowsToDisplay);
        return; //return early because we need to recompute the visible rows
       else 
        throw new Error('no visible rows!!');
      
    
    var adjustInfiniteContainerByThisAmount;

    //check if the visible rows fill up the viewport
    //tnrtodo: maybe put logic in here to reshrink the number of rows to display... maybe...
    if (visibleRowsContainer.getBoundingClientRect().height / 2 <= this.props.containerHeight) 
      //visible rows don't yet fill up the viewport, so we need to add rows
      if (this.rowStart + this.state.visibleRows.length < this.props.rowData.length) 
        //load another row to the bottom
        this.prepareVisibleRows(this.rowStart, this.state.visibleRows.length + 1);
       else 
        //there aren't more rows that we can load at the bottom so we load more at the top
        if (this.rowStart - 1 > 0) 
          this.prepareVisibleRows(this.rowStart - 1, this.state.visibleRows.length + 1); //don't want to just shift view
         else if (this.state.visibleRows.length < this.props.rowData.length) 
          this.prepareVisibleRows(0, this.state.visibleRows.length + 1);
        
      
     else if (visibleRowsContainer.getBoundingClientRect().top > infiniteContainer.getBoundingClientRect().top) 
      //scroll to align the tops of the boxes
      adjustInfiniteContainerByThisAmount = visibleRowsContainer.getBoundingClientRect().top - infiniteContainer.getBoundingClientRect().top;
      //   this.adjustmentScroll = true;
      infiniteContainer.scrollTop = infiniteContainer.scrollTop + adjustInfiniteContainerByThisAmount;
     else if (visibleRowsContainer.getBoundingClientRect().bottom < infiniteContainer.getBoundingClientRect().bottom) 
      //scroll to align the bottoms of the boxes
      adjustInfiniteContainerByThisAmount = visibleRowsContainer.getBoundingClientRect().bottom - infiniteContainer.getBoundingClientRect().bottom;
      //   this.adjustmentScroll = true;
      infiniteContainer.scrollTop = infiniteContainer.scrollTop + adjustInfiniteContainerByThisAmount;
    
  ,

  componentWillMount: function(argument) 
    //this is the only place where we use preloadRowStart
    var newRowStart = 0;
    if (this.props.preloadRowStart < this.props.rowData.length) 
      newRowStart = this.props.preloadRowStart;
    
    this.prepareVisibleRows(newRowStart, 4);
  ,

  componentDidMount: function(argument) 
    //call componentDidUpdate so that the scroll position will be adjusted properly
    //(we may load a random row in the middle of the sequence and not have the infinte container scrolled properly initially, so we scroll to the show the rowContainer)
    this.componentDidUpdate();
  ,

  prepareVisibleRows: function(rowStart, newNumberOfRowsToDisplay)  //note, rowEnd is optional
    //setting this property here, but we should try not to use it if possible, it is better to use
    //this.state.visibleRowData.length
    this.numberOfRowsToDisplay = newNumberOfRowsToDisplay;
    var rowData = this.props.rowData;
    if (rowStart + newNumberOfRowsToDisplay > this.props.rowData.length) 
      this.rowEnd = rowData.length - 1;
     else 
      this.rowEnd = rowStart + newNumberOfRowsToDisplay - 1;
    
    // var visibleRows = this.state.visibleRowsDataData.slice(rowStart, this.rowEnd + 1);
    // rowData.slice(rowStart, this.rowEnd + 1);
    // setPreloadRowStart(rowStart);
    this.rowStart = rowStart;
    if (!areNonNegativeIntegers([this.rowStart, this.rowEnd])) 
      var e = new Error('Error: row start or end invalid!');
      console.warn('e.trace', e.trace);
      throw e;
    
    var newVisibleRows = rowData.slice(this.rowStart, this.rowEnd + 1);
    this.setState(
      visibleRows: newVisibleRows
    );
  ,
  getVisibleRowsContainerDomNode: function() 
    return this.refs.visibleRowsContainer.getDOMNode();
  ,


  render: function() 
    var self = this;
    var rowItems = this.state.visibleRows.map(function(row) 
      return self.props.renderRow(row);
    );

    var rowHeight = this.currentAverageElementHeight ? this.currentAverageElementHeight : this.props.averageElementHeight;
    this.topSpacerHeight = this.rowStart * rowHeight;
    this.bottomSpacerHeight = (this.props.rowData.length - 1 - this.rowEnd) * rowHeight;

    var infiniteContainerStyle = 
      height: this.props.containerHeight,
      overflowY: "scroll",
    ;
    return (
      <div
        ref="infiniteContainer"
        className="infiniteContainer"
        style=infiniteContainerStyle
        onScroll=this.onEditorScroll
        >
          <div ref="topSpacer" className="topSpacer" style=height: this.topSpacerHeight/>
          <div ref="visibleRowsContainer" className="visibleRowsContainer">
            rowItems
          </div>
          <div ref="bottomSpacer" className="bottomSpacer" style=height: this.bottomSpacerHeight/>
      </div>
    );
  
);

module.exports = InfiniteScoller;

【讨论】:

以上是关于ReactJS:建模双向无限滚动的主要内容,如果未能解决你的问题,请参考以下文章

如何同步无限的 UIScrollView?

使用 React JS 无限滚动

无限滚动IOS在gridview中一次垂直和水平滚动

两个方向无限滚动与Firebase / Firestore后端

ReactJS - 获取两个 div 两个互相跟随滚动

在 Flutter 中创建一个双向无限 ListView.builder