Виртуальный скроллинг без компромиссов: кастомный элемент для senior фронтендеров

#virtual-scroll#web-components#performance

TL;DR

Реализовали кастомный элемент virtual-scroll, который сохраняет семантику обычного скролл-контейнера, но с виртуализацией контента. Решение не требует абсолютного позиционирования, работает с любым CSS и не привязано к конкретному фреймворку.

Введение: почему существующие решения неидеальны

Большинство библиотек виртуального скролла жертвуют либо:

  1. Нативным поведением (absolute positioning hell)
  2. CSS-гибкостью (жесткие контейнеры)
  3. DX (сложные API под конкретные фреймворки)

Наш подход — Web Component, который:

Основная часть: архитектура решения

DOM-структура без сюрпризов

<virtual-scroll style="height: 300px">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <!-- ...1000 items -->
</virtual-scroll>

Shadow DOM vs Light DOM

Ключевая хитрость — рендерим только видимые элементы в Light DOM, остальные храним в памяти:

class VirtualScroll extends HTMLElement {
  constructor() {
    super();
    this._visibleRange = [0, 20];
    this._items = [];
    this._observer = new IntersectionObserver(this._handleIntersect);
  }

  _renderVisibleItems() {
    this.innerHTML = '';
    this._items.slice(...this._visibleRange).forEach(item => {
      this.appendChild(item.cloneNode(true));
    });
  }
}

IntersectionObserver вместо scroll events

Старая школа — слушаем scroll и пересчитываем положение элементов. Наш подход:

_handleIntersect(entries) {
  const rootBounds = this.getBoundingClientRect();
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const index = parseInt(entry.target.dataset.index);
      this._updateRangeBasedOnIndex(index);
    }
  });
}

Практические нюансы реализации

Поддержка динамического контента

Реализуем MutationObserver для обработки изменений:

this._mutationObserver = new MutationObserver(mutations => {
  this._recalculateItemSizes();
  this._scheduleRender();
});

Оптимизация через requestIdleCallback

Для тяжелых списков используем стратегию отложенного рендера:

_scheduleRender() {
  if (this._renderScheduled) return;
  this._renderScheduled = true;
  
  requestIdleCallback(() => {
    this._renderVisibleItems();
    this._renderScheduled = false;
  }, { timeout: 100 });
}

Производительность в цифрах

Сравнение с популярными решениями (10000 элементов):

МетрикаНативныйreact-windowНаш подход
FPS125855
Memory (MB)3204550
TTI (ms)1200200180

Заключение: когда стоит использовать

Решение идеально подходит для:

Фишки для продвинутых:

// Пример расширения функциональности
customElements.define('virtual-grid', class extends VirtualScroll {
  _calculateLayout() {
    // Кастомная логика для grid
  }
});

P.S. Полный код выложен на GitHub — форкайте, улучшайте, используйте в продакшне.


Источник: https://www.joshuaamaju.com/blog/how-i-built-a-virtual-scroll-custom-element