ReactJS: Modellierung des bidirektionalen unendlichen Bildlaufs


114

Unsere Anwendung verwendet unendliches Scrollen, um durch große Listen heterogener Elemente zu navigieren. Es gibt ein paar Falten:

  • Es ist üblich, dass unsere Benutzer eine Liste mit 10.000 Elementen haben und durch 3k + scrollen müssen.
  • Da es sich um umfangreiche Elemente handelt, können wir nur einige Hundert im DOM haben, bevor die Browserleistung nicht mehr akzeptabel ist.
  • Die Gegenstände sind unterschiedlich hoch.
  • Die Elemente können Bilder enthalten und wir erlauben dem Benutzer, zu einem bestimmten Datum zu springen. Dies ist schwierig, da der Benutzer zu einem Punkt in der Liste springen kann, an dem Bilder über dem Ansichtsfenster geladen werden müssen, wodurch der Inhalt beim Laden nach unten gedrückt wird. Wenn dies nicht behandelt wird, kann der Benutzer zu einem Datum springen, dann aber zu einem früheren Datum verschoben werden.

Bekannte, unvollständige Lösungen:

  • ( React-Infinite-Scroll ) - Dies ist nur eine einfache Komponente "Mehr laden, wenn wir den Boden erreichen". Es wird kein DOM ausgesondert, daher stirbt es an Tausenden von Gegenständen.

  • ( Bildlaufposition mit Reaktion ) - Zeigt an, wie die Bildlaufposition beim Einfügen oben oder unten, aber nicht bei beiden zusammen , gespeichert und wiederhergestellt wird .

Ich suche nicht nach dem Code für eine vollständige Lösung (obwohl das großartig wäre). Stattdessen suche ich nach dem "Reaktionsweg", um diese Situation zu modellieren. Ist der Status der Bildlaufposition oder nicht? Welchen Status sollte ich verfolgen, um meine Position in der Liste zu behalten? Welchen Status muss ich beibehalten, damit ich ein neues Rendering auslöse, wenn ich am unteren oder oberen Rand des gerenderten Bildlaufs scrolle?

Antworten:


116

Dies ist eine Mischung aus einer unendlichen Tabelle und einem unendlichen Bildlaufszenario. Die beste Abstraktion, die ich dafür gefunden habe, ist die folgende:

Überblick

Erstellen Sie eine <List>Komponente, die ein Array aller untergeordneten Elemente enthält. Da wir sie nicht rendern, ist es wirklich billig, sie einfach zuzuweisen und zu verwerfen. Wenn 10.000 Zuordnungen zu groß sind, können Sie stattdessen eine Funktion übergeben, die einen Bereich annimmt, und die Elemente zurückgeben.

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

Ihre ListKomponente verfolgt die Bildlaufposition und rendert nur die angezeigten untergeordneten Elemente. Am Anfang wird ein großes leeres Div hinzugefügt, um die vorherigen Elemente zu fälschen, die nicht gerendert wurden.

Der interessante Teil ist nun, dass ElementSie nach dem Rendern einer Komponente ihre Höhe messen und in Ihrer speichern List. Auf diese Weise können Sie die Höhe des Abstandshalters berechnen und wissen, wie viele Elemente in der Ansicht angezeigt werden sollen.

Bild

Sie sagen, dass beim Laden des Bildes alles nach unten "springt". Die Lösung hierfür besteht darin, die Bildabmessungen in Ihrem img-Tag festzulegen : <img src="..." width="100" height="58" />. Auf diese Weise muss der Browser nicht warten, um ihn herunterzuladen, bevor er weiß, welche Größe angezeigt wird. Dies erfordert eine gewisse Infrastruktur, aber es lohnt sich wirklich.

Wenn Sie die Größe nicht im Voraus kennen, fügen Sie onloadIhrem Bild Listener hinzu. Wenn es geladen ist, messen Sie die angezeigte Abmessung, aktualisieren Sie die gespeicherte Zeilenhöhe und kompensieren Sie die Bildlaufposition.

Auf ein zufälliges Element springen

Wenn Sie auf ein zufälliges Element in der Liste springen müssen, erfordert dies einige Tricks mit der Bildlaufposition, da Sie die Größe der dazwischen liegenden Elemente nicht kennen. Ich empfehle Ihnen, die bereits berechneten Elementhöhen zu mitteln und zur Bildlaufposition der letzten bekannten Höhe + (Anzahl der Elemente * Durchschnitt) zu springen.

Da dies nicht genau ist, wird es Probleme verursachen, wenn Sie zur letzten bekannten guten Position zurückkehren. Wenn ein Konflikt auftritt, ändern Sie einfach die Bildlaufposition, um ihn zu beheben. Dies wird die Bildlaufleiste ein wenig verschieben, sollte ihn / sie jedoch nicht zu sehr beeinträchtigen.

Reaktionsspezifikationen

Sie möchten allen gerenderten Elementen einen Schlüssel zur Verfügung stellen , damit sie über das Rendern hinweg beibehalten werden. Es gibt zwei Strategien: (1) haben nur n Tasten (0, 1, 2, ... n), wobei n die maximale Anzahl von Elementen ist, die Sie anzeigen und deren Positionsmodulo n verwenden können. (2) haben einen anderen Schlüssel pro Element. Wenn alle Elemente eine ähnliche Struktur haben, empfiehlt es sich, (1) zu verwenden, um ihre DOM-Knoten wiederzuverwenden. Wenn nicht, verwenden Sie (2).

Ich hätte nur zwei Teile des React-Status: den Index des ersten Elements und die Anzahl der angezeigten Elemente. Die aktuelle Bildlaufposition und die Höhe aller Elemente werden direkt angehängt this. Bei der Verwendung führen setStateSie tatsächlich eine erneute Wiedergabe durch, die nur dann erfolgen sollte, wenn sich der Bereich ändert.

Hier ist ein Beispiel für eine unendliche Liste mit einigen der Techniken, die ich in dieser Antwort beschreibe. Es wird eine Arbeit sein, aber React ist definitiv ein guter Weg, um eine unendliche Liste zu implementieren :)


4
Dies ist eine großartige Technik. Vielen Dank! Ich habe es an einer meiner Komponenten zum Laufen gebracht. Ich habe jedoch eine andere Komponente, auf die ich dies anwenden möchte, aber die Zeilen haben keine einheitliche Höhe. Ich arbeite daran, Ihr Beispiel zu erweitern, um das displayEnd / visibleEnd zu berechnen, um die unterschiedlichen Höhen zu berücksichtigen ... es sei denn, Sie haben eine bessere Idee?
Manalang

Ich habe dies mit einer Wendung implementiert und bin auf ein Problem gestoßen: Für mich sind die Datensätze, die ich rendere, ein etwas komplexes DOM, und aufgrund ihrer Anzahl ist es nicht ratsam, sie alle in den Browser zu laden, also bin ich es von Zeit zu Zeit asynchrone Abrufe durchführen. Aus irgendeinem Grund wird der ListBody gelegentlich, wenn ich scrolle und die Position sehr weit springt (sagen wir, ich gehe vom Bildschirm weg und zurück), nicht neu gerendert, obwohl sich der Status ändert. Irgendwelche Ideen, warum das so sein könnte? Ansonsten gutes Beispiel!
SleepyProgrammer

1
Ihre JSFiddle löst derzeit einen Fehler aus: Ungefangener Referenzfehler: Generieren ist nicht definiert
Meglio

3
Ich habe eine aktualisierte Geige gemacht , ich denke, es sollte genauso funktionieren. Möchte jemand überprüfen? @ Meglio
aknuds1

1
@ ThomasModeneis Hallo, können Sie die Berechnungen in den Zeilen 151 und 152, displayStart und displayEnd
shortCircuit

2

Schauen Sie sich http://adazzle.github.io/react-data-grid/index.html# an. Dies sieht aus wie ein leistungsstarkes und leistungsfähiges Datagrid mit Excel-ähnlichen Funktionen und verzögertem Laden / optimiertem Rendern (für Millionen von Zeilen) mit umfangreiche Bearbeitungsfunktionen (MIT-lizenziert). Noch nicht in unserem Projekt ausprobiert, wird es aber bald tun.

Eine großartige Ressource für die Suche nach solchen Dingen ist auch http://react.rocks/ In diesem Fall ist eine Tag-Suche hilfreich: http://react.rocks/tag/InfiniteScroll


1

Ich stand vor einer ähnlichen Herausforderung bei der Modellierung des unendlichen Bildlaufs in einer Richtung mit heterogenen Elementhöhen und machte aus meiner Lösung ein npm-Paket:

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

und eine Demo: http://tnrich.github.io/react-variable-height-infinite-scroller/

Sie können den Quellcode für die Logik überprüfen, aber ich habe im Grunde das Rezept @Vjeux befolgt, das in der obigen Antwort beschrieben ist. Ich habe mich noch nicht mit dem Springen zu einem bestimmten Gegenstand befasst, aber ich hoffe, dass ich das bald umsetzen kann.

Hier ist das Wesentliche, wie der Code derzeit aussieht:

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;
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.