ReactJS:build模双向无限滚动

我们的应用程序使用无限滚动浏览大型异构项目列表。 有几个皱纹:

  • 我们的用户通常有10,000个项目的列表,需要滚动3k +。
  • 这些是丰富的项目,所以在浏览器性能变得不可接受之前,我们在DOM中只能有几百个。
  • 项目是不同的高度。
  • 这些项目可能包含图像,我们允许用户跳转到特定的date。 这很棘手,因为用户可以跳转到列表中需要在视口上方加载图像的点,这会在加载内容时将内容向下推。 如果处理不好,就意味着用户可能会跳到一个date,但会被转移到更早的date。

已知,不完整的解决scheme:

  • https://github.com/guillaumervls/react-infinite-scroll-这只是一个简单的“当我们碰到底部时加载更多”组件。 它不会剔除任何DOM,所以会死在成千上万的物品上。

  • http://blog.vjeux.com/2013/javascript/scroll-position-with-react.html – 显示如何存储和恢复滚动的位置,当插入顶部插入底部,但不是两个在一起。

我没有寻找一个完整的解决scheme的代码(虽然这将是伟大的。)相反,我正在寻找“反应的方式”来模拟这种情况。 是否滚动位置状态? 我应该跟踪哪个状态以保留我在列表中的位置? 我需要保持什么状态,以便在滚动显示的底部或顶部附近时触发新的渲染?

这是无限表和无限滚动情景的混合。 我发现的最好的抽象是以下几点:

概观

创build一个带有所有子级数组的<List>组件。 由于我们没有渲染它们,分配它们并丢弃它们真的很便宜。 如果10k的分配太大,你可以改为传递一个函数来获取一个范围并返回元素。

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

您的List组件正在跟踪滚动位置是什么,只呈现在视图中的子项。 它在开始时添加一个大的空div来伪造之前没有渲染的项目。

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

图片

你在说,当图像加载时,它们会使所有的东西“跳”下来。 解决方法是在img标签中设置图片尺寸: <img src="..." width="100" height="58" /> 通过这种方式,浏览器不必等待才能知道要显示的尺寸。 这需要一些基础设施,但这确实是值得的。

如果您不能预先知道尺寸,请将图像加载器加载到图像上,然后测量其显示的尺寸并更新存储的行高并补偿滚动位置。

跳过一个随机的元素

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

由于这不是确切的,所以当你回到最后一个已知的好位置时会引起问题。 发生冲突时,只需更改滚动位置即可修复。 这将会移动滚动条,但不应该影响他/她太多。

反应细节

您希望为所有呈现的元素提供一个键 ,以便在呈现中进行维护。 有两种策略:(1)只有n个键(0,1,2,… n),其中n是可以显示的元素的最大数量,并使用它们的位置模n。 (2)每个元素有不同的键。 如果所有元素共享一个相似的结构,那么使用(1)重用它们的DOM节点是很好的。 如果他们不使用(2)。

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

这里是一个例子http://jsfiddle.net/vjeux/KbWJ2/9/使用我在这个答案中描述的一些技术的无限列表。; 这将是一些工作,但React绝对是一个很好的方法来实现一个无限的列表:)

我正面临着对异构物品高度的单向无限滚动build模的类似挑战,因此我的解决scheme中包含了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;