ReactJS: Modélisation du défilement infini bidirectionnel

114

Notre application utilise le défilement infini pour parcourir de grandes listes d'éléments hétérogènes. Il y a quelques rides:

  • Il est courant que nos utilisateurs aient une liste de 10000 éléments et doivent faire défiler 3k +.
  • Ce sont des éléments riches, nous ne pouvons donc en avoir que quelques centaines dans le DOM avant que les performances du navigateur ne deviennent inacceptables.
  • Les articles sont de différentes hauteurs.
  • Les éléments peuvent contenir des images et nous permettons à l'utilisateur de passer à une date spécifique. C'est délicat car l'utilisateur peut sauter à un point de la liste où nous devons charger des images au-dessus de la fenêtre, ce qui abaisserait le contenu lors de leur chargement. Ne pas gérer cela signifie que l'utilisateur peut sauter à une date, mais être ensuite décalé vers une date antérieure.

Solutions connues et incomplètes:

  • ( react-infinite-scroll ) - Ceci est juste un simple composant "charger plus lorsque nous touchons le bas". Il n'élimine aucun des DOM, il mourra donc sur des milliers d'éléments.

  • ( Position de défilement avec React ) - Montre comment stocker et restaurer la position de défilement lors de l'insertion en haut ou de l' insertion en bas, mais pas les deux ensemble.

Je ne cherche pas le code pour une solution complète (bien que ce serait génial.) Au lieu de cela, je cherche la "manière React" pour modéliser cette situation. L'état de la position de défilement est-il ou non? Quel état dois-je suivre pour conserver ma position dans la liste? Quel état dois-je conserver pour déclencher un nouveau rendu lorsque je fais défiler vers le bas ou le haut de ce qui est rendu?

Noé
la source

Réponses:

116

Il s'agit d'un mélange d'une table infinie et d'un scénario de défilement infini. La meilleure abstraction que j'ai trouvée pour cela est la suivante:

Aperçu

Créez un <List>composant qui prend un tableau de tous les enfants. Puisque nous ne les rendons pas, il est vraiment bon marché de simplement les allouer et de les supprimer. Si les allocations de 10k sont trop importantes, vous pouvez à la place passer une fonction qui prend une plage et renvoie les éléments.

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

Votre Listcomposant garde une trace de la position de défilement et ne rend que les enfants qui sont en vue. Il ajoute un grand div vide au début pour simuler les éléments précédents qui ne sont pas rendus.

Maintenant, la partie intéressante est qu'une fois qu'un Elementcomposant est rendu, vous mesurez sa hauteur et le stockez dans votre fichier List. Cela vous permet de calculer la hauteur de l'espaceur et de savoir combien d'éléments doivent être affichés dans la vue.

Image

Vous dites que lorsque l'image se charge, tout "saute" vers le bas. La solution pour cela est de définir les dimensions de l' image dans votre balise img: <img src="..." width="100" height="58" />. De cette façon, le navigateur n'a pas besoin d'attendre pour le télécharger avant de savoir quelle taille il va être affiché. Cela nécessite une infrastructure mais cela en vaut vraiment la peine.

Si vous ne pouvez pas connaître la taille à l'avance, ajoutez des onloadécouteurs à votre image et, lorsqu'elle est chargée, mesurez sa dimension affichée et mettez à jour la hauteur de ligne stockée et compensez la position de défilement.

Sauter sur un élément aléatoire

Si vous devez sauter sur un élément aléatoire de la liste, cela nécessitera une astuce avec la position de défilement car vous ne connaissez pas la taille des éléments entre les deux. Ce que je vous suggère de faire est de faire la moyenne des hauteurs d'élément que vous avez déjà calculées et de passer à la position de défilement de la dernière hauteur connue + (nombre d'éléments * moyenne).

Comme ce n'est pas exact, cela causera des problèmes lorsque vous reviendrez à la dernière bonne position connue. Lorsqu'un conflit survient, changez simplement la position du défilement pour le résoudre. Cela va déplacer un peu la barre de défilement mais ne devrait pas trop l'affecter.

Spécificités de React

Vous souhaitez fournir une clé à tous les éléments rendus afin qu'ils soient conservés dans tous les rendus. Il existe deux stratégies: (1) avoir seulement n touches (0, 1, 2, ... n) où n est le nombre maximum d'éléments que vous pouvez afficher et utiliser leur position modulo n. (2) avoir une clé différente par élément. Si tous les éléments partagent une structure similaire, il est bon d'utiliser (1) pour réutiliser leurs nœuds DOM. Si ce n'est pas le cas, utilisez (2).

Je n'aurais que deux éléments d'état React: l'index du premier élément et le nombre d'éléments affichés. La position de défilement actuelle et la hauteur de tous les éléments seraient directement associées this. Lors de l'utilisation, setStatevous effectuez un rendu qui ne devrait se produire que lorsque la plage change.

Voici un exemple de liste infinie utilisant certaines des techniques que je décris dans cette réponse. Ça va être du travail mais React est définitivement un bon moyen d'implémenter une liste infinie :)

Vjeux
la source
4
C'est une technique géniale. Merci! Je l'ai fait fonctionner sur l'un de mes composants. Cependant, j'ai un autre composant auquel je voudrais appliquer cela, mais les lignes n'ont pas une hauteur cohérente. Je travaille sur l'augmentation de votre exemple pour calculer le displayEnd / visibleEnd pour tenir compte des différentes hauteurs ... à moins que vous n'ayez une meilleure idée?
manalang le
J'ai implémenté cela avec une torsion et j'ai rencontré un problème: pour moi, les enregistrements que je rend sont un DOM un peu complexe, et à cause du nombre d'entre eux, il n'est pas prudent de tous les charger dans le navigateur, donc je suis faire des récupérations asynchrones de temps en temps. Pour une raison quelconque, à l'occasion, lorsque je fais défiler et que la position saute très loin (disons que je sors de l'écran et que je reviens), le ListBody ne se rend pas à nouveau, même si l'état change. Des idées pourquoi cela pourrait être? Excellent exemple sinon!
SleepyProgrammer
1
Votre JSFiddle renvoie actuellement une erreur: Uncaught ReferenceError: generate is not defined
Meglio
3
J'ai fait un violon mis à jour , je pense que cela devrait fonctionner de la même manière. Quelqu'un veut-il vérifier? @Meglio
aknuds1
1
@ThomasModeneis salut, pouvez-vous clarifier les calculs effectués sur les lignes 151 et 152, le displayStart et displayEnd
shortCircuit
2

jetez un œil à http://adazzle.github.io/react-data-grid/index.html# Cela ressemble à une grille de données puissante et performante avec des fonctionnalités de type Excel et un chargement paresseux / rendu optimisé (pour des millions de lignes) avec fonctionnalités d'édition riches (sous licence MIT). Pas encore essayé dans notre projet mais le fera très bientôt.

Une excellente ressource pour rechercher des choses comme celles-ci est également http://react.rocks/ Dans ce cas, une recherche de balises est utile: http://react.rocks/tag/InfiniteScroll

Gregor
la source
1

J'étais confronté à un défi similaire pour modéliser le défilement infini unidirectionnel avec des hauteurs d'éléments hétérogènes et j'ai donc créé un package npm à partir de ma solution:

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

et une démo: http://tnrich.github.io/react-variable-height-infinite-scroller/

Vous pouvez consulter le code source pour la logique, mais j'ai essentiellement suivi la recette @Vjeux décrite dans la réponse ci-dessus. Je n'ai pas encore abordé le passage à un élément particulier, mais j'espère le mettre en œuvre bientôt.

Voici les détails de ce à quoi le code ressemble actuellement:

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
la source