ReactJS: Modelado de desplazamiento infinito bidireccional

114

Nuestra aplicación utiliza el desplazamiento infinito para navegar por grandes listas de elementos heterogéneos. Hay algunas arrugas:

  • Es común que nuestros usuarios tengan una lista de 10,000 elementos y necesiten desplazarse por más de 3000.
  • Estos son elementos ricos, por lo que solo podemos tener unos pocos cientos en el DOM antes de que el rendimiento del navegador sea inaceptable.
  • Los artículos son de diferentes alturas.
  • Los artículos pueden contener imágenes y permitimos al usuario saltar a una fecha específica. Esto es complicado porque el usuario puede saltar a un punto en la lista donde necesitamos cargar imágenes sobre la ventana gráfica, lo que empujaría el contenido hacia abajo cuando se carguen. No manejar eso significa que el usuario puede saltar a una fecha, pero luego cambiar a una fecha anterior.

Soluciones conocidas e incompletas:

  • ( react-infinite-scroll ) - Este es solo un simple componente de "carga más cuando tocamos el fondo". No elimina ninguno de los DOM, por lo que morirá en miles de elementos.

  • ( Posición de desplazamiento con React ): muestra cómo almacenar y restaurar la posición de desplazamiento al insertar en la parte superior o al insertar en la parte inferior, pero no ambos juntos.

No estoy buscando el código para una solución completa (aunque eso sería genial). En cambio, estoy buscando la "forma de Reaccionar" para modelar esta situación. ¿Es el estado de la posición de desplazamiento o no? ¿Qué estado debo seguir para mantener mi posición en la lista? ¿Qué estado debo mantener para activar un nuevo procesamiento cuando me desplazo cerca de la parte inferior o superior de lo que se representa?

Noé
fuente

Respuestas:

116

Esta es una mezcla de una tabla infinita y un escenario de desplazamiento infinito. La mejor abstracción que encontré para esto es la siguiente:

Visión general

Cree un <List>componente que tome una matriz de todos los elementos secundarios. Como no los renderizamos, es realmente económico asignarlos y descartarlos. Si las asignaciones de 10k son demasiado grandes, puede pasar una función que tome un rango y devuelva los elementos.

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

Tu List componente realiza un seguimiento de la posición de desplazamiento y solo muestra los elementos secundarios que están a la vista. Agrega un div vacío grande al principio para falsificar los elementos anteriores que no se procesan.

Ahora, lo interesante es que una vez que Elementse renderiza un componente, mide su altura y lo almacena en su archivo List. Esto le permite calcular la altura del espaciador y saber cuántos elementos deben mostrarse a la vista.

Imagen

Estás diciendo que cuando la imagen se está cargando hacen que todo "salte" hacia abajo. La solución para esto es establecer las dimensiones de la imagen en su etiqueta img:<img src="..." width="100" height="58" /> . De esta manera, el navegador no tiene que esperar para descargarlo antes de saber qué tamaño se mostrará. Esto requiere algo de infraestructura pero realmente vale la pena.

Si no puede saber el tamaño de antemano, agregue onload oyentes a su imagen y, cuando se cargue, mida su dimensión mostrada y actualice la altura de fila almacenada y compense la posición de desplazamiento.

Saltando a un elemento aleatorio

Si necesita saltar a un elemento aleatorio en la lista, eso requerirá algunos trucos con la posición de desplazamiento porque no conoce el tamaño de los elementos intermedios. Lo que le sugiero que haga es promediar las alturas de los elementos que ya ha calculado y saltar a la posición de desplazamiento de la última altura conocida + (número de elementos * promedio).

Como esto no es exacto, causará problemas cuando vuelva a la última posición buena conocida. Cuando ocurre un conflicto, simplemente cambie la posición de desplazamiento para solucionarlo. Esto moverá un poco la barra de desplazamiento, pero no debería afectarlo demasiado.

Reaccionar específicos

Desea proporcionar una clave para todos los elementos renderizados para que se mantengan en todos los renderizados. Hay dos estrategias: (1) tener solo n teclas (0, 1, 2, ... n) donde n es el número máximo de elementos que puede mostrar y usar su posición módulo n. (2) tener una clave diferente por elemento. Si todos los elementos comparten una estructura similar, es bueno usar (1) para reutilizar sus nodos DOM. Si no es así, utilice (2).

Solo tendría dos partes del estado de React: el índice del primer elemento y la cantidad de elementos que se muestran. La posición de desplazamiento actual y la altura de todos los elementos se adjuntarían directamente this. Cuando lo usas setState, en realidad estás haciendo una reproducción que solo debería ocurrir cuando cambia el rango.

Aquí hay un ejemplo de lista infinita usando algunas de las técnicas que describo en esta respuesta. Va a ser algo de trabajo, pero React es definitivamente una buena manera de implementar una lista infinita :)

Vjeux
fuente
4
Esta es una técnica asombrosa. ¡Gracias! Lo hice funcionar en uno de mis componentes. Sin embargo, tengo otro componente al que me gustaría aplicar esto, pero las filas no tienen una altura constante. Estoy trabajando en aumentar su ejemplo para calcular el displayEnd / visibleEnd para tener en cuenta las diferentes alturas ... a menos que tenga una mejor idea.
manalang
Implementé esto con un giro y encontré un problema: para mí, los registros que estoy renderizando son DOM algo complejos, y debido al número de ellos, no es prudente cargarlos todos en el navegador, así que estoy haciendo búsquedas asíncronas de vez en cuando. Por alguna razón, en ocasiones, cuando me desplazo y la posición salta muy lejos (digamos que salgo de la pantalla y vuelvo), ListBody no se vuelve a renderizar, aunque el estado cambia. ¿Alguna idea de por qué podría ser esto? ¡Gran ejemplo de lo contrario!
SleepyProgrammer
1
Su JSFiddle actualmente arroja un error: Uncaught ReferenceError: generate no está definido
Meglio
3
He hecho un violín actualizado , creo que debería funcionar igual. ¿Alguien quiere verificar? @Meglio
aknuds1
1
@ThomasModeneis hola, ¿puede aclarar los cálculos realizados en la línea 151 y 152, el displayStart y
displayEnd
2

eche un vistazo a http://adazzle.github.io/react-data-grid/index.html# Esto parece una cuadrícula de datos potente y de alto rendimiento con funciones similares a Excel y carga diferida / representación optimizada (para millones de filas) con ricas funciones de edición (con licencia MIT). Aún no se ha probado en nuestro proyecto, pero lo haremos muy pronto.

Un gran recurso para buscar cosas como estas también es http://react.rocks/ En este caso, una búsqueda de etiquetas es útil: http://react.rocks/tag/InfiniteScroll

Gregor
fuente
1

Enfrentaba un desafío similar para modelar el desplazamiento infinito en una sola dirección con alturas de elementos heterogéneos y, por lo tanto, hice un paquete npm con mi solución:

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

y una demostración: http://tnrich.github.io/react-variable-height-infinite-scroller/

Puede consultar el código fuente de la lógica, pero básicamente seguí la receta @Vjeux descrita en la respuesta anterior. Todavía no he abordado el salto a un elemento en particular, pero espero implementarlo pronto.

Aquí está el meollo de cómo se ve el código actualmente:

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
fuente