TL;DR
Реализовали кастомный элемент virtual-scroll, который сохраняет семантику обычного скролл-контейнера, но с виртуализацией контента. Решение не требует абсолютного позиционирования, работает с любым CSS и не привязано к конкретному фреймворку.
Введение: почему существующие решения неидеальны
Большинство библиотек виртуального скролла жертвуют либо:
- Нативным поведением (absolute positioning hell)
- CSS-гибкостью (жесткие контейнеры)
- DX (сложные API под конкретные фреймворки)
Наш подход — Web Component, который:
- Сохраняет flow layout
- Работает с любыми дочерними элементами
- Не ломает CSS-специфичность
Основная часть: архитектура решения
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 | Наш подход |
|---|---|---|---|
| FPS | 12 | 58 | 55 |
| Memory (MB) | 320 | 45 | 50 |
| TTI (ms) | 1200 | 200 | 180 |
Заключение: когда стоит использовать
Решение идеально подходит для:
- Приложений с тяжелыми списками
- Проектов без фреймворков
- Случаев, когда важна CSS-гибкость
Фишки для продвинутых:
- Поддержка горизонтального скролла
- Кастомные стратегии кэширования
- Плагинная система для нестандартных случаев
// Пример расширения функциональности
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