ReactJS: 双向无限滚动建模

我们的应用程序使用无限滚动来浏览大量的异构项目列表:

  • 对于我们的用户来说,拥有一个包含10,000个项目的列表并且需要滚动3k + 是很常见的。
  • 这些都是内容丰富的项目,因此在浏览器性能变得不可接受之前,我们只能在 DOM 中使用几百个项目。
  • 这些物品高低不一。
  • 项目可能包含图像,我们允许用户跳转到一个特定的日期。这很棘手,因为用户可以跳转到列表中需要加载 viewport 上方的图像的位置,这将在加载时将内容向下推。如果不能处理这个问题,就意味着用户可能会跳转到一个日期,然后被转移到一个更早的日期。

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

  • (反应-无限-卷轴) - This is just a simple "load more when we hit the bottom" component. It does not cull any of the DOM, so it will die on thousands of items.

  • (滚动位置与反应)-显示如何存储和恢复滚动位置时插入在顶部的 < em > or 插入在底部,但不一起。

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

36552 次浏览

这是无限表和无限滚动场景的混合。我找到的最好的抽象方法如下:

概述

制作一个 <List>组件,该组件接受一个包含 all子级的数组。由于我们不渲染它们,只是分配和丢弃它们真的很便宜。如果10k 分配过大,可以转而传递一个接受范围的函数并返回元素。

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

您的 List组件正在跟踪滚动位置,并且只呈现视图中的子元素。它在开头添加一个大的空 div,以伪造以前没有呈现的项。

现在,有趣的部分是,一旦呈现了 Element组件,就可以测量它的高度并将其存储在 List中。这使您可以计算间隔符的高度,并知道视图中应该显示多少元素。

形象

你是说,当图像加载时,他们让一切“跳”下来。解决方案是在 img 标记中设置图像尺寸: <img src="..." width="100" height="58" />。这样浏览器就不必等到下载后才知道它的大小。这需要一些基础设施,但真的很值得。

如果您不能预先知道大小,然后添加 onload侦听器到您的图像,当它被加载,然后测量其显示的尺寸,更新存储的行高度,并补偿滚动位置。

跳到一个随机的元素上

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

因为这并不精确,所以当你回到最后一个已知的好位置时,就会出现问题。当冲突发生时,只需更改滚动位置来修复冲突。这将移动滚动条一点,但不应该影响他/她太多。

反应细节

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

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

下面是使用我在这个答案中描述的一些技术的无限列表的 例子。这将是一些工作,但是 React 无疑是实现无限列表的一个好方法:)

我们来看看这个 http://adazzle.github.io/react-data-grid/index.html# 这看起来像一个功能强大、性能良好的 datagrid,具有类似 Excel 的特性和延迟加载/优化呈现(对于数百万行) ,并且具有丰富的编辑特性(MIT 授权)。 还没有在我们的项目中尝试,但很快就会这样做。

搜索这些东西的一个很好的资源也是一个 http://react.rocks/ 在这种情况下,标记搜索是有帮助的: Http://react.rocks/tag/infinitescroll

我面临着一个类似的挑战,建模单向无限滚动与不同的项目高度,所以在我的解决方案 npm 包:

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

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

您可以查看逻辑的源代码,但我基本上遵循了上述答案中概述的菜谱@Vjeux。我还没有尝试过跳到一个特定的项目,但是我希望很快就能实现。

Here's the nitty-gritty of what the code currently looks like:

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;