ReactJS: Çift Yönlü Sonsuz Kaydırmayı Modelleme


114

Uygulamamız, büyük heterojen öğeler listelerinde gezinmek için sonsuz kaydırma kullanır. Birkaç kırışıklık var:

  • Kullanıcılarımızın 10.000 öğelik bir listeye sahip olması ve 3k + 'da gezinmesi yaygın bir durumdur.
  • Bunlar zengin öğelerdir, bu nedenle, tarayıcı performansı kabul edilemez hale gelmeden önce DOM'da yalnızca birkaç yüze sahip olabiliriz.
  • Öğeler farklı yüksekliklerdedir.
  • Öğeler resimler içerebilir ve kullanıcının belirli bir tarihe atlamasına izin veririz. Bu aldatıcıdır çünkü kullanıcı, görüntüleri görüntü alanının yukarısında yüklememiz gereken ve yüklendiklerinde içeriği aşağı iten bir noktaya atlayabilir. Bunun işlenememesi, kullanıcının bir tarihe atlayabileceği, ancak daha sonra daha önceki bir tarihe kaydırılabileceği anlamına gelir.

Bilinen, eksik çözümler:

  • ( react-infinite-scroll ) - Bu sadece basit bir "dibe vurduğumuzda daha fazla yükle" bileşenidir. DOM’un hiçbirini ayırmaz, bu nedenle binlerce öğede ölecektir.

  • ( React ile Kaydırma Konumu ) - Yukarıya veya aşağıya yerleştirirken kaydırma konumunun nasıl saklanacağını ve geri yükleneceğini gösterir, ancak ikisi birden değil.

Tam bir çözüm için kod aramıyorum (yine de bu harika olurdu.) Bunun yerine, bu durumu modellemek için "React yolunu" arıyorum. Kaydırma konumu durumu mu, değil mi? Listedeki konumumu korumak için hangi durumu izlemeliyim? Oluşturulan şeyin altına veya üstüne yakın bir yere kaydırdığımda yeni bir oluşturmayı tetiklemek için hangi durumu korumam gerekiyor?

Yanıtlar:


116

Bu, sonsuz bir tablo ile sonsuz kaydırma senaryosunun bir karışımıdır. Bunun için bulduğum en iyi soyutlama şudur:

genel bakış

Bir Make <List>dizisini alır bileşeni bütün çocuklar. Onları render etmediğimiz için, onları sadece tahsis edip atmak gerçekten ucuz. 10k ayırma çok büyükse, bunun yerine bir aralığı alan ve öğeleri döndüren bir işlevi iletebilirsiniz.

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

Kişisel Listbileşeni kaydırma konumu ne takip ve sadece görünümünde çocukları vermektedir edilir. Oluşturulmayan önceki öğeleri taklit etmek için başlangıca büyük bir boş div ekler.

Şimdi, ilginç olan kısım, bir Elementbileşen oluşturulduktan sonra yüksekliğini ölçüp List,. Bu, boşluk tutucunun yüksekliğini hesaplamanıza ve görünümde kaç öğenin görüntülenmesi gerektiğini bilmenize olanak tanır.

görüntü

Resim yüklenirken her şeyi "aşağıya atladıklarını" söylüyorsunuz. Bunun için çözüm, img etiketinin görüntü boyutlarını belirlemektir: <img src="..." width="100" height="58" />. Bu şekilde tarayıcının, hangi boyutta görüntüleneceğini bilmeden önce indirmeyi beklemesi gerekmez. Bu biraz altyapı gerektirir ama buna gerçekten değer.

Boyutu önceden bilemiyorsanız, onloadgörüntünüze dinleyiciler ekleyin ve yüklendiğinde görüntülenen boyutunu ölçün ve depolanan satır yüksekliğini güncelleyin ve kaydırma konumunu dengeleyin.

Rastgele bir öğeye atlama

Listedeki rastgele bir öğeye atlamanız gerekiyorsa, bu, kaydırma konumunda biraz hile gerektirecek çünkü aradaki öğelerin boyutunu bilmiyorsunuz. Yapmanızı önerdiğim şey, halihazırda hesaplamış olduğunuz eleman yüksekliklerinin ortalamasını almak ve bilinen son yükseklik + (eleman sayısı * ortalama) kaydırma konumuna atlamaktır.

Bu kesin olmadığından, bilinen son iyi konuma geri döndüğünüzde sorunlara neden olacaktır. Bir çakışma olduğunda, düzeltmek için kaydırma konumunu değiştirmeniz yeterlidir. Bu, kaydırma çubuğunu biraz hareket ettirecek ancak onu çok fazla etkilememelidir.

React Specifics

Oluşturulan tüm öğelere, işlemeler boyunca korunmaları için bir anahtar sağlamak istiyorsunuz . İki strateji vardır: (1) yalnızca n tuşa (0, 1, 2, ... n) sahip olup burada n, görüntüleyebileceğiniz ve konum modulo n'larını kullanabileceğiniz maksimum öğe sayısıdır. (2) öğe başına farklı bir anahtara sahiptir. Tüm öğeler benzer bir yapıyı paylaşıyorsa, DOM düğümlerini yeniden kullanmak için (1) 'i kullanmak iyidir. Eğer yapmazlarsa (2) 'yi kullanın.

Yalnızca iki parça React durumum olurdu: ilk öğenin indeksi ve görüntülenen öğelerin sayısı. Geçerli kaydırma konumu ve tüm öğelerin yüksekliği doğrudan eklenir this. Kullanırken setState, aslında yalnızca aralık değiştiğinde olması gereken bir yeniden işleme yapıyorsunuz.

İşte bu cevapta anlattığım bazı teknikleri kullanan sonsuz liste örneği . Biraz iş olacak ama React, sonsuz bir listeyi uygulamak için kesinlikle iyi bir yoldur :)


4
Bu harika bir teknik. Teşekkürler! Bileşenlerimden biri üzerinde çalıştırdım. Ancak, bunu uygulamak istediğim başka bir bileşenim var, ancak satırların tutarlı bir yüksekliği yok. Değişen yükseklikleri hesaba katmak için displayEnd / visibleEnd değerini hesaplamak için örneğinizi büyütmeye çalışıyorum ... daha iyi bir fikriniz yoksa?
manalang

Bunu bir bükülme ile uyguladım ve bir sorunla karşılaştım: Benim için, oluşturduğum kayıtlar biraz karmaşık DOM ve bunların # tanesi nedeniyle hepsini tarayıcıya yüklemek mantıklı değil, bu yüzden ben zaman zaman eşzamansız yapmak. Bazı nedenlerden dolayı, bazen kaydırdığımda ve konum çok uzağa sıçradığında (diyelim ki ekrandan çıkıp geri dönüyorum), durum değişse bile ListBody yeniden işlenmiyor. Bunun neden olabileceğine dair bir fikriniz var mı? Aksi takdirde harika bir örnek!
SleepyProgrammer

1
JSFiddle'ınız şu anda bir hata veriyor: Yakalanmamış ReferenceError: oluşturma tanımlanmadı
Meglio

3
Yarattığım güncelleştirilmiş keman , ben aynı çalışması gerektiğini düşünüyorum. Doğrulamak isteyen var mı? @Meglio
aknuds1

1
@ThomasModeneis merhaba, 151 ve 152 satırlarında yapılan hesaplamaları netleştirebilir misiniz, ekran
Başlat

2

http://adazzle.github.io/react-data-grid/index.html# adresine bir göz atın Bu, Excel benzeri özellikler ve tembel yükleme / optimize edilmiş oluşturma (milyonlarca satır için) ile güçlü ve performanslı bir veri sayfasına benziyor. zengin düzenleme özellikleri (MIT lisanslı). Henüz projemizde denenmedi ama çok yakında olacak.

Buna benzer şeyleri aramak için harika bir kaynak da http://react.rocks/ Bu durumda, etiket araması yararlıdır: http://react.rocks/tag/InfiniteScroll


1

Heterojen öğe yükseklikleriyle tek yönlü sonsuz kaydırmayı modellemek için benzer bir zorlukla karşı karşıyaydım ve bu nedenle çözümümden bir npm paketi yaptım:

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

ve bir demo: http://tnrich.github.io/react-variable-height-infinite-scroller/

Mantık için kaynak kodunu kontrol edebilirsiniz, ancak temel olarak yukarıdaki cevapta özetlenen @Vjeux tarifini takip ettim. Henüz belirli bir öğeye atlamakla uğraşmadım, ancak bunu yakında uygulamayı umuyorum.

İşte kodun şu anda neye benzediğinin özü:

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;
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.