ReactJS: Lập mô hình cuộn vô hạn hai hướng


114

Ứng dụng của chúng tôi sử dụng tính năng cuộn vô hạn để điều hướng danh sách lớn các mục không đồng nhất. Có một vài nếp nhăn:

  • Người dùng của chúng tôi thường có danh sách 10.000 mục và cần cuộn qua 3k +.
  • Đây là những vật phẩm phong phú, vì vậy chúng tôi chỉ có thể có vài trăm trong DOM trước khi hiệu suất trình duyệt trở nên không thể chấp nhận được.
  • Các mặt hàng có độ cao khác nhau.
  • Các mục có thể chứa hình ảnh và chúng tôi cho phép người dùng chuyển đến một ngày cụ thể. Điều này khá phức tạp vì người dùng có thể nhảy đến một điểm trong danh sách mà chúng ta cần tải hình ảnh phía trên khung nhìn, điều này sẽ đẩy nội dung xuống khi họ tải. Không xử lý được điều đó có nghĩa là người dùng có thể chuyển sang một ngày, nhưng sau đó được chuyển sang một ngày sớm hơn.

Các giải pháp chưa đầy đủ, đã biết:

  • ( react -finity-scroll ) - Đây chỉ là một thành phần đơn giản "tải thêm khi chúng ta chạm đáy". Nó không tiêu hủy bất kỳ DOM nào, vì vậy nó sẽ chết trên hàng nghìn mục.

  • ( Vị trí cuộn với React ) - Hiển thị cách lưu trữ và khôi phục vị trí cuộn khi chèn ở trên cùng hoặc khi chèn ở dưới cùng, nhưng không phải cả hai cùng nhau.

Tôi không tìm kiếm mã cho một giải pháp hoàn chỉnh (mặc dù điều đó sẽ rất tuyệt.) Thay vào đó, tôi đang tìm kiếm "Cách phản ứng" để mô hình hóa tình huống này. Vị trí cuộn có trạng thái hay không? Tôi nên theo dõi trạng thái nào để giữ được vị trí của mình trong danh sách? Tôi cần giữ trạng thái nào để kích hoạt kết xuất mới khi cuộn gần cuối hoặc trên cùng của những gì được hiển thị?

Câu trả lời:


116

Đây là sự kết hợp giữa một bảng vô hạn và một kịch bản cuộn vô hạn. Sự trừu tượng tốt nhất mà tôi tìm thấy cho điều này là:

Tổng quat

Tạo một <List>thành phần có một mảng tất cả các con. Vì chúng tôi không hiển thị chúng nên việc phân bổ chúng và loại bỏ chúng thực sự rất rẻ. Nếu 10k phân bổ là quá lớn, thay vào đó, bạn có thể chuyển một hàm có phạm vi và trả về các phần tử.

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

ListThành phần của bạn đang theo dõi vị trí cuộn là gì và chỉ hiển thị các phần con trong chế độ xem. Nó thêm một div trống lớn ở đầu để giả mạo các mục trước đó không được hiển thị.

Bây giờ, phần thú vị là khi một Elementthành phần được hiển thị, bạn đo chiều cao của nó và lưu trữ nó trong của bạn List. Điều này cho phép bạn tính toán chiều cao của khoảng đệm và biết có bao nhiêu phần tử sẽ được hiển thị trong chế độ xem.

Hình ảnh

Bạn đang nói rằng khi hình ảnh đang tải, chúng làm cho mọi thứ "nhảy" xuống. Giải pháp cho việc này là để thiết lập kích thước hình ảnh trong thẻ img của bạn: <img src="..." width="100" height="58" />. Bằng cách này, trình duyệt không phải đợi tải xuống trước khi biết kích thước nó sẽ được hiển thị. Điều này đòi hỏi một số cơ sở hạ tầng nhưng nó thực sự đáng giá.

Nếu bạn không thể biết trước kích thước, hãy thêm onloadngười nghe vào hình ảnh của bạn và khi nó được tải, sau đó đo kích thước hiển thị và cập nhật chiều cao hàng được lưu trữ và bù lại vị trí cuộn.

Nhảy ở một phần tử ngẫu nhiên

Nếu bạn cần chuyển đến một phần tử ngẫu nhiên trong danh sách, điều đó sẽ đòi hỏi một số thủ thuật với vị trí cuộn vì bạn không biết kích thước của các phần tử ở giữa. Những gì tôi đề nghị bạn làm là tính trung bình chiều cao của phần tử mà bạn đã tính toán và chuyển đến vị trí cuộn của chiều cao đã biết gần đây nhất + (số phần tử * trung bình).

Vì điều này không chính xác nên sẽ gây ra vấn đề khi bạn quay trở lại vị trí tốt đã biết cuối cùng. Khi xung đột xảy ra, bạn chỉ cần thay đổi vị trí cuộn để khắc phục. Thao tác này sẽ di chuyển thanh cuộn một chút nhưng không ảnh hưởng quá nhiều đến anh / cô ấy.

Thông tin cụ thể về React

Bạn muốn cung cấp khóa cho tất cả các phần tử được hiển thị để chúng được duy trì qua các lần hiển thị. Có hai chiến lược: (1) chỉ có n khóa (0, 1, 2, ... n) trong đó n là số phần tử tối đa bạn có thể hiển thị và sử dụng modulo n vị trí của chúng. (2) có một khóa khác nhau cho mỗi phần tử. Nếu tất cả các phần tử chia sẻ một cấu trúc tương tự, tốt hơn nên sử dụng (1) để sử dụng lại các nút DOM của chúng. Nếu họ không thì sử dụng (2).

Tôi sẽ chỉ có hai phần trạng thái React: chỉ số của phần tử đầu tiên và số phần tử đang được hiển thị. Vị trí cuộn hiện tại và chiều cao của tất cả các phần tử sẽ được gắn trực tiếp vào this. Khi sử dụng, setStatebạn thực sự đang thực hiện một kết xuất chỉ xảy ra khi phạm vi thay đổi.

Đây là một ví dụ về danh sách vô hạn sử dụng một số kỹ thuật tôi mô tả trong câu trả lời này. Nó sẽ là một số công việc nhưng React chắc chắn là một cách tốt để triển khai một danh sách vô hạn :)


4
Đây là một kỹ thuật tuyệt vời. Cảm ơn! Tôi đã làm cho nó hoạt động trên một trong các thành phần của tôi. Tuy nhiên, tôi có một thành phần khác mà tôi muốn áp dụng điều này, nhưng các hàng không có chiều cao nhất quán. Tôi đang làm việc trên ví dụ của bạn để tính toán displayEnd / displayEnd để tính đến các chiều cao khác nhau ... trừ khi bạn có ý tưởng tốt hơn?
manalang

Tôi đã thực hiện điều này một cách khó khăn và gặp phải một vấn đề: Đối với tôi, các bản ghi tôi đang hiển thị là DOM hơi phức tạp và vì # trong số chúng, không cẩn thận khi tải tất cả chúng vào trình duyệt, vì vậy tôi thỉnh thoảng thực hiện tìm nạp không đồng bộ. Vì một số lý do, đôi khi khi tôi cuộn và vị trí nhảy rất xa (giả sử tôi rời khỏi màn hình và quay lại), ListBody không hiển thị lại, mặc dù trạng thái thay đổi. Vài ý tưởng tại sao nó như thế? Ví dụ tuyệt vời khác!
SleepyProgrammer

1
JSFiddle của bạn hiện ném một lỗi: của router ReferenceError: Tạo không được định nghĩa
Meglio

3
Tôi đã tạo một fiddle cập nhật , tôi nghĩ rằng nó sẽ hoạt động như cũ. Bất cứ ai quan tâm để xác minh? @Meglio
aknuds1

1
@ThomasModeneis xin chào, bạn có thể làm rõ các tính toán được thực hiện trên dòng 151 và 152, màn hình Bắt đầu và hiển thịEnd
shortCircuit

2

hãy xem tại http://adazzle.github.io/react-data-grid/index.html# Đây trông giống như một datagrid mạnh mẽ và hiệu quả với các tính năng giống như Excel và tải chậm / kết xuất được tối ưu hóa (cho hàng triệu hàng) với tính năng chỉnh sửa phong phú (MIT cấp phép). Chưa thử trong dự án của chúng tôi nhưng sẽ sớm làm được như vậy.

Một tài nguyên tuyệt vời để tìm kiếm những thứ như thế này cũng là http://react.rocks/ Trong trường hợp này, tìm kiếm bằng thẻ rất hữu ích: http://react.rocks/tag/InfiniteScroll


1

Tôi đang đối mặt với một thách thức tương tự đối với việc lập mô hình cuộn vô hạn một hướng với chiều cao mục không đồng nhất và do đó, đã tạo ra một gói npm từ giải pháp của tôi:

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

và bản demo: http://tnrich.github.io/react-variable-height-infinite-scroller/

Bạn có thể kiểm tra mã nguồn để biết logic, nhưng về cơ bản tôi đã làm theo công thức @Vjeux được nêu trong câu trả lời ở trên. Tôi vẫn chưa giải quyết vấn đề chuyển sang một mục cụ thể, nhưng tôi hy vọng sẽ sớm triển khai điều đó.

Đây là thực tế của mã hiện tại trông như thế nào:

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;
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.