ReactJS: Моделювання двонаправленого нескінченного прокручування


114

Наш додаток використовує нескінченну прокрутку для навігації по великих списках різнорідних предметів. Є кілька зморшок:

  • Нашим користувачам властиво мати список з 10 000 предметів і потрібно прокручувати 3k +.
  • Це багаті елементи, тому ми можемо мати лише кілька сотень у DOM, перш ніж продуктивність браузера стане неприйнятною.
  • Елементи різної висоти.
  • Елементи можуть містити зображення, і ми дозволяємо користувачу перейти до певної дати. Це складно, тому що користувач може перейти до точки у списку, куди нам потрібно завантажити зображення над вікном перегляду, що підштовхує вміст при завантаженні. Якщо не впоратися, це означає, що користувач може перейти на дату, але потім перейти на більш ранню дату.

Відомі, неповні рішення:

Я не шукаю коду для повного рішення (хоча це було б чудово.) Натомість я шукаю «Реагувати шлях» для моделювання цієї ситуації. Є стан позиції прокрутки чи ні? Який стан я повинен відстежувати, щоб зберегти свою позицію у списку? Який стан мені потрібно зберегти, щоб запустити нову візуалізацію, коли прокручую біля нижньої або верхньої частини рендеринга?

Відповіді:


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, безумовно, є хорошим способом реалізації нескінченного списку :)


4
Це дивовижна техніка. Дякую! У мене це працює над одним із моїх компонентів. Однак у мене є ще один компонент, до якого я б хотів застосувати це, але рядки не мають однакової висоти. Я працюю над збільшенням вашого прикладу, щоб обчислити displayEnd / visibleEnd для врахування різної висоти ... якщо ви не маєте кращої ідеї?
маналанг

Я реалізував це з поворотом і зіткнувся з проблемою: Для мене записи, які я рендерую, є дещо складними DOM, і через # з них не доцільно завантажувати їх усіх у браузер, тому я роблячи час від часу асинхронізацію. Чомусь, коли я прокручую і положення дуже стрибає (скажімо, я виходжу з екрана і назад), ListBody не повторюється, навіть якщо стан змінюється. Будь-які ідеї, чому це може бути? Чудовий приклад інакше!
SleepyProgrammer

1
Ваш JSFiddle наразі видає помилку: Uncaught ReferenceError: generator не визначено
Meglio

3
Я склав оновлену загадку , я думаю, вона повинна працювати так само. Хтось хоче перевірити? @Meglio
aknuds1

1
@ThomasModeneis привіт, ви можете уточнити розрахунки, зроблені на рядках 151 і 152, displayStart і displayEnd
shortCircuit

2

подивіться на http://adazzle.github.io/react-data-grid/index.html# Це схоже на потужну та ефективну мережу даних із функціями, схожими на 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;
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.