ReactJS: моделирование двунаправленной бесконечной прокрутки

103

Наше приложение использует бесконечную прокрутку для навигации по большим спискам гетерогенных элементов. Есть несколько морщин:

  • Обычно для наших пользователей есть список из 10 000 элементов и вам нужно прокручивать 3k +.
  • Это богатые элементы, поэтому у нас может быть только несколько сотен в DOM до того, как производительность браузера станет неприемлемой.
  • Элементы имеют разную высоту.
  • Элементы могут содержать изображения, и мы разрешаем пользователю перейти к определенной дате. Это сложно, потому что пользователь может перейти к точке в списке, где нам нужно загрузить изображения над окном просмотра, что приведет к тому, что контент будет опущен при загрузке. Несоблюдение этого означает, что пользователь может перейти к дате, но затем будет перенесен на более раннюю дату.

Известные, неполные решения:

Я не ищу код для полного решения (хотя это было бы здорово.) Вместо этого я ищу способ "Реагировать" для моделирования этой ситуации. Состояние позиции прокрутки или нет? Какое состояние следует отслеживать, чтобы сохранить свою позицию в списке? Какое состояние мне нужно сохранить, чтобы я запускал новый рендер, когда я прокручиваю нижнюю или верхнюю часть того, что отображается?

Теги:
infinite-scroll

3 ответа

106
Лучший ответ

Это сочетание бесконечной таблицы и бесконечного сценария прокрутки. Лучшая абстракция, которую я нашел для этого, заключается в следующем:

Обзор

Создайте компонент <List>, который принимает массив из всех. Поскольку мы их не представляем, очень дешево просто выделить их и отказаться от них. Если 10k-распределения слишком большие, вы можете вместо этого передать функцию, которая принимает диапазон и возвращает элементы.

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

Ваш компонент List отслеживает позицию прокрутки и отображает только те дети, которые находятся в поле зрения. Он добавляет большой пустой div в начале, чтобы подделать предыдущие элементы, которые не отображаются.

Теперь интересная часть состоит в том, что после рендеринга компонента Element вы измеряете его высоту и сохраняете ее в List. Это позволяет вам вычислить высоту проставки и узнать, сколько элементов должно отображаться в поле зрения.

Изображение

Вы говорите, что когда изображение загружается, они делают все "прыгающим" вниз. Решение для этого - установить размеры изображения в тэге img: <img src="..." width="100" height="58" />. Таким образом, браузер не должен ждать, чтобы загрузить его, прежде чем знать, какой размер он будет отображаться. Это требует некоторой инфраструктуры, но это действительно того стоит.

Если вы не можете заранее знать размер, добавьте onload прослушиватели к своему изображению, и когда он будет загружен, измерьте его отображаемое измерение и обновите сохраненную высоту строки и скорректируйте положение прокрутки.

Прыжки в случайном элементе

Если вам нужно перейти к случайному элементу в списке, который потребует некоторого обмана с позиции прокрутки, потому что вы не знаете размер элементов между ними. Я предлагаю вам сделать это, чтобы усреднить высоту элементов, которые вы уже вычислили, и перейти к позиции прокрутки последней известной высоты + (количество элементов * среднее).

Так как это не точно, это вызовет проблемы, когда вы вернетесь к последней известной хорошей позиции. Когда происходит конфликт, просто измените положение прокрутки, чтобы исправить его. Это немного сдвинет полосу прокрутки, но не должно сильно повлиять на него/нее.

Особенности реакции

Вы хотите предоставить key всем отображаемым элементам, чтобы они поддерживались через визуализацию. Существуют две стратегии: (1) имеют только n ключей (0, 1, 2,... n), где n - максимальное количество элементов, которые вы можете отобразить, и использовать их положение по модулю n. (2) имеют разные ключи для каждого элемента. Если все элементы имеют сходную структуру, полезно использовать (1) для повторного использования своих узлов DOM. Если они не используют, то используйте (2).

У меня было бы только две части состояния React: индекс первого элемента и количество отображаемых элементов. Текущее положение прокрутки и высота всех элементов будут напрямую привязаны к this. При использовании setState вы действительно делаете ревердер, который должен произойти только при изменении диапазона.

Вот пример http://jsfiddle.net/vjeux/KbWJ2/9/ бесконечного списка, используя некоторые из тех методов, которые я описываю в этом ответе. Это будет какая-то работа, но React окончательно является хорошим способом реализации бесконечного списка:)

  • 4
    Это потрясающая техника. Спасибо! Я получил это работает на одном из моих компонентов. Однако у меня есть еще один компонент, к которому я хотел бы применить это, но строки не имеют одинаковой высоты. Я работаю над дополнением вашего примера, чтобы вычислить displayEnd / visibleEnd для учета различной высоты ... разве у вас нет лучшей идеи?
  • 0
    Я реализовал это с изюминкой, и столкнулся с проблемой: для меня записи, которые я рендеринг, являются довольно сложным DOM, и из-за # из них не разумно загружать их все в браузер, поэтому я делать асинхронные выборки время от времени. По какой-то причине, когда я прокручиваю и прыжки положения очень далеко (скажем, я выхожу за пределы экрана и обратно), ListBody не выполняет повторную визуализацию, даже если состояние изменяется. Есть идеи, почему это может быть? Отличный пример в противном случае!
Показать ещё 7 комментариев
1

Я столкнулся с аналогичной задачей для моделирования однонаправленной бесконечной прокрутки с неоднородными высотами элементов и поэтому сделал из пакета npm пакет:

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

и демо: http://tnrich.github.io/react-variable-height-infinite-scroller/

Вы можете проверить исходный код для логики, но я в основном придерживался рецепта @Vjeux, изложенного в приведенном выше ответе. Я еще не занялся прыжком к определенному предмету, но я надеюсь скоро это реализовать.

Здесь nitty-gritty того, как выглядит код в настоящее время:

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 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;
0

посмотрите http://adazzle.github.io/react-data-grid/index.html# Это выглядит как мощный и эффективный datagrid с функциями, подобными Excel, и ленивый рендеринг/оптимизированный рендеринг (для миллионов строк) с богатыми функциями редактирования (лицензия MIT). Еще не опробован в нашем проекте, но скоро это сделаем.

Отличный ресурс для поиска таких вещей также http://react.rocks/ В этом случае полезно использовать поиск тегов: http://react.rocks/tag/InfiniteScroll

Ещё вопросы

Сообщество Overcoder
Наверх
Меню