ReactJS: การสร้างแบบจำลองการเลื่อนแบบไม่มีที่สิ้นสุดแบบสองทิศทาง


114

แอปพลิเคชันของเราใช้การเลื่อนแบบไม่มีที่สิ้นสุดเพื่อนำทางรายการที่แตกต่างกันจำนวนมาก มีริ้วรอยเล็กน้อย:

  • เป็นเรื่องปกติที่ผู้ใช้ของเราจะมีรายการ 10,000 รายการและต้องเลื่อนดู 3k +
  • สิ่งเหล่านี้เป็นรายการที่สมบูรณ์ดังนั้นเราจึงมีเพียงไม่กี่ร้อยรายการใน DOM ก่อนที่ประสิทธิภาพของเบราว์เซอร์จะไม่เป็นที่ยอมรับ
  • รายการมีความสูงแตกต่างกัน
  • รายการอาจมีรูปภาพและเราอนุญาตให้ผู้ใช้ข้ามไปยังวันที่ที่ระบุ นี่เป็นเรื่องยุ่งยากเนื่องจากผู้ใช้สามารถข้ามไปยังจุดหนึ่งในรายการที่เราต้องโหลดภาพเหนือวิวพอร์ตซึ่งจะดันเนื้อหาลงเมื่อโหลด ไม่สามารถจัดการได้นั่นหมายความว่าผู้ใช้อาจข้ามไปยังวันที่ แต่จะถูกเลื่อนไปเป็นวันที่ก่อนหน้านี้

โซลูชันที่เป็นที่รู้จักและไม่สมบูรณ์:

  • ( react-infinite-scroll ) - นี่เป็นเพียงคอมโพเนนต์ "โหลดมากขึ้นเมื่อเรากดด้านล่าง" มันไม่ได้คัดแยก DOM ใด ๆ ออกไปดังนั้นมันจะตายในหลายพันรายการ

  • ( ตำแหน่งการเลื่อนด้วยปฏิกิริยา ) - แสดงวิธีการจัดเก็บและเรียกคืนตำแหน่งการเลื่อนเมื่อใส่ที่ด้านบนหรือแทรกที่ด้านล่าง แต่ไม่ใช่ทั้งสองอย่างพร้อมกัน

ฉันไม่ได้มองหาโค้ดสำหรับโซลูชันที่สมบูรณ์ (แม้ว่าจะดีมากก็ตาม) แต่ฉันกำลังมองหา "วิธีตอบสนอง" เพื่อจำลองสถานการณ์นี้ สถานะตำแหน่งเลื่อนหรือไม่? ฉันควรติดตามสถานะใดเพื่อรักษาตำแหน่งของฉันไว้ในรายการ ฉันต้องเก็บสถานะอะไรเพื่อที่จะทริกเกอร์การเรนเดอร์ใหม่เมื่อฉันเลื่อนไปใกล้ด้านล่างหรือด้านบนของสิ่งที่แสดงผล

คำตอบ:


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 คือจำนวนองค์ประกอบสูงสุดที่คุณสามารถแสดงและใช้โมดูโลตำแหน่งของมันได้ (2) มีคีย์ที่แตกต่างกันสำหรับแต่ละองค์ประกอบ หากองค์ประกอบทั้งหมดมีโครงสร้างที่คล้ายกันควรใช้ (1) เพื่อใช้โหนด DOM ซ้ำ หากไม่ได้ใช้ (2)

ฉันจะมีสถานะการตอบสนองเพียงสองส่วน: ดัชนีขององค์ประกอบแรกและจำนวนองค์ประกอบที่แสดง thisเลื่อนตำแหน่งปัจจุบันและความสูงขององค์ประกอบทั้งหมดจะได้รับการติดโดยตรงกับ เมื่อใช้setStateคุณกำลังทำการเรนเดอร์จริงซึ่งจะเกิดขึ้นเมื่อช่วงเปลี่ยนไปเท่านั้น

นี่คือตัวอย่างของรายการที่ไม่มีที่สิ้นสุดโดยใช้เทคนิคบางอย่างที่ฉันอธิบายไว้ในคำตอบนี้ มันจะใช้งานได้ แต่ React เป็นวิธีที่ดีในการใช้รายการที่ไม่มีที่สิ้นสุด :)


4
นี่เป็นเทคนิคที่ยอดเยี่ยม ขอบคุณ! ฉันทำให้มันทำงานกับหนึ่งในส่วนประกอบของฉัน อย่างไรก็ตามฉันมีองค์ประกอบอื่นที่ต้องการใช้กับสิ่งนี้ แต่แถวไม่มีความสูงที่สม่ำเสมอ ฉันกำลังดำเนินการเพิ่มตัวอย่างของคุณเพื่อคำนวณ displayEnd / visibleEnd เพื่อพิจารณาความสูงที่แตกต่างกัน ... เว้นแต่คุณจะมีความคิดที่ดีกว่านี้?
manalang

ฉันได้ใช้สิ่งนี้ด้วยการบิดและพบปัญหา: สำหรับฉันเร็กคอร์ดที่ฉันแสดงนั้นค่อนข้างซับซ้อน DOM และเนื่องจาก # ของพวกเขาจึงไม่รอบคอบที่จะโหลดทั้งหมดลงในเบราว์เซอร์ดังนั้นฉันจึง ทำการ async ดึงข้อมูลเป็นครั้งคราว ด้วยเหตุผลบางอย่างในบางครั้งที่ฉันเลื่อนและตำแหน่งกระโดดไปไกลมาก (บอกว่าฉันออกจากหน้าจอและถอยหลัง) ListBody จะไม่แสดงผลใหม่แม้ว่าสถานะจะเปลี่ยนไปก็ตาม ความคิดใด ๆ ที่อาจเป็นเช่นนี้? เป็นตัวอย่างที่ดี!
SleepyProgrammer

1
ขณะนี้ JSFiddle ของคุณแสดงข้อผิดพลาด: Uncaught ReferenceError: create ไม่ได้กำหนดไว้
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.