スクロールアルゴリズム—データのフェッチと表示の改善


8

理論的な問題について少し述べたいと思います。

私が無限のスクロールを持っていて、ここで説明されているようなものを実装したとします:https : //medium.com/frontend-journeys/how-virtual-infinite-scrolling-works-239f7ee5aa58。特別なことは何もありません。それは、データのテーブル、たとえばNxNであると言えば十分です。ユーザーは、スプレッドシートのように、右下にスクロールでき、現在のビューとマイナスのデータのみを表示します。扱う。

ここで、次のような関数を使用して、そのビューでデータを「フェッチして表示」するのに約10ミリ秒かかるとしましょう。

get_data(start_col, end_col, start_row, end_row);

これは、スクロールバーのどこかをクリックするか、必要なデータをレンダリングするために「わずかにスクロール」するときに即座に読み込まれます。ただし、すべての「未完了のフェッチイベント」について、必要なビューデータのレンダリングに2倍の時間がかかると仮定します(メモリ、GC、その他いくつかの理由により)。したがって、ゆっくりと意図的に左から右にスクロールすると、データのロードをトリガーする100以上のスクロールイベントが生成される可能性があります-最初は著しく遅延はありません。フェッチは10ミリ秒未満で発生しますが、すぐに20ミリ秒、次に40ミリ秒かかり、必要なデータをロードするために1秒以上かかるまで、かなりの遅延が発生します。さらに、デバウンス/遅延のようなものは使用できません。

どのような考慮事項を考慮に入れる必要があり、これを実現するためのサンプルアルゴリズムはどのように見えますか?これは、10000 x 10000のスプレッドシート(​​Excelはすべてのデータを一度に読み込むことができます)を想定した、データに対するユーザー操作の例です-https://gyazo.com/0772f941f43f9d14f884b7afeac9f414


飛行中に複数のリクエストが発生することはありませんか?ユーザーがスクロールすると、保留中のリクエストがない場合にのみリクエストを送信します。保留中の要求に対する応答を受け取ったときに、最後の要求を送信してからスクロールが変わった場合は、新しい要求を送信します。
ybungalobill

なぜあなたは与えられた答えを受け入れなかったのかしら。その理由と、回答として期待していることを明確にしていただけますか?
トリンコット

@trincot-はい、それは同意された素晴らしい答えです。誰かが私のオリジナルの投稿を編集しました(編集を参照)、「これは理論的な質問なので、賞金を授与します...」
samuelbrody1249

1
それは本当に私の質問に答えません...
トリンコット

1
検討する価値のある別の戦略は、スクロールの方向に基づいてテーブルデータをバッファリングすることです。たとえば、ユーザーが下にスクロールしている場合、ビューにあるものをフェッチするだけでなく、ユーザーが下にスクロールし続けることを見込んで、たとえばさらに25〜50行下にフェッチします。さらに(そして私はYosefがこれをほのめかしていると思います)データビューがバッファリングされたデータを消費する前に、ユーザーがスクロールしている間、より多くのデータをバッファリングします(そのため、常に25-50行がバッファリングされます)。この追加データは、フェッチの往復にすでに含まれているオーバーヘッドにほとんど追加されません...
Jon Trent

回答:


3

スクロールイベントではリクエストを送信しないでください。このスクロールによってユーザーがスクロールの終わりに到達した場合のみ。

if(e.target.scrollHeight - e.target.offsetHeight === 0) {
    // the element reach the end of vertical scroll
}
if(e.target.scrollWidth - e.target.offsetWidth === 0) {
   // the element reach the end of horizontal scroll
}

また、新しいデータをフェッチするのに十分近いと定義される幅を指定することもできます(ei e.target.scrollHeight - e.target.offsetHeight <= 150


1

理論と実践:理論的には理論と実践の間に違いはありませんが、実際には違いがあります。

  • 理論:すべては明らかですが、何も機能しません。
  • 練習:すべてが機能しますが、明確なものはありません。
  • 時には理論と実践が出会います。何も機能せず、何も明らかではありません。

時々、最良のアプローチはプロトタイプであり、問​​題を興味深いものにするために、少し時間をかけて調理しましたが、プロトタイプとしては確かに多くのいぼがあります...

要するに、データフェッチのバックログを制限する最も簡単なソリューションは、フェッチを実行しているルーチン内に貧乏人のミューテックスを設定することです。(以下のコード例では、フェッチシミュレートされた関数であるsimulateFetchOfData。)mutexがあれば、そのようなことは関数スコープ外変数を設定することを含むfalseフェッチ、使用のために開放され、そして場合trueフェッチ現在進行中です。

つまり、ユーザーが水平または垂直スライダーを調整してデータのフェッチを開始すると、データをフェッチする関数は、最初にグローバル変数mutexが真であるかどうか(つまり、フェッチが既に実行されているかどうか)を確認し、真の場合は単に終了します。 。mutexがtrueでない場合は、trueに設定さmutexれ、引き続きフェッチが実行されます。そしてもちろん、フェッチ関数の最後でmutexfalseに設定されているため、次のユーザー入力イベントは最初にミューテックスチェックを通過し、別のフェッチを実行します...

プロトタイプに関するいくつかのメモ。

  • simulateFetchOfData関数内には、データの取得の遅延をシミュレートするPromiseとして構成されたsleep(100)があります。これには、コンソールへのロギングがいくつか含まれています。ミューテックスチェックを削除すると、スライダーを移動しているときにコンソールが開いた状態で、の多くのインスタンスsimulateFetchOfDataが開始され、スリープ(つまり、データのシミュレートされたフェッチ)を待機してサスペンス状態になり、解決されます。適所では、一度に1つのインスタンスのみが開始されます。
  • スリープ時間を調整して、より大きなネットワークまたはデータベースの待ち時間をシミュレートし、ユーザーエクスペリエンスの感触をつかむことができます。たとえば、私が使用しているネットワークでは、米国本土の通信で90ミリ秒の遅延が発生します。
  • もう1つの注目すべき点は、フェッチが終了したとき、およびmutexfalse にリセットした後、水平スクロール値と垂直スクロール値が揃っているかどうかを確認するチェックが実行されることです。そうでない場合、別のフェッチが開始されます。これにより、フェッチがビジーのために多くのスクロールイベントが発生しない可能性があるにもかかわらず、少なくとも1つの最終フェッチをトリガーすることによって最終スクロール値に対処できます。
  • シミュレートされたセルデータは、行と列の列番号の文字列値です。たとえば、「555-333」は行555、列333を示します。
  • 名前bufferが付けられたスパース配列は、「フェッチされた」データを保持するために使用されます。コンソールで調べると、多くの「空のx XXXX」エントリが表示されます。simulateFetchOfData関数は、データが既に保持されている場合、そのように設定されていないbuffer場合、いかなる「フェッチ」が行われます。

(プロトタイプを表示するには、コード全体を新しいテキストファイルにコピーして貼り付け、「。html」に名前を変更して、ブラウザーで開きます。 編集: ChromeとEdgeでテストされています。)

<html><head>

<script>

function initialize() {

  window.rowCount = 10000;
  window.colCount = 5000;

  window.buffer = [];

  window.rowHeight = Array( rowCount ).fill( 25 );  // 20px high rows 
  window.colWidth = Array( colCount ).fill( 70 );  // 70px wide columns 

  var cellAreaCells = { row: 0, col: 0, height: 0, width: 0 };

  window.contentGridCss = [ ...document.styleSheets[ 0 ].rules ].find( rule => rule.selectorText === '.content-grid' );

  window.cellArea = document.getElementById( 'cells' );

  // Horizontal slider will indicate the left most column.
  window.hslider = document.getElementById( 'hslider' );
  hslider.min = 0;
  hslider.max = colCount;
  hslider.oninput = ( event ) => {
    updateCells();
  }

  // Vertical slider will indicate the top most row.
  window.vslider = document.getElementById( 'vslider' );
  vslider.max = 0;
  vslider.min = -rowCount;
  vslider.oninput = ( event ) => {
    updateCells();
  }

  function updateCells() {
    // Force a recalc of the cell height and width...
    simulateFetchOfData( cellArea, cellAreaCells, { row: -parseInt( vslider.value ), col: parseInt( hslider.value ) } );
  }

  window.mutex = false;
  window.lastSkippedRange = null;

  window.addEventListener( 'resize', () => {
    //cellAreaCells.height = 0;
    //cellAreaCells.width = 0;
    cellArea.innerHTML = '';
    contentGridCss.style[ "grid-template-rows" ] = "0px";
    contentGridCss.style[ "grid-template-columns" ] = "0px";

    window.initCellAreaSize = { height: document.getElementById( 'cellContainer' ).clientHeight, width: document.getElementById( 'cellContainer' ).clientWidth };
    updateCells();
  } );
  window.dispatchEvent( new Event( 'resize' ) );

}

function sleep( ms ) {
  return new Promise(resolve => setTimeout( resolve, ms ));
}

async function simulateFetchOfData( cellArea, curRange, newRange ) {

  //
  // Global var "mutex" is true if this routine is underway.
  // If so, subsequent calls from the sliders will be ignored
  // until the current process is complete.  Also, if the process
  // is underway, capture the last skipped call so that when the
  // current finishes, we can ensure that the cells align with the
  // settled scroll values.
  //
  if ( window.mutex ) {
    lastSkippedRange = newRange;
    return;
  }
  window.mutex = true;
  //
  // The cellArea width and height in pixels will tell us how much
  // room we have to fill.
  //
  // row and col is target top/left cell in the cellArea...
  //

  newRange.height = 0;
  let rowPixelTotal = 0;
  while ( newRange.row + newRange.height < rowCount && rowPixelTotal < initCellAreaSize.height ) {
    rowPixelTotal += rowHeight[ newRange.row + newRange.height ];
    newRange.height++;
  }

  newRange.width = 0;
  let colPixelTotal = 0;
  while ( newRange.col + newRange.width < colCount && colPixelTotal < initCellAreaSize.width ) {
    colPixelTotal += colWidth[ newRange.col + newRange.width ];
    newRange.width++;
  }

  //
  // Now the range to acquire is newRange. First, check if this data 
  // is already available, and if not, fetch the data.
  //

  function isFilled( buffer, range ) {
    for ( let r = range.row; r < range.row + range.height; r++ ) {
      for ( let c = range.col; c < range.col + range.width; c++ ) {
        if ( buffer[ r ] == null || buffer[ r ][ c ] == null) {
          return false;
        }
      }
    }
    return true;
  }

  if ( !isFilled( buffer, newRange ) ) {
    // fetch data!
    for ( let r = newRange.row; r < newRange.row + newRange.height; r++ ) {  
      buffer[ r ] = [];
      for ( let c = newRange.col; c < newRange.col + newRange.width; c++ ) {
        buffer[ r ][ c ] = `${r}-${c} data`;
      }
    }
    console.log( 'Before sleep' );
    await sleep(100);
    console.log( 'After sleep' );
  }

  //
  // Now that we have the data, let's load it into the cellArea.
  //

  gridRowSpec = '';
  for ( let r = newRange.row; r < newRange.row + newRange.height; r++ ) {
    gridRowSpec += rowHeight[ r ] + 'px ';
  }

  gridColumnSpec = '';
  for ( let c = newRange.col; c < newRange.col + newRange.width; c++ ) {
    gridColumnSpec += colWidth[ c ] + 'px ';
  }

  contentGridCss.style[ "grid-template-rows" ] = gridRowSpec;
  contentGridCss.style[ "grid-template-columns" ] = gridColumnSpec;

  cellArea.innerHTML = '';

  for ( let r = newRange.row; r < newRange.row + newRange.height; r++ ) {  
    for ( let c = newRange.col; c < newRange.col + newRange.width; c++ ) {
      let div = document.createElement( 'DIV' );
      div.innerText = buffer[ r ][ c ];
      cellArea.appendChild( div );
    }
  }

  //
  // Let's update the reference to the current range viewed and clear the mutex.
  //
  curRange = newRange;

  window.mutex = false;

  //
  // One final step.  Check to see if the last skipped call to perform an update
  // matches with the current scroll bars.  If not, let's align the cells with the
  // scroll values.
  //
  if ( lastSkippedRange ) {
    if ( !( lastSkippedRange.row === newRange.row && lastSkippedRange.col === newRange.col ) ) {
      lastSkippedRange = null;
      hslider.dispatchEvent( new Event( 'input' ) );
    } else {
      lastSkippedRange = null;
    }
  }
}

</script>

<style>

/*

".range-slider" adapted from... https://codepen.io/ATC-test/pen/myPNqW

See https://www.w3schools.com/howto/howto_js_rangeslider.asp for alternatives.

*/

.range-slider-horizontal {
  width: 100%;
  height: 20px;
}

.range-slider-vertical {
  width: 20px;
  height: 100%;
  writing-mode: bt-lr; /* IE */
  -webkit-appearance: slider-vertical;
}

/* grid container... see https://www.w3schools.com/css/css_grid.asp */

.grid-container {

  display: grid;
  width: 95%;
  height: 95%;

  padding: 0px;
  grid-gap: 2px;
  grid-template-areas:
    topLeft column  topRight
    row     cells   vslider
    botLeft hslider botRight;
  grid-template-columns: 50px 95% 27px;
  grid-template-rows: 20px 95% 27px;
}

.grid-container > div {
  border: 1px solid black;
}

.grid-topLeft {
  grid-area: topLeft;
}

.grid-column {
  grid-area: column;
}

.grid-topRight {
  grid-area: topRight;
}

.grid-row {
  grid-area: row;
}

.grid-cells {
  grid-area: cells;
}

.grid-vslider {
  grid-area: vslider;
}

.grid-botLeft {
  grid-area: botLeft;
}

.grid-hslider {
  grid-area: hslider;
}

.grid-botRight {
  grid-area: botRight;
}

/* Adapted from... https://medium.com/evodeck/responsive-data-tables-with-css-grid-3c58ecf04723 */

.content-grid {
  display: grid;
  overflow: hidden;
  grid-template-rows: 0px;  /* Set later by simulateFetchOfData */
  grid-template-columns: 0px;  /* Set later by simulateFetchOfData */
  border-top: 1px solid black;
  border-right: 1px solid black;
}

.content-grid > div {
  overflow: hidden;
  white-space: nowrap;
  border-left: 1px solid black;
  border-bottom: 1px solid black;  
}
</style>


</head><body onload='initialize()'>

<div class='grid-container'>
  <div class='topLeft'> TL </div>
  <div class='column' id='columns'> column </div>
  <div class='topRight'> TR </div>
  <div class='row' id = 'rows'> row </div>
  <div class='cells' id='cellContainer'>
    <div class='content-grid' id='cells'>
      Cells...
    </div>
  </div>
  <div class='vslider'> <input id="vslider" type="range" class="range-slider-vertical" step="1" value="0" min="0" max="0"> </div>
  <div class='botLeft'> BL </div>
  <div class='hslider'> <input id="hslider" type="range" class="range-slider-horizontal" step="1" value="0" min="0" max="0"> </div>
  <div class='botRight'> BR </div>
</div>

</body></html>

繰り返しますが、これは不要なデータ呼び出しのバックログを制限する手段を証明するためのプロトタイプです。これを運用目的でリファクタリングする場合、次のような多くの領域で対処する必要があります。1)グローバル変数スペースの使用を減らす。2)行と列のラベルを追加します。3)個々の行または列をスクロールするためのボタンをスライダーに追加します。4)データ計算が必要な場合、関連データをバッファリングする可能性があります。5)など...


この素晴らしい答えに感謝し、この答えに時間を割いてください。
samuelbrody1249

0

できることがいくつかあります。データリクエストプロシージャとユーザースクロールイベントの間に配置された2レベルの中間層として表示されます。

1.スクロールイベント処理の遅延

そうです、デバウンスはスクロール関連の問題では私たちの友人ではありません。しかし、発砲の数を減らす正しい方法があります。

固定間隔ごとに最大1回呼び出される、スクロールバージョンのスクロールイベントハンドラーを使用します。lodashスロットルを使用するか、独自のバージョン[ 1 ]、[ 2 ]、[ 3 ]を実装できます。インターバル値として40〜100 msを設定します。またtrailing、タイマー間隔に関係なく、最後のスクロールイベントが処理されるようにオプションを設定する必要があります。

2.スマートデータフロー

スクロールイベントハンドラーが呼び出されると、データ要求プロセスが開始されます。おっしゃったように、スクロールイベントが発生するたびに(スロットル処理が完了した場合でも)実行すると、タイムラグが発生する可能性があります。いくつかの一般的な戦略がある可能性があります。1)別の保留中の要求がある場合は、データを要求しないでください。2)間隔ごとに1回だけデータを要求します。3)以前の保留中のリクエストをキャンセルします。

1番目と2番目のアプローチは、データフローレベルでのデバウンスとスロットルにすぎません。デバウンスは、リクエストを開始する前の1つの条件+最後に1つの追加リクエストで最小限の労力で実装できます。しかし、UXの観点からはスロットルがより適切であると私は信じています。ここでは、いくつかのロジックを提供する必要があり、trailingオプションはゲーム内にあるはずなので、忘れないでください。

最後のアプローチ(リクエストのキャンセル)もUXフレンドリーですが、スロットルよりも慎重ではありません。とにかくリクエストを開始しますが、このリクエストの後に別のリクエストが開始された場合は、その結果を破棄します。を使用している場合は、リクエストの中止を試みることもできますfetch

私の意見では、(2)と(3)の戦略を組み合わせることが最善の選択肢となるため、前のリクエストの開始から一定の時間間隔が経過した場合にのみデータをリクエストし、その後別のリクエストが開始された場合はリクエストをキャンセルします。


0

この質問に答える特定のアルゴリズムはありませんが、遅延が蓄積しないようにするには、次の2つを確認する必要があります。

1.メモリリークなし

アプリで何もオブジェクト、クラス、配列などの新しいインスタンスを作成していないことを必ず確認してください。メモリは、10秒間スクロールした後も60秒間と同じである必要があります。データ構造を事前に割り当てることができる場合あなたは(配列を含む)必要があり、それらを再利用します:

2.データ構造の継続的な再利用

これは、無限スクロールページでは一般的です。画面上に一度に最大30の画像を表示する無限スクロール画像ギャラリーでは、実際に<img>作成される要素は30〜40しかない場合があります。これらは、ユーザーがスクロールするときに使用および再利用されるため、新しいHTML要素を作成(または破棄、つまりガベージコレクション)する必要はありません。代わりに、これらの画像は新しいソースURLと新しい位置を取得し、ユーザーはスクロールを続けることができますが、(それらに気付かれない限り)常に同じDOM要素が何度も表示されます。

キャンバスを使用している場合は、DOM要素を使用してこのデータを表示することはありませんが、理論は同じであり、データ構造は独自のものです。

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