ReactJS : 양방향 무한 스크롤링 모델링


114

우리의 응용 프로그램은 무한 스크롤을 사용하여 이기종 항목의 큰 목록을 탐색합니다. 몇 가지 주름이 있습니다.

  • 일반적으로 사용자는 10,000 개의 항목 목록을 가지고 있고 3k 이상을 스크롤해야합니다.
  • 이들은 풍부한 항목이므로 브라우저 성능이 허용되지 않을 때까지 DOM에 수백 개만있을 수 있습니다.
  • 항목의 높이는 다양합니다.
  • 항목에는 이미지가 포함될 수 있으며 사용자가 특정 날짜로 이동할 수 있습니다. 이는 사용자가 목록에서 뷰포트 위에 이미지를로드해야하는 지점으로 이동할 수 있기 때문에 까다 롭습니다. 그러면로드 될 때 콘텐츠가 아래로 내려갑니다. 이를 처리하지 못하면 사용자가 날짜로 이동 한 다음 이전 날짜로 이동할 수 있습니다.

알려진 불완전한 솔루션 :

  • ( react-infinite-scroll )-이것은 단순한 "하단에 도달했을 때 더 많이로드"하는 구성 요소입니다. DOM을 컬링하지 않으므로 수천 개의 항목에서 죽습니다.

  • ( React로 스크롤 위치 )-상단에 삽입 하거나 하단에 삽입 할 때 스크롤 위치를 저장하고 복원하는 방법을 보여줍니다 .

나는 완전한 솔루션을위한 코드를 찾고있는 것이 아니다 (물론 훌륭 할 것이다.) 대신, 나는이 상황을 모델링하기위한 "React way"를 찾고있다. 스크롤 위치 상태입니까? 목록에서 내 위치를 유지하려면 어떤 상태를 추적해야합니까? 렌더링 된 항목의 맨 아래 또는 맨 위로 스크롤 할 때 새 렌더링을 트리거하려면 어떤 상태를 유지해야합니까?

답변:


116

이것은 무한 테이블과 무한 스크롤 시나리오의 혼합입니다. 내가 찾은 최고의 추상화는 다음과 같습니다.

개요

모든 자식 <List>의 배열을 받는 구성 요소를 만듭니다 . 렌더링하지 않기 때문에 할당하고 버리는 것이 정말 저렴합니다. 10k 할당이 너무 크면 범위를 취하고 요소를 반환하는 함수를 대신 전달할 수 있습니다.

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

귀하의 List구성 요소는 스크롤 위치가 무엇을 추적 만 뷰에있는 아이들을 렌더링합니다. 렌더링되지 않은 이전 항목을 가짜로 만들기 위해 처음에 큰 빈 div를 추가합니다.

이제 흥미로운 부분은 Element구성 요소가 렌더링되면 높이를 측정하여 List. 이를 통해 스페이서의 높이를 계산하고 뷰에 표시해야하는 요소 수를 알 수 있습니다.

영상

이미지가로드 될 때 모든 것이 "점프"아래로 내려 간다는 것입니다. 이에 대한 해결책은 img 태그에 이미지 크기를 설정하는 것입니다 : <img src="..." width="100" height="58" />. 이렇게하면 브라우저가 표시 될 크기를 알기 전에 다운로드를 기다릴 필요가 없습니다. 여기에는 약간의 인프라가 필요하지만 그만한 가치가 있습니다.

크기를 미리 알 수없는 경우 onload이미지 에 리스너를 추가 하고로드 될 때 표시된 크기를 측정하고 저장된 행 높이를 업데이트하고 스크롤 위치를 보정합니다.

임의의 요소에서 점프

목록에서 임의의 요소로 이동해야하는 경우 스크롤 위치에 약간의 속임수가 필요한 경우 사이에있는 요소의 크기를 알 수 없습니다. 내가 제안하는 것은 이미 계산 한 요소 높이를 평균화하고 마지막으로 알려진 높이 + (요소 수 * 평균)의 스크롤 위치로 점프하는 것입니다.

이것은 정확하지 않기 때문에 마지막으로 알려진 좋은 위치로 돌아갈 때 문제가 발생합니다. 충돌이 발생하면 단순히 스크롤 위치를 변경하여 해결하십시오. 이것은 스크롤바를 약간 움직일 것이지만 그 / 그녀에게 너무 많은 영향을주지 않아야합니다.

반응 특성

렌더링 된 모든 요소에 를 제공 하여 렌더링간에 유지되도록합니다. 두 가지 전략이 있습니다. (1) n 개의 키 (0, 1, 2, ... n) 만 있습니다. 여기서 n은 표시 할 수있는 요소의 최대 수이며 n 모듈로 위치를 사용할 수 있습니다. (2) 요소마다 다른 키가 있습니다. 모든 요소가 유사한 구조를 공유하는 경우 (1)을 사용하여 DOM 노드를 재사용하는 것이 좋습니다. 그렇지 않으면 (2)를 사용하십시오.

React 상태는 첫 번째 요소의 인덱스와 표시되는 요소의 수 두 가지만 있습니다. 현재 스크롤 위치와 모든 요소의 높이가에 직접 연결됩니다 this. 사용할 때 setState실제로 범위가 변경 될 때만 발생해야하는 다시 렌더링을 수행합니다.

다음은 이 답변에서 설명하는 기술 중 일부를 사용하는 무한 목록 의 입니다. 약간의 작업이 될 것이지만 React는 무한 목록을 구현하는 좋은 방법입니다. :)


4
이것은 굉장한 기술입니다. 감사! 내 구성 요소 중 하나에서 작동합니다. 그러나 이것을 적용하고 싶은 다른 구성 요소가 있지만 행의 높이가 일정하지 않습니다. 더 나은 아이디어가 없다면 다양한 높이를 설명하기 위해 displayEnd / visibleEnd를 계산하기 위해 예제를 보강하는 중입니다.
manalang

나는 이것을 트위스트로 구현했고 문제가 발생했습니다. 저에게 렌더링하는 레코드는 다소 복잡한 DOM이며, # 때문에 모두 브라우저에로드하는 것이 현명하지 않습니다. 때때로 비동기 가져 오기를 수행합니다. 어떤 이유로 스크롤하고 위치가 매우 멀리 점프 할 때 (예 : 화면에서 나갔다가 되돌아가는 경우) ListBody는 상태가 변경 되어도 다시 렌더링되지 않습니다. 이것이 왜 그런지 어떤 아이디어? 그렇지 않으면 좋은 예!
SleepyProgrammer 2015-06-10

1
귀하의 JSFiddle 현재 오류가 발생합니다 : catch되지 않은 ReferenceError가 : 정의되지 않은 생성
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.