Search code examples
javascriptvirtualscrollpure-js

Virtual scroll implementation causes scrolling down to scroll to the end


I tried to write a virtual scroll in pure JS, but when I scroll down, the scrolling does not stop. I can't figure out what's causing the infinite scrolling.

To emulate scrolling, I use two containers, top and bottom. During scrolling, I proportionally change their height and at the same time rerender the internal content.

class VirtualScroll {
  constructor(selector, visibleRows = 0, amount, rowHeight) {
    this.scrollContainer = document.querySelector(selector);

    if (!this.scrollContainer) {
      return false;
    }

    this.rowHeight = rowHeight;
    this.visibleRows = visibleRows;
    this.start = 0;

    this.data = this.createTableData(amount);

    // Верхнее пространство для скролла
    this.topSpace = document.createElement("div");
    this.topSpace.classList.add('hidden-top');
    this.topSpace.style = `height: ${this.getTopHeight()}px;`;
    this.scrollContainer.appendChild(this.topSpace);

    // Создание пространство видимых блоков
    this.container = document.createElement("div");
    this.container.classList.add('scroll-container');
    this.fillContainer();

    this.scrollContainer.appendChild(this.container);

    // Верхнее пространство для скролла
    this.bottomSpace = document.createElement("div");
    this.bottomSpace.classList.add('hidden-bottom');
    this.bottomSpace.style = `height: ${this.getBottomHeight()}px`
    this.scrollContainer.appendChild(this.bottomSpace);

    this.scrollContainer.addEventListener('scroll', (e) => {
      const oldStart = this.start + 0;
      this.start = Math.min(
        this.data.length - this.visibleRows - 1,
        Math.floor(e.target.scrollTop / this.rowHeight)
      )

      if (oldStart === this.start) {
        return false;
      }

      this.bottomSpace.style = `height: ${this.getBottomHeight()}px`;
      this.topSpace.style = `height: ${this.getTopHeight()}px;`;
      this.fillContainer();
    });
  }

  getTopHeight() {
    return this.rowHeight * this.start;
  }

  getBottomHeight() {
    return this.rowHeight * (this.data.length - (this.start + this.visibleRows + 1));
  }

  createTableData(h) {
    return new Array(h).fill(0).map((_, row) => {
      return row;
    });
  }

  // Заполнение контейнера видимыми блоками 
  fillContainer() {
    this.container.innerHTML = "";
    this.data.slice(this.start, this.start + this.visibleRows + 1).map((row, rowIndex) => {
      const item = document.createElement("div");
      item.classList.add('scroll-item');
      item.innerText = row;
      item.id = this.start + ' ' + rowIndex + row;
      this.container.appendChild(item);
    })
  }
}

new VirtualScroll(".container", 4, 100, 30);
* {
  box-sizing: border-box;
}

.container {
  height: 120px;
  overflow-y: auto;
}

.scroll-container {
  width: 100%;
}

.scroll-item {
  height: 30px;
  border: 1px solid black;
  padding: 10px;
  color: black;
}
<div class="container"> </div>

CodePen Example

I hope more attentive and skillful developers will tell me what’s wrong.


Solution

  • The approach of using a top and bottom element that is changing size dynamically is probably not going to reliably work. The reason for this is that when the height of the top div changes, the browser will trigger a layout operation on the parent scroll container which in turn could change the scroll position.

    You then have a situation where the scroll handler gets called in a loop since the logic in the handler ultimately ends up triggering the scroll container to change its scroll position. Tangentially, this is one of the reasons developing "chat" interfaces is classically surprisingly complex since new content ("messages") are added above.

    A quick Google search around this reveals that it's a common problem.

    What you can do instead is scrap the top/bottom containers and use a combination of absolute positioning combined with top for the top spacing and padding-bottom for the bottom spacing. Since absolute positioning takes the container out of the layout, it will not trigger the scroll event when top changes, so it won't get stuck in a loop.

    Your maths on the space needed each side is accurate, so we can simply use the existing calculations you have, but plugged into these CSS properties. The bonus is the whole thing is done with slightly less effort/complexity and without majorly changing the overarching implementation you have.

    Note changes are needed in both the CSS and JS. Here it is a codepen.

    class VirtualScroll {
      constructor(selector, visibleRows = 0, amount, rowHeight) {
    this.scrollContainer = document.querySelector(selector);
    
    if (!this.scrollContainer) {
      return false;
    }
    
    this.rowHeight = rowHeight;
    this.visibleRows = visibleRows;
    this.start = 0;
    
    this.data = this.createTableData(amount);
    
    // Создание простанство видимых блоков
    this.container = document.createElement("div");
    this.container.classList.add("scroll-container");
    this.fillContainer();
    
    this.scrollContainer.appendChild(this.container);
    this.container.style = `top: ${this.getTopHeight()}px; padding-bottom: ${this.getBottomHeight()}px;`;
    
    this.scrollContainer.addEventListener("scroll", (e) => {
      const oldStart = this.start + 0;
      this.start = Math.min(
        this.data.length - this.visibleRows - 1,
        Math.floor(e.target.scrollTop / this.rowHeight),
      );
    
      if (oldStart === this.start) {
        return false;
      }
    
      this.container.style = `top: ${this.getTopHeight()}px; padding-bottom: ${this.getBottomHeight()}px;`;
      this.fillContainer();
    });
      }
    
      getTopHeight() {
    return this.rowHeight * this.start;
      }
    
      getBottomHeight() {
    return (
      this.rowHeight * (this.data.length - (this.start + this.visibleRows + 1))
    );
      }
    
      createTableData(h) {
    return new Array(h).fill(0).map((_, row) => {
      return row;
    });
      }
    
      // Заполнение контейнера видимыми блоками
      fillContainer() {
    this.container.innerHTML = "";
    this.data
      .slice(this.start, this.start + this.visibleRows + 1)
      .map((row, rowIndex) => {
        const item = document.createElement("div");
        item.classList.add("scroll-item");
        item.innerText = row;
        item.id = this.start + " " + rowIndex + row;
        this.container.appendChild(item);
      });
      }
    }
    
    new VirtualScroll(".container", 4, 100, 30);
    * {
      box-sizing:border-box;
    }
    .container {
      height: 120px;
      overflow-y: auto;
      position: relative;
    }
    .scroll-container{
      width: 100%;
      position: absolute;
    }
    .scroll-item {
      height: 30px;
      border: 1px solid black;
      padding: 10px;
      color: black;
    }
    <div class="container"> </div>