ReactJS: Modeling Bi-Directional Infinite Scrolling

114

Aplikasi kami menggunakan pengguliran tak terbatas untuk menavigasi daftar besar item heterogen. Ada beberapa kerutan:

  • Sangat umum bagi pengguna kami untuk memiliki daftar 10.000 item dan perlu menggulir 3k +.
  • Ini adalah item kaya, jadi kami hanya dapat memiliki beberapa ratus di DOM sebelum kinerja browser menjadi tidak dapat diterima.
  • Item memiliki ketinggian yang berbeda-beda.
  • Item mungkin berisi gambar dan kami mengizinkan pengguna untuk melompat ke tanggal tertentu. Ini rumit karena pengguna bisa melompat ke satu titik dalam daftar tempat kita perlu memuat gambar di atas viewport, yang akan mendorong konten ke bawah saat dimuat. Gagal menangani itu berarti bahwa pengguna dapat melompat ke tanggal tertentu, tetapi kemudian dialihkan ke tanggal yang lebih awal.

Solusi yang diketahui dan tidak lengkap:

  • ( react-infinite-scroll ) - Ini hanyalah komponen sederhana "memuat lebih banyak saat kita mencapai bagian bawah". Itu tidak menyisihkan DOM mana pun, sehingga akan mati pada ribuan item.

  • ( Scroll Position with React ) - Menunjukkan cara menyimpan dan mengembalikan posisi scroll saat memasukkan di bagian atas atau memasukkan di bagian bawah, tetapi tidak keduanya bersamaan.

Saya tidak mencari kode untuk solusi lengkap (meskipun itu akan bagus.) Sebaliknya, saya mencari "cara Bereaksi" untuk memodelkan situasi ini. Apakah status posisi gulir atau tidak? Status apa yang harus saya lacak untuk mempertahankan posisi saya di daftar? Keadaan apa yang perlu saya pertahankan agar saya memicu render baru saat saya menggulir di dekat bagian bawah atau atas apa yang dirender?

noah
sumber

Jawaban:

116

Ini adalah campuran dari tabel tak terbatas dan skenario gulir tak terbatas. Abstraksi terbaik yang saya temukan untuk ini adalah sebagai berikut:

Gambaran

Buat <List>komponen yang mengambil array dari semua anak. Karena kami tidak merendernya, sangat murah untuk mengalokasikannya dan membuangnya. Jika alokasi 10k terlalu besar, Anda dapat meneruskan fungsi yang mengambil rentang dan mengembalikan elemen.

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

ListKomponen Anda melacak posisi scroll dan hanya menampilkan anak-anak yang terlihat. Ini menambahkan div kosong besar di awal untuk memalsukan item sebelumnya yang tidak dirender.

Sekarang, bagian yang menarik adalah setelah sebuah Elementkomponen dibuat, Anda mengukur tingginya dan menyimpannya di file List. Ini memungkinkan Anda menghitung ketinggian spacer dan mengetahui berapa banyak elemen yang harus ditampilkan dalam tampilan.

Gambar

Anda mengatakan bahwa saat gambar dimuat, semuanya "melompat" ke bawah. Solusi untuk ini adalah untuk mengatur dimensi gambar di tag img Anda: <img src="..." width="100" height="58" />. Dengan cara ini browser tidak perlu menunggu untuk mendownloadnya sebelum mengetahui ukuran apa yang akan ditampilkan. Ini membutuhkan beberapa infrastruktur tetapi itu sangat berharga.

Jika Anda tidak dapat mengetahui ukurannya terlebih dahulu, tambahkan onloadpendengar ke gambar Anda dan saat dimuat, ukur dimensi yang ditampilkan dan perbarui tinggi baris yang disimpan dan kompensasi posisi gulir.

Melompat di elemen acak

Jika Anda perlu melompat ke elemen acak dalam daftar itu akan membutuhkan beberapa trik dengan posisi gulir karena Anda tidak tahu ukuran elemen di antaranya. Apa yang saya sarankan untuk Anda lakukan adalah untuk meratakan ketinggian elemen yang telah Anda hitung dan melompat ke posisi gulir dari ketinggian yang diketahui terakhir + (jumlah elemen * rata-rata).

Karena ini tidak tepat, ini akan menyebabkan masalah ketika Anda mencapai kembali ke posisi terakhir yang diketahui. Saat konflik terjadi, cukup ubah posisi gulir untuk memperbaikinya. Ini akan memindahkan bilah gulir sedikit tetapi seharusnya tidak terlalu mempengaruhi dia.

React Specifics

Anda ingin memberikan kunci ke semua elemen yang dirender agar dipertahankan di seluruh render. Ada dua strategi: (1) hanya memiliki n kunci (0, 1, 2, ... n) di mana n adalah jumlah maksimum elemen yang dapat Anda tampilkan dan gunakan posisinya modulo n. (2) memiliki kunci yang berbeda per elemen. Jika semua elemen memiliki struktur yang serupa, sebaiknya gunakan (1) untuk menggunakan kembali node DOM-nya. Jika tidak, gunakan (2).

Saya hanya akan memiliki dua bagian dari status React: indeks dari elemen pertama dan jumlah elemen yang ditampilkan. Posisi gulir saat ini dan ketinggian semua elemen akan langsung dilampirkan this. Saat menggunakan setStateAnda sebenarnya melakukan perenderan yang seharusnya hanya terjadi saat rentang berubah.

Berikut adalah contoh daftar tak terbatas menggunakan beberapa teknik yang saya jelaskan dalam jawaban ini. Ini akan menjadi beberapa pekerjaan tetapi React jelas merupakan cara yang baik untuk mengimplementasikan daftar tak terbatas :)

Vjeux
sumber
4
Ini adalah teknik yang luar biasa. Terima kasih! Saya membuatnya berfungsi pada salah satu komponen saya. Namun, saya memiliki komponen lain yang ingin saya terapkan ini, tetapi baris tidak memiliki ketinggian yang konsisten. Saya sedang meningkatkan contoh Anda untuk menghitung displayEnd / visibleEnd untuk memperhitungkan berbagai ketinggian ... kecuali Anda memiliki ide yang lebih baik?
manalang
Saya telah menerapkan ini dengan twist, dan mengalami masalah: Bagi saya, record yang saya render adalah DOM yang agak rumit, dan karena # dari mereka, tidak bijaksana untuk memuat semuanya ke dalam browser, jadi saya melakukan pengambilan asinkron dari waktu ke waktu. Untuk beberapa alasan, terkadang ketika saya menggulir dan posisi melompat sangat jauh (katakanlah saya keluar dari layar dan mundur), ListBody tidak merender ulang, meskipun status berubah. Ada ide mengapa ini mungkin terjadi? Contoh yang bagus sebaliknya!
SleepyProgrammer
1
JSFiddle Anda saat ini membuat kesalahan: Uncaught ReferenceError: menghasilkan tidak ditentukan
Meglio
3
Saya telah membuat biola yang diperbarui , saya pikir itu harus bekerja sama. Ada yang mau memverifikasi? @Meglio
aknuds1
1
@ThomasModeneis hai, dapatkah Anda mengklarifikasi perhitungan yang dilakukan pada baris 151 dan 152, displayStart dan displayEnd
shortCircuit
2

lihat di http://adazzle.github.io/react-data-grid/index.html# Ini terlihat seperti datagrid yang kuat dan berkinerja dengan fitur seperti Excel dan pemuatan lambat / rendering yang dioptimalkan (untuk jutaan baris) dengan fitur pengeditan yang kaya (berlisensi MIT). Belum mencoba dalam proyek kami tetapi akan segera melakukannya.

Sumber yang bagus untuk mencari hal-hal seperti ini juga http://react.rocks/ Dalam kasus ini, pencarian tag sangat membantu: http://react.rocks/tag/InfiniteScroll

Gregor
sumber
1

Saya menghadapi tantangan serupa untuk memodelkan pengguliran tak terbatas satu arah dengan ketinggian item yang heterogen dan karenanya membuat paket npm dari solusi saya:

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

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

Anda dapat memeriksa kode sumber untuk logikanya, tetapi pada dasarnya saya mengikuti resep @Vjeux yang diuraikan dalam jawaban di atas. Saya belum mencoba melompat ke item tertentu, tetapi saya berharap untuk segera menerapkannya.

Berikut inti dari tampilan kode saat ini:

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
sumber