ReactJS: modelagem de rolagem bidirecional infinita

114

Nosso aplicativo usa rolagem infinita para navegar por grandes listas de itens heterogêneos. Existem algumas rugas:

  • É comum que nossos usuários tenham uma lista de 10.000 itens e precisem percorrer 3k +.
  • Esses são itens ricos, portanto, podemos ter apenas algumas centenas no DOM antes que o desempenho do navegador se torne inaceitável.
  • Os itens são de alturas variadas.
  • Os itens podem conter imagens e permitimos que o usuário pule para uma data específica. Isso é complicado porque o usuário pode pular para um ponto na lista em que precisamos carregar imagens acima da janela de visualização, o que empurraria o conteúdo para baixo ao carregar. Deixar de lidar com isso significa que o usuário pode pular para uma data, mas depois ser deslocado para uma data anterior.

Soluções conhecidas e incompletas:

  • ( react-infinite-scroll ) - Este é apenas um componente simples de "carregar mais quando chegarmos ao fundo". Ele não elimina nenhum DOM, então morrerá em milhares de itens.

  • ( Scroll Position with React ) - Mostra como armazenar e restaurar a posição do scroll ao inserir na parte superior ou na parte inferior, mas não os dois juntos.

Não estou procurando o código para uma solução completa (embora isso seja ótimo). Em vez disso, estou procurando a "maneira React" de modelar essa situação. A posição de rolagem está ou não no estado? Que estado devo rastrear para manter minha posição na lista? Qual estado eu preciso manter para acionar uma nova renderização ao rolar próximo à parte inferior ou superior do que foi renderizado?

Noé
fonte

Respostas:

116

Esta é uma mistura de uma mesa infinita e um cenário de rolagem infinita. A melhor abstração que encontrei para isso é a seguinte:

Visão geral

Faça um <List>componente que tenha um array de todos os filhos. Como não os renderizamos, é muito barato apenas alocá-los e descartá-los. Se as alocações de 10k forem muito grandes, você poderá passar uma função que tenha um intervalo e retornar os elementos.

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

Seu List componente está monitorando qual é a posição de rolagem e renderiza apenas os filhos que estão em exibição. Ele adiciona um grande div vazio no início para simular os itens anteriores que não são renderizados.

Agora, a parte interessante é que, uma vez que um Elementcomponente é renderizado, você mede sua altura e o armazena em seu List. Isso permite que você calcule a altura do espaçador e saiba quantos elementos devem ser exibidos na visualização.

Imagem

Você está dizendo que quando a imagem está carregando eles fazem tudo "saltar" para baixo. A solução para isso é definir as dimensões da imagem em sua tag img:<img src="..." width="100" height="58" /> . Desta forma, o navegador não precisa esperar para baixá-lo para saber o tamanho que será exibido. Isso requer alguma infraestrutura, mas realmente vale a pena.

Se você não pode saber o tamanho com antecedência, adicione onload ouvintes à sua imagem e, quando ela for carregada, meça sua dimensão exibida e atualize a altura da linha armazenada e compense a posição de rolagem.

Pulando em um elemento aleatório

Se você precisa pular para um elemento aleatório na lista, isso vai exigir alguns truques com a posição de rolagem, porque você não sabe o tamanho dos elementos intermediários. O que eu sugiro que você faça é calcular a média das alturas dos elementos que você já calculou e pular para a posição de rolagem da última altura conhecida + (número de elementos * média).

Como isso não é exato, causará problemas quando você chegar à última posição válida conhecida. Quando ocorre um conflito, basta alterar a posição do scroll para corrigi-lo. Isso vai mover a barra de rolagem um pouco, mas não deve afetá-lo muito.

React Specifics

Você deseja fornecer uma chave para todos os elementos renderizados para que sejam mantidos entre os renderizadores. Existem duas estratégias: (1) ter apenas n chaves (0, 1, 2, ... n) onde n é o número máximo de elementos que você pode exibir e usar seu módulo de posição n. (2) tem uma chave diferente por elemento. Se todos os elementos compartilham uma estrutura semelhante, é bom usar (1) para reutilizar seus nós DOM. Se não o fizerem, use (2).

Eu teria apenas duas partes do estado React: o índice do primeiro elemento e o número de elementos sendo exibidos. A posição de rolagem atual e a altura de todos os elementos seriam diretamente anexadas this. Ao usar, setStatevocê está, na verdade, fazendo um rerender que só deve acontecer quando o intervalo muda.

Aqui está um exemplo de lista infinita usando algumas das técnicas que descrevo nesta resposta. Vai dar certo, mas o React é definitivamente uma boa maneira de implementar uma lista infinita :)

Vjeux
fonte
4
Esta é uma técnica incrível. Obrigado! Fiz funcionar em um dos meus componentes. No entanto, tenho outro componente ao qual gostaria de aplicar isso, mas as linhas não têm uma altura consistente. Estou trabalhando para aumentar seu exemplo para calcular displayEnd / visibleEnd para levar em conta as alturas variáveis ​​... a menos que você tenha uma ideia melhor?
manalang
Eu implementei isso com uma torção e encontrei um problema: para mim, os registros que estou renderizando são DOM um tanto complexos e, por causa do número deles, não é prudente carregá-los todos no navegador, então estou fazendo buscas assíncronas de vez em quando. Por alguma razão, às vezes quando eu rolar e a posição pula muito (digamos que eu saia da tela e volto), o ListBody não é renderizado novamente, mesmo que o estado mude. Alguma ideia de por que isso pode ser? Grande exemplo de outra forma!
SleepyProgrammer
1
Seu JSFiddle atualmente gera um erro: Uncaught ReferenceError: gerar não está definido
Meglio
3
Fiz um violino atualizado , acho que deve funcionar da mesma forma. Alguém se preocupa em verificar? @Meglio
aknuds1
1
@ThomasModeneis oi, você pode esclarecer os cálculos feitos nas linhas 151 e 152, o displayStart e
displayEnd
2

dê uma olhada em http://adazzle.github.io/react-data-grid/index.html# Este parece ser um datagrid poderoso e de alto desempenho com recursos semelhantes ao Excel e carregamento lento / renderização otimizada (para milhões de linhas) com recursos de edição ricos (licenciados pelo MIT). Ainda não tentei em nosso projeto, mas o farei em breve.

Um ótimo recurso para pesquisar coisas como essas também é http://react.rocks/ Nesse caso, uma pesquisa de tag é útil: http://react.rocks/tag/InfiniteScroll

Gregor
fonte
1

Eu estava enfrentando um desafio semelhante para modelar a rolagem infinita de direção única com alturas de item heterogêneas e, portanto, criei um pacote npm de minha solução:

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

e uma demonstração: http://tnrich.github.io/react-variable-height-infinite-scroller/

Você pode verificar o código-fonte da lógica, mas basicamente segui a receita @Vjeux delineada na resposta acima. Ainda não pulei para um item específico, mas espero implementá-lo em breve.

Esta é a essência da aparência atual do código:

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
fonte