ReactJS: Modelowanie dwukierunkowego nieskończonego przewijania

114

Nasza aplikacja wykorzystuje nieskończone przewijanie do nawigacji po dużych listach heterogenicznych elementów. Zmarszczek jest kilka:

  • Często zdarza się, że nasi użytkownicy mają listę 10 000 pozycji i muszą przewijać ponad 3 tys.
  • Są to bogate elementy, więc w DOM możemy mieć tylko kilkaset, zanim wydajność przeglądarki stanie się nie do przyjęcia.
  • Przedmioty mają różną wysokość.
  • Pozycje mogą zawierać obrazy i pozwalamy użytkownikowi przejść do określonej daty. Jest to trudne, ponieważ użytkownik może przeskoczyć do punktu na liście, w którym musimy załadować obrazy powyżej widocznego obszaru, co spowodowałoby przesunięcie treści w dół podczas ładowania. Niezrozumienie tego oznacza, że ​​użytkownik może przeskoczyć do daty, ale potem zostać przeniesiony na datę wcześniejszą.

Znane, niekompletne rozwiązania:

  • ( reaguj-nieskończone-przewijanie ) - To jest po prostu prosty element „załaduj więcej, gdy trafimy na najniższy” element. Nie usuwa żadnego DOM, więc umrze przy tysiącach przedmiotów.

  • ( Pozycja przewijania z reakcją ) - Pokazuje, jak zapisać i przywrócić pozycję przewijania podczas wstawiania na górze lub na dole, ale nie obu razem.

Nie szukam kodu dla kompletnego rozwiązania (chociaż byłoby świetnie.) Zamiast tego szukam sposobu „Reagowania”, aby modelować tę sytuację. Czy stan pozycji przewijania jest czy nie? Jaki stan powinienem śledzić, aby zachować swoją pozycję na liście? Jaki stan muszę zachować, aby uruchomić nowy render, gdy przewijam w pobliżu dolnej lub górnej części renderowanego elementu?

noah
źródło

Odpowiedzi:

116

Jest to połączenie nieskończonego stołu i nieskończonego scenariusza przewijania. Najlepsza abstrakcja, jaką znalazłem, jest następująca:

Przegląd

Utwórz <List>składnik, który przyjmuje tablicę wszystkich dzieci. Ponieważ ich nie renderujemy, po prostu przydzielanie ich i odrzucanie jest naprawdę tanie. Jeśli alokacje 10k są zbyt duże, możesz zamiast tego przekazać funkcję, która przyjmuje zakres i zwraca elementy.

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

Twój List komponent śledzi położenie przewijania i renderuje tylko te elementy podrzędne, które są widoczne. Na początku dodaje duży pusty element div, aby sfałszować poprzednie elementy, które nie są renderowane.

Ciekawe jest to, że kiedyś plik Element wyrenderowaniu komponentu mierzysz jego wysokość i przechowujesz wList . Pozwala to obliczyć wysokość odstępnika i dowiedzieć się, ile elementów powinno być wyświetlonych.

Wizerunek

Mówisz, że ładowanie obrazu powoduje, że wszystko „skacze” w dół. Rozwiązaniem tego problemu jest ustawienie wymiarów obrazu w tagu img:<img src="..." width="100" height="58" /> . W ten sposób przeglądarka nie musi czekać, aby go pobrać, zanim wie, jaki rozmiar będzie wyświetlany. Wymaga to trochę infrastruktury, ale naprawdę warto.

Jeśli nie możesz z góry poznać rozmiaru, dodaj onload detektory do obrazu, a po załadowaniu zmierz jego wyświetlany wymiar, zaktualizuj zapisaną wysokość wiersza i skompensuj pozycję przewijania.

Skakanie po losowym elemencie

Jeśli chcesz przeskoczyć do losowego elementu na liście, który będzie wymagał pewnych sztuczek z pozycją przewijania, ponieważ nie znasz rozmiaru elementów pomiędzy nimi. Sugeruję, abyś uśrednił już obliczone wysokości elementów i przeskoczył do pozycji przewijania ostatniej znanej wysokości + (liczba elementów * średnia).

Ponieważ nie jest to dokładne, spowoduje to problemy po powrocie do ostatniej znanej dobrej pozycji. Gdy wystąpi konflikt, po prostu zmień pozycję przewijania, aby go naprawić. To trochę przesunie pasek przewijania, ale nie powinno na niego / jej zbytnio wpłynąć.

Reaguj na szczegóły

Chcesz podać klucz do wszystkich renderowanych elementów, aby były one obsługiwane przez wszystkie renderowania. Istnieją dwie strategie: (1) mają tylko n kluczy (0, 1, 2, ... n) gdzie n to maksymalna liczba elementów, które można wyświetlić i użyć ich pozycji modulo n. (2) mają inny klucz na element. Jeśli wszystkie elementy mają podobną strukturę, dobrze jest użyć (1), aby ponownie użyć ich węzłów DOM. Jeśli nie, użyj (2).

Miałbym tylko dwa elementy stanu React: indeks pierwszego elementu i liczbę wyświetlanych elementów. Bieżąca pozycja przewijania i wysokość wszystkich elementów zostaną bezpośrednio dołączone this. Podczas używaniasetState , w rzeczywistości dokonujesz rerenderowania, co powinno nastąpić tylko wtedy, gdy zmieni się zakres.

Oto przykład nieskończonej listy przy użyciu niektórych technik, które opisuję w tej odpowiedzi. To będzie trochę pracy, ale React jest zdecydowanie dobrym sposobem na zaimplementowanie nieskończonej listy :)

Vjeux
źródło
4
To niesamowita technika. Dzięki! Mam to działające na jednym z moich komponentów. Jednak mam inny składnik, do którego chciałbym to zastosować, ale wiersze nie mają stałej wysokości. Pracuję nad rozszerzeniem twojego przykładu, aby obliczyć displayEnd / visibleEnd, aby uwzględnić różne wysokości ... chyba że masz lepszy pomysł?
manalang
Zaimplementowałem to z niespodzianką i napotkałem problem: dla mnie rekordy, które renderuję, są nieco złożonym DOM, a z powodu ich # nie jest rozsądne ładowanie ich wszystkich do przeglądarki, więc jestem od czasu do czasu asynchroniczne pobieranie. Z jakiegoś powodu, czasami, gdy przewijam i przeskakuję bardzo daleko (powiedzmy, że wychodzę z ekranu iz powrotem), ListBody nie renderuje ponownie, mimo że stan się zmienia. Jakieś pomysły, dlaczego tak może być? Świetny przykład inaczej!
SleepyProgrammer
1
Twój JSFiddle obecnie zgłasza błąd: Uncaught ReferenceError: Generowanie nie jest zdefiniowane
Meglio
3
Zrobiłem zaktualizowane skrzypce , myślę, że powinno działać tak samo. Czy ktoś chce zweryfikować? @Meglio
aknuds1
1
@ThomasModeneis cześć, czy możesz wyjaśnić obliczenia wykonane na liniach 151 i 152, wyświetlaczStart i
displayEnd
2

zajrzyj na http://adazzle.github.io/react-data-grid/index.html# To wygląda na potężny i wydajny datagrid z funkcjami podobnymi do Excela i leniwym ładowaniem / zoptymalizowanym renderowaniem (dla milionów wierszy) z bogate funkcje edycji (licencja MIT). Jeszcze nie próbowałem w naszym projekcie, ale zrobię to wkrótce.

Świetnym źródłem do wyszukiwania takich rzeczy jest również http://react.rocks/ W tym przypadku pomocne jest wyszukiwanie tagów: http://react.rocks/tag/InfiniteScroll

Gregor
źródło
1

Stałem przed podobnym wyzwaniem związanym z modelowaniem nieskończonego przewijania w jednym kierunku z niejednorodnymi wysokościami elementów, dlatego z mojego rozwiązania utworzyłem pakiet npm:

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

i demo: http://tnrich.github.io/react-variable-height-infinite-scroller/

Możesz sprawdzić kod źródłowy logiki, ale zasadniczo postępowałem zgodnie z przepisem @Vjeux przedstawionym w powyższej odpowiedzi. Nie zająłem się jeszcze przeskakiwaniem do konkretnego przedmiotu, ale mam nadzieję, że wkrótce to zaimplementuję.

Oto szczegóły tego, jak obecnie wygląda kod:

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
źródło