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

114

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

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

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

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

Ной
источник

Ответы:

116

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

Обзор

Создайте <List>компонент, который принимает массив всех дочерних элементов . Поскольку мы не обрабатываем их, очень дешево просто выделить их и отбросить. Если выделения 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 определенно хороший способ реализовать бесконечный список :)

Vjeux
источник
4
Это потрясающая техника. Спасибо! Я заставил его работать над одним из моих компонентов. Однако у меня есть еще один компонент, к которому я бы хотел применить это, но строки не имеют постоянной высоты. Я работаю над расширением вашего примера, чтобы вычислить displayEnd / visibleEnd для учета разной высоты ... если у вас нет идеи получше?
маналанг
Я реализовал это с хитростью и столкнулся с проблемой: для меня записи, которые я визуализирую, представляют собой несколько сложную DOM, и из-за их количества загружать их все в браузер нецелесообразно, поэтому я время от времени выполняя асинхронную выборку. По какой-то причине, когда я прокручиваю и меняя положение очень далеко (скажем, я выхожу за пределы экрана и возвращаюсь), ListBody не перерисовывается, даже если состояние меняется. Есть идеи, почему это может быть? В противном случае отличный пример!
SleepyProgrammer
1
Ваш JSFiddle в настоящее время выдает ошибку: Uncaught ReferenceError: generate is not defined
Meglio
3
Я сделал обновленную скрипку , думаю, она должна работать так же. Кто-нибудь хочет проверить? @Meglio
aknuds1 01
1
@ThomasModeneis привет, не могли бы вы уточнить вычисления, сделанные в строке 151 и 152, displayStart и displayEnd
shortCircuit
2

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

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

Грегор
источник
1

Я столкнулся с аналогичной проблемой при моделировании однонаправленной бесконечной прокрутки с разнородной высотой элементов, поэтому из своего решения сделал пакет 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;
MajorBummer
источник