10

我想提出一个理论上的问题。

假设我有一个无限滚动,实现了如下所述的内容:https ://medium.com/frontend-journeys/how-virtual-infinite-scrolling-works-239f7ee5aa58 。它没有什么花哨的,只要说它是一个数据表就足够了,比如 NxN,用户可以像电子表格一样向下和向右滚动,它只会显示当前视图中的数据加上减去一个处理。

现在,我们还假设在该视图中“获取并显示”数据大约需要 10 毫秒,其功能如下:

get_data(start_col, end_col, start_row, end_row);

当单击滚动条中的某个位置或进行“轻微滚动”以呈现必要的数据时,它会立即加载。但是,我们还假设对于每个“未完成的获取事件”,渲染必要的视图数据需要双倍的时间(由于内存、gc 和其他一些事情)。因此,如果我以缓慢的故意方式从左到右滚动,我可能会生成 100 多个滚动事件,这些事件会触发数据的加载——起初明显延迟为零。提取发生在 10 毫秒以下,但很快它开始需要 20 毫秒,然后是 40 毫秒,现在我们有一个明显的延迟,直到加载必要数据的时间超过一秒。此外,我们不能使用去抖动/延迟之类的东西,

我需要考虑哪些因素以及实现此目的的示例算法是什么样的?这是我希望对数据进行的用户交互的示例,假设有一个 10000 x 10000 的电子表格(尽管 Excel 可以一次加载所有数据)- https://gyazo.com/0772f941f43f9d14f884b7afeac9f414

4

4 回答 4

5

我认为您不应该在任何滚动事件中发送请求。仅当用户通过此滚动到达滚动的末尾时。

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

于 2020-02-16T15:01:05.713 回答
1

理论与实践:理论上,理论与实践没有区别,但在实践中是有区别的。

  • 理论:一切都很清楚,但没有任何作用;
  • 练习:一切正常,但没有什么是清楚的;
  • 有时理论与实践相遇:没有什么是有效的,也没有什么是清楚的。

有时最好的方法是原型,发现有趣的问题,我花了一点时间来制作一个,尽管作为原型,它确实有很多缺点......

简而言之,限制数据提取积压的最简单解决方案似乎只是在执行提取的例程中设置一个穷人的互斥锁。(在下面的代码示例中,模拟的 fetch 函数是simulateFetchOfData。)互斥锁涉及在函数范围之外设置一个变量,这样如果false,则 fetch 可以使用,并且true当前正在进行 fetch 。

也就是说,当用户调整水平或垂直滑块以开始获取数据时,获取数据的函数首先检查全局变量mutex是否为真(即,获取已经在进行中),如果是,则简单地退出. 如果mutex不为真,则设置mutex为真,然后继续执行提取。当然,在 fetch 函数结束时,mutex设置为 false,这样下一个用户输入事件将通过前面的互斥体检查,并执行另一个 fetch...

关于原型的几点说明。

  • 在该simulateFetchOfData函数中,将 sleep(100) 配置为 Promise,用于模拟检索数据的延迟。这夹在控制台上的一些日志记录中。如果您删除互斥检查,您将在控制台打开的情况下看到,在移动滑块时,许多实例simulateFetchOfData被启动并进入悬念等待睡眠(即模拟获取数据)以解决,而在互斥检查时在适当的位置,任何时候只启动一个实例。
  • 可以调整睡眠时间以模拟更大的网络或数据库延迟,以便您可以感受一下用户体验。例如,我正在使用的网络在美国大陆的通信延迟为 90 毫秒。
  • 另一个值得注意的是,当完成一次获取并重置mutex为 false 后,会执行检查以确定水平和垂直滚动值是否对齐。如果不是,则启动另一个提取。这确保了尽管许多滚动事件可能由于提取繁忙而未触发,但至少通过触发一次最终提取来处理最终滚动值。
  • 模拟的单元格数据只是行-破折号-列数的字符串值。例如,“555-333”表示第 555 行第 333 列。
  • 一个名为的稀疏数组buffer用于保存“获取的”数据。在控制台中检查它会发现许多“empty 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) 等等...

于 2020-02-22T03:34:18.943 回答
0

有些事情是可以做的。我将其视为放置在数据请求过程和用户​​滚动事件之间的两级中间层。

1.延迟滚动事件处理

你是对的,debounce 不是我们在滚动相关问题上的朋友。但是有正确的方法可以减少射击次数。

使用限制版本的滚动事件处理程序,每个固定间隔最多调用一次。您可以使用 lodash 节流阀或实现自己的版本 [ 1 ]、[ 2 ]、[ 3 ]。将 40 - 100 ms 设置为间隔值。您还需要设置trailing选项,以便处理最后一个滚动事件,而不管计时器间隔如何。

2.智能数据流

当调用滚动事件处理程序时,应该启动数据请求过程。正如您所提到的,每次发生滚动事件时都这样做(即使我们完成了节流)可能会导致时间滞后。可能有一些常见的策略:1)如果有另一个待处理的请求,则不请求数据;2)每个间隔请求数据不超过一次;3) 取消先前的待处理请求。

第一种和第二种方法只不过是数据流级别的去抖动和节流。在发起请求之前只需要一个条件+最后一个额外的请求,就可以以最小的努力实现去抖动。但我认为从用户体验的角度来看,节流阀更合适。在这里,您需要提供一些逻辑,并且不要忘记trailing选项,因为它应该在游戏中。

最后一种方法(请求取消)也对 UX 友好,但不如限制方法那么小心。无论如何,您都会启动请求,但如果在此请求之后启动了另一个请求,则丢弃其结果。如果您正在使用fetch.

在我看来,最好的选择是结合 (2) 和 (3) 策略,因此您仅在自上一个请求发起后经过某个固定时间间隔后才请求数据,并且如果在之后发起另一个请求,则您取消该请求.

于 2020-02-13T04:31:17.400 回答
0

There's no specific algorithm that answers this question, but in order to get no buildup of delay you need to ensure two things:

1. No memory leaks

Be absolutely sure that nothing in your app is creating new instances of objects, classes, arrays, etc. The memory should be the same after scrolling around for 10 seconds as it is for 60 seconds, etc. You can pre-allocate data structures if you need to (including arrays), and then re-use them:

2. Constant re-use of data structures

这在无限滚动页面中很常见。在一次在屏幕上最多显示 30 个图像的无限滚动图像库中,实际上可能只有 30-40 个<img>元素被创建。然后在用户滚动时使用和重新使用这些元素,因此不需要创建(或销毁,因此被垃圾收集)新的 HTML 元素。相反,这些图像获得了新的源 URL 和新位置,用户可以继续滚动,但是(他们不知道)他们总是一遍又一遍地看到相同的 DOM 元素。

如果您使用画布,则不会使用 DOM 元素来显示此数据,但原理是一样的,只是数据结构是您自己的。

于 2020-02-20T21:53:39.807 回答