ReactJS: 无限双向滚动建模

119
我们的应用程序使用无限滚动来浏览大型异构项目列表。以下是一些注意点:
  • 我们的用户通常拥有包含 10,000 个项目并需要滚动 3k+ 的列表。
  • 这些是复杂的项目,因此在浏览器性能变得不可接受之前,我们只能在 DOM 中保存数百个项目。
  • 项目高度不同。
  • 项目可能包含图像,我们允许用户跳转到特定日期。这很棘手,因为用户可以跳转到需要加载视口上方图片的位置,这会在它们加载时将内容向下推移。如果没有处理好,用户可能会跳转到一个日期,但随后被转移到早期日期。

已知的尚未完成的解决方案:

  • (react-infinite-scroll) - 这只是一个简单的“到达底部时加载更多”组件。它不会清除任何 DOM,因此会在成千上万的项目上死机。

  • (使用 React 处理滚动位置) - 演示了如何在顶部 底部插入时存储和恢复滚动位置,但不能同时处理两者。

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

3个回答

118

这是无限表与无限滚动场景的混合体。我找到的最好的抽象是:

概述

制作一个<List>组件,它接受一个包含所有子元素的数组。由于我们不渲染它们,所以分配和丢弃它们非常便宜。如果10k个分配太大,你可以传递一个函数,该函数接受一个范围并返回元素。

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

你的 List 组件会记录滚动位置,并且只渲染当前视图内的子元素。它会在开头添加一个大的空 div 来模拟之前没有被渲染的项。

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

图片

你说当图片加载时,它们会使页面“跳动”下去。解决方法是在 img 标签中设置图片的尺寸: <img src="..." width="100" height="58" />。这样浏览器在下载图片之前就知道它将要显示的大小了。这需要一些基础设施,但确实值得这样做。

如果你无法提前知道尺寸,则需要为你的图片添加 onload 监听器,等它加载完后再测量它的显示尺寸并更新存储的行高,同时调整滚动位置。

随机跳转到某个元素

如果你需要跳转到列表中的任意元素,那么需要通过滚动位置一些巧妙的操作,因为你并不知道它们之间元素的大小。我建议的做法是将你已经计算出的元素高度取平均值,然后跳转到最后已知的高度加上(元素数量 * 平均高度)的滚动位置。

由于这是不精确的,当你返回到上一个已知的位置时,可能会产生一些问题。当发生冲突时,只需更改滚动位置即可解决它。这会稍微移动一下滚动条,但不应对用户造成太大影响。

React 细节

你需要为所有渲染的元素提供一个 key ,以便它们在重新渲染时能够被保留。有两种策略:(1) 仅使用 n 个键(0、1、2、...n),其中 n 是你可以显示的最大元素数量,并使用其位置 modulo n 。(2) 对每个元素使用不同的键。如果所有元素共享相似的结构,则使用(1)以重用它们的 DOM 节点。否则使用(2)。

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

这里有一个使用我在本答案中描述的一些技巧的无限列表的 示例。这需要一些工作,但 React 绝对是实现无限列表的好方法 :)


4
这是一种很棒的技巧,谢谢!我已经在一个组件上让它运行了。然而,我还有另一个组件想要应用这个技巧,但是行高不一致。我正在尝试改进你的示例以计算 displayEnd/visibleEnd 来适应不同的行高……除非你有更好的建议? - manalang
我已经实现了这个功能,但遇到了一个问题:对于我正在渲染的记录来说,它们是相当复杂的DOM,因为它们的数量很多,所以将它们全部加载到浏览器中并不明智,因此我会不时地进行异步获取。但有时候,当我滚动并且位置跳得非常远(比如说我离开屏幕然后回来),ListBody不会重新渲染,即使状态发生了改变。你有什么想法吗?除此之外,这是一个很好的例子! - SleepyProgrammer
1
您的 JSFiddle 目前出现错误:Uncaught ReferenceError: generate 未定义。 - Meglio
3
我已经制作了一个更新的示例,我认为它应该可以正常工作。有人能验证一下吗?@Meglio - aknuds1
1
@ThomasModeneis 你好,能否澄清一下第151和152行的计算,即displayStart和displayEnd? - shortCircuit
显示剩余5条评论

3

2

我曾经面临过类似的挑战,需要对具有异构项高度的单向无限滚动进行建模,因此我将我的解决方案制作成了一个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;

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接