ReactJS: modellazione dello scorrimento infinito bidirezionale


114

La nostra applicazione utilizza lo scorrimento infinito per navigare in grandi elenchi di elementi eterogenei. Ci sono alcune rughe:

  • È normale che i nostri utenti abbiano un elenco di 10.000 articoli e debbano scorrere 3k +.
  • Questi sono elementi ricchi, quindi possiamo averne solo poche centinaia nel DOM prima che le prestazioni del browser diventino inaccettabili.
  • Gli articoli sono di varie altezze.
  • Gli elementi possono contenere immagini e consentiamo all'utente di passare a una data specifica. Questo è complicato perché l'utente può saltare a un punto dell'elenco in cui è necessario caricare le immagini sopra la visualizzazione, il che spingerebbe il contenuto verso il basso durante il caricamento. Non riuscire a gestirlo significa che l'utente può saltare a una data, ma poi essere spostato a una data precedente.

Soluzioni note, incomplete:

  • ( react-infinite-scroll ) - Questo è solo un semplice componente "carica di più quando raggiungiamo il fondo". Non elimina nessuno dei DOM, quindi morirà su migliaia di elementi.

  • ( Scroll Position with React ) - Mostra come memorizzare e ripristinare la posizione di scorrimento quando si inserisce in alto o si inserisce in basso, ma non entrambi insieme.

Non sto cercando il codice per una soluzione completa (anche se sarebbe fantastico). Invece, sto cercando il "modo React" per modellare questa situazione. Lo stato della posizione di scorrimento è o no? Quale stato devo monitorare per mantenere la mia posizione nell'elenco? Quale stato devo mantenere in modo da attivare un nuovo rendering quando scorro vicino alla parte inferiore o superiore di ciò che viene visualizzato?

Risposte:


116

Questo è un mix di una tabella infinita e uno scenario di scorrimento infinito. La migliore astrazione che ho trovato per questo è la seguente:

Panoramica

Crea un <List>componente che prenda un array di tutti i figli. Dal momento che non li rendiamo, è davvero economico allocarli e scartarli. Se le allocazioni di 10k sono troppo grandi, puoi invece passare una funzione che prende un intervallo e restituisce gli elementi.

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

Il tuo List componente tiene traccia di quale sia la posizione di scorrimento e visualizza solo i figli che sono in vista. Aggiunge un grande div vuoto all'inizio per falsificare gli elementi precedenti che non vengono visualizzati.

Ora, la parte interessante è che una volta che un Elementcomponente è stato renderizzato, ne misuri l'altezza e la memorizzi nel tuo file List. Ciò consente di calcolare l'altezza dello spaziatore e di sapere quanti elementi devono essere visualizzati in vista.

Immagine

Stai dicendo che quando le immagini vengono caricate fanno "saltare" tutto in basso. La soluzione per questo è impostare le dimensioni dell'immagine nel tag img:<img src="..." width="100" height="58" /> . In questo modo il browser non deve attendere per scaricarlo prima di sapere quale dimensione verrà visualizzata. Ciò richiede alcune infrastrutture ma ne vale davvero la pena.

Se non puoi conoscere la dimensione in anticipo, aggiungi onload listener alla tua immagine e quando viene caricata misura la dimensione visualizzata e aggiorna l'altezza della riga memorizzata e compensa la posizione di scorrimento.

Saltare su un elemento casuale

Se è necessario saltare a un elemento casuale nell'elenco, sarà necessario un trucco con la posizione di scorrimento perché non si conosce la dimensione degli elementi intermedi. Quello che ti suggerisco di fare è fare la media delle altezze degli elementi che hai già calcolato e saltare alla posizione di scorrimento dell'ultima altezza nota + (numero di elementi * media).

Poiché questo non è esatto, causerà problemi quando torni all'ultima posizione buona nota. Quando si verifica un conflitto, è sufficiente modificare la posizione di scorrimento per risolverlo. Questo sposterà leggermente la barra di scorrimento ma non dovrebbe influire troppo su di lui / lei.

Specifiche di reazione

Si desidera fornire una chiave a tutti gli elementi di rendering in modo che vengano mantenuti durante i rendering. Esistono due strategie: (1) avere solo n tasti (0, 1, 2, ... n) dove n è il numero massimo di elementi che è possibile visualizzare e utilizzare la loro posizione modulo n. (2) avere una chiave diversa per elemento. Se tutti gli elementi condividono una struttura simile è bene usare (1) per riutilizzare i loro nodi DOM. In caso contrario, utilizzare (2).

Avrei solo due parti di stato React: l'indice del primo elemento e il numero di elementi visualizzati. La posizione di scorrimento corrente e l'altezza di tutti gli elementi sarebbero direttamente associate this. Quando si utilizza setStatesi sta effettivamente eseguendo un rendering che dovrebbe avvenire solo quando l'intervallo cambia.

Ecco un esempio di elenco infinito utilizzando alcune delle tecniche che descrivo in questa risposta. Ci vorrà del lavoro, ma React è sicuramente un buon modo per implementare una lista infinita :)


4
Questa è una tecnica fantastica. Grazie! Ho funzionato su uno dei miei componenti. Tuttavia, ho un altro componente a cui vorrei applicare questo, ma le righe non hanno un'altezza costante. Sto lavorando per aumentare il tuo esempio per calcolare displayEnd / visibleEnd per tenere conto delle altezze variabili ... a meno che tu non abbia un'idea migliore?
Manalang

L'ho implementato con una svolta e ho riscontrato un problema: per me, i record che sto renderizzando sono DOM piuttosto complessi e, a causa del numero di essi, non è prudente caricarli tutti nel browser, quindi sono facendo recuperi asincroni di tanto in tanto. Per qualche ragione, a volte quando scorro e posiziono salti molto lontano (diciamo che esco dallo schermo e ritorno), ListBody non esegue nuovamente il rendering, anche se lo stato cambia. Qualche idea sul perché questo potrebbe essere? Ottimo esempio altrimenti!
SleepyProgrammer

1
Il tuo JSFiddle attualmente genera un errore: Errore di riferimento non rilevato: la generazione non è definita
Meglio

3
Ho creato un violino aggiornato , penso che dovrebbe funzionare allo stesso modo. Qualcuno vuole verificare? @Meglio
aknuds1

1
@ThomasModeneis ciao, puoi chiarirmi i calcoli fatti sulle righe 151 e 152, displayStart e displayEnd
shortCircuit

2

dai un'occhiata a http://adazzle.github.io/react-data-grid/index.html# Sembra un datagrid potente e performante con funzionalità simili a Excel e caricamento lento / rendering ottimizzato (per milioni di righe) con ricche funzionalità di editing (con licenza MIT). Non ancora provato nel nostro progetto, ma lo farò presto.

Una grande risorsa per cercare cose come queste è anche http://react.rocks/ In questo caso, è utile una ricerca per tag: http://react.rocks/tag/InfiniteScroll


1

Stavo affrontando una sfida simile per la modellazione dello scorrimento infinito unidirezionale con altezze di elementi eterogenee e quindi ho creato un pacchetto npm dalla mia soluzione:

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

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

Puoi controllare il codice sorgente per la logica, ma ho sostanzialmente seguito la ricetta @Vjeux delineata nella risposta sopra. Non ho ancora affrontato il salto a un oggetto particolare, ma spero di implementarlo presto.

Ecco il nocciolo di come appare il codice attualmente:

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;
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.