ReactJS:双方向無限スクロールのモデリング


114

このアプリケーションでは、無限スクロールを使用して、異種のアイテムの大きなリストをナビゲートします。いくつかのしわがあります。

  • ユーザーが10,000アイテムのリストを持ち、3k以上をスクロールする必要があるのはよくあることです。
  • これらは豊富なアイテムであるため、ブラウザのパフォーマンスが受け入れられなくなるまでに、DOMに数百しか存在できません。
  • アイテムの高さはさまざまです。
  • アイテムには画像が含まれている場合があり、ユーザーは特定の日付にジャンプできます。これは、ビューポートの上に画像をロードする必要があるリストのポイントにユーザーがジャンプできるため、ロードするとコンテンツが押し下げられるため、これはトリッキーです。これを処理できない場合、ユーザーは日付にジャンプしても、以前の日付にシフトする可能性があります。

既知の不完全なソリューション:

  • react-infinite-scroll)-これは、単純な「下にヒットしたときにさらにロードする」コンポーネントです。DOMを排除することはないため、何千ものアイテムで死んでしまいます。

  • Reactを使用したスクロール位置)- 両方ではなく、上部に挿入する下部に挿入するときに、スクロール位置を保存および復元する方法を示します。

完全なソリューションのコードを探しているのではありません(それはすばらしいでしょう)。代わりに、この状況をモデル化するための「Reactの方法」を探しています。スクロール位置の状態ですか?リストで自分の位置を保持するには、どの状態を追跡する必要がありますか?レンダリングされたものの下部または上部の近くでスクロールしたときに新しいレンダリングをトリガーするには、どの状態を維持する必要がありますか?

回答:


116

これは、無限のテーブルと無限のスクロールシナリオの組み合わせです。これについて私が見つけた最高の抽象化は次のとおりです:

概観

作る<List>の配列とるコンポーネントのすべての子供たちを。それらをレンダリングしないので、それらを割り当てて破棄するだけで非常に安価です。10kの割り当てが大きすぎる場合は、代わりに、範囲を取得して要素を返す関数を渡すことができます。

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

あなたのListコンポーネントは、スクロール位置が何であるかを追跡し、唯一のビューを表示している子どもたちをレンダリングしています。レンダリングされていない前のアイテムを偽造するために、最初に大きな空のdivを追加します。

ここで興味深いのは、Elementコンポーネントがレンダリングされたら、その高さを測定してに保存することListです。これにより、スペーサーの高さを計算し、ビューに表示する必要がある要素の数を知ることができます。

画像

あなたは、画像がロードされているとき、それらがすべてを「ジャンプ」させると言っています。これの解決策は、imgタグで画像のサイズを設定することです<img src="..." width="100" height="58" />。これにより、ブラウザは、表示されるサイズがわかる前にダウンロードを待つ必要がなくなります。これにはいくつかのインフラストラクチャが必要ですが、それだけの価値があります。

事前にサイズがわからない場合はonload、画像にリスナーを追加し、画像が読み込まれたときに表示される寸法を測定して、格納されている行の高さを更新し、スクロール位置を補正します。

ランダムな要素でジャンプする

リスト内のランダムな要素にジャンプする必要がある場合は、要素間のサイズがわからないため、スクロール位置を調整する必要があります。私がお勧めすることは、すでに計算した要素の高さを平均し、最後の既知の高さ+(要素の数*平均)のスクロール位置にジャンプすることです。

これは正確ではないため、最後の既知の適切な位置に戻ったときに問題が発生します。競合が発生した場合は、スクロール位置を変更して修正します。これにより、スクロールバーが少し移動しますが、スクロールバーに大きな影響はありません。

反応の詳細

すべてのレンダリングされた要素にキーを提供して、それらがレンダリング全体で維持されるようにします。2つの戦略があります:(1)はn個のキーのみ(0、1、2、... n)を持ちます。ここで、nは表示できる要素の最大数であり、nを法としてそれらの位置を使用できます。(2)要素ごとに異なるキーを持っている。すべての要素が同様の構造を共有している場合は、(1)を使用してDOMノードを再利用することをお勧めします。そうでない場合は、(2)を使用します。

最初の要素のインデックスと表示されている要素の数の2つだけのReact状態があります。現在のスクロール位置とすべての要素の高さがに直接アタッチされthisます。使用setStateする場合、実際にはレンダーを行っていますが、これは範囲が変更されたときにのみ発生します。

これは、私がこの回答で説明するいくつかの手法を使用した無限リストのです。これは多少の作業になりますが、Reactは間違いなく無限リストを実装するための優れた方法です:)


4
これは素晴らしいテクニックです。ありがとう!私はそれを私のコンポーネントの1つで動作させました。しかし、これを適用したい別のコンポーネントがありますが、行の高さが一定ではありません。さまざまな高さを考慮してdisplayEnd / visibleEndを計算するようにサンプルを拡張しています...より良いアイデアがない限り、
manalang 2014年

私はこれをひねりを加えて実装し、問題に遭遇しました:私にとって、レンダリングしているレコードはやや複雑なDOMであり、それらの#のため、それらすべてをブラウザーにロードすることは賢明ではないので、私は時々非同期フェッチを行います。何らかの理由で、スクロールして位置が非常に遠くにジャンプする(たとえば、画面から出て戻る)と、状態が変化しても、ListBodyが再レンダリングされません。これがなぜなのか、何かアイデアはありますか?そうでなければ素晴らしい例!
SleepyProgrammer 2015年

1
JSFiddleは現在エラーをスローします:Uncaught ReferenceError:generate is not defined
Meglio

3
私は更新されたフィドルを作りまし、私はそれが同じように動くべきだと思います。誰でも確認したいですか?@Meglio
aknuds1

1
@ThomasModeneisこんにちは、行151と152で行われた計算を明確にできますか、displayStartおよびdisplayEnd
shortCircuit

2

http://adazzle.github.io/react-data-grid/index.html#を見てください。 これは、Excelのような機能と遅延ロード/最適化されたレンダリング(数百万行)を備えた強力で高性能のデータグリッドのように見えます。豊富な編集機能(MITライセンス済み)。私たちのプロジェクトではまだ試していませんが、すぐにそうなるでしょう。

これらのようなものを検索するための優れたリソースもhttp://react.rocks/です 。この場合、タグ検索が役立ちます:http : //react.rocks/tag/InfiniteScroll


1

アイテムの高さが異なる単一方向の無限スクロールをモデリングするための同様の課題に直面していたため、ソリューションからnpmパッケージを作成しました。

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

とデモ:http : //tnrich.github.io/react-variable-height-infinite-scroller/

ロジックのソースコードをチェックアウトできますが、私は基本的に上記の回答で概説されているレシピ@Vjeuxに従いました。特定の項目へのジャンプにはまだ取り組んでいませんが、すぐに実装したいと思っています。

以下は、現在のコードの要点です。

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;
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.