Виртуализация сетки

В интерфейсах, содержащих большое количество элементов сетки, возникают проблемы производительности. Когда в DOM одновременно присутствуют сотни или тысячи виджетов, браузер тратит значительные ресурсы на:

  • перерасчёт layout
  • обработку событий перетаскивания
  • перерисовку элементов
  • вычисление позиций

Библиотека Gridstack.js поддерживает механизмы оптимизации, позволяющие работать с большими наборами элементов. Одним из ключевых подходов является виртуализация сетки — стратегия, при которой в DOM присутствуют только видимые или близкие к видимой области элементы.

Виртуализация широко применяется в таблицах данных, списках сообщений, панелях аналитики и других интерфейсах, где количество компонентов может достигать нескольких тысяч.

Основная идея:

рендерить только ту часть сетки, которая реально отображается на экране, а остальные элементы хранить в памяти в виде данных.


Проблема больших сеток

Рассмотрим типичную ситуацию:

  • сетка содержит 1000–5000 виджетов
  • каждый элемент — DOM-узел с вложенными компонентами
  • используется перетаскивание и изменение размеров

Простейшая инициализация Gridstack может выглядеть так:

const grid = GridStack.init();

for (let i = 0; i < 1000; i++) {
  grid.addWidget({
    x: i % 12,
    y: Math.floor(i / 12),
    w: 1,
    h: 1,
    content: `Widget ${i}`
  });
}

В этом случае:

  • создаётся 1000 DOM-элементов
  • каждый участвует в layout
  • каждый имеет обработчики событий

Результат:

  • медленный рендер
  • лаги при прокрутке
  • зависания при drag & drop
  • высокая нагрузка на память

Виртуализация решает эту проблему.


Принцип работы виртуализации

Виртуализированная сетка разделяет данные и отображение.

Структура состоит из трёх уровней:

1. Модель данных

Хранит полную информацию о виджетах.

const widgets = [
  {id: 1, x:0, y:0, w:2, h:1, content:"A"},
  {id: 2, x:2, y:0, w:2, h:1, content:"B"},
  ...
];

2. Видимая область

Определяется текущим положением прокрутки.

scrollTop
viewportHeight

3. Активные DOM-элементы

В DOM присутствуют только элементы, попадающие в область:

visibleRange

Определение видимого диапазона

Для расчёта используется положение прокрутки контейнера.

Основные параметры:

scrollTop
viewportHeight
cellHeight

Формула:

firstVisibleRow = floor(scrollTop / cellHeight)
lastVisibleRow = ceil((scrollTop + viewportHeight) / cellHeight)

К этим значениям добавляется буфер, предотвращающий подгрузку при каждом пикселе прокрутки.

Пример:

const buffer = 5;

const startRow = firstVisibleRow - buffer;
const endRow = lastVisibleRow + buffer;

Теперь можно определить элементы, которые должны быть отображены.


Фильтрация виджетов

Каждый элемент сетки имеет координату y.

Фильтрация:

function getVisibleWidgets(widgets, startRow, endRow) {
  return widgets.filter(w => {
    return w.y + w.h >= startRow && w.y <= endRow;
  });
}

Таким образом в DOM попадёт лишь небольшой поднабор элементов.


Рендеринг виртуализированной сетки

Типичный процесс рендеринга состоит из нескольких этапов.

1. Получение видимых элементов

const visibleWidgets = getVisibleWidgets(
  widgets,
  startRow,
  endRow
);

2. Очистка сетки

grid.removeAll(false);

false предотвращает уничтожение DOM-контейнера.

3. Добавление только нужных элементов

visibleWidgets.forEach(w => {
  grid.addWidget({
    x: w.x,
    y: w.y,
    w: w.w,
    h: w.h,
    content: w.content
  });
});

Обработка прокрутки

Виртуализация напрямую зависит от прокрутки контейнера.

container.addEventListener('scroll', handleScroll);

Функция:

function handleScroll() {
  const scrollTop = container.scrollTop;
  const viewportHeight = container.clientHeight;

  const firstRow = Math.floor(scrollTop / cellHeight);
  const lastRow = Math.ceil(
    (scrollTop + viewportHeight) / cellHeight
  );

  updateVisible(firstRow, lastRow);
}

updateVisible обновляет DOM.


Стабилизация интерфейса

Чтобы прокрутка не “прыгала”, высота контейнера должна соответствовать полной высоте сетки.

Общая высота вычисляется:

totalRows * cellHeight

Создаётся виртуальный контейнер.

const spacer = document.createElement('div');
spacer.style.height = totalRows * cellHeight + 'px';
container.appendChild(spacer);

Реальные элементы позиционируются абсолютно.


Абсолютное позиционирование элементов

Виртуализированные элементы не участвуют в обычном потоке документа.

Позиция рассчитывается вручную.

widget.style.position = 'absolute';
widget.style.top = (widgetData.y * cellHeight) + 'px';
widget.style.left = (widgetData.x * cellWidth) + 'px';

Это позволяет:

  • отображать элементы в нужной позиции
  • не создавать тысячи DOM-узлов

Буферизация элементов

Без буфера элементы будут появляться с задержкой при прокрутке.

Буфер решает проблему:

visibleRange = viewport + buffer

Пример:

viewport rows: 50
buffer rows: 10

В DOM:

70 строк

Это значительно уменьшает частоту обновлений.


Кэширование DOM-элементов

Удаление и повторное создание элементов может быть дорогим.

Поэтому используется пул элементов.

Пример:

const elementPool = [];

При удалении:

function recycleElement(el) {
  elementPool.push(el);
}

При создании:

function getElement() {
  if (elementPool.length) {
    return elementPool.pop();
  }
  return document.createElement('div');
}

Это снижает нагрузку на GC.


Виртуализация и drag & drop

Перетаскивание усложняет виртуализацию.

Проблемы:

  • элемент может быть перемещён в невидимую область
  • координаты меняются при прокрутке
  • сетка должна обновлять модель данных

Процесс:

  1. пользователь начинает перетаскивание
  2. элемент фиксируется
  3. временно отключается виртуализация
  4. после завершения позиция обновляется
grid.on('dragstop', (event, el) => {
  const node = el.gridstackNode;

  updateWidgetData(node);
});

Виртуализация и изменение размеров

Resize работает аналогично drag.

После изменения размеров необходимо обновить модель:

grid.on('resizestop', (event, el) => {
  const node = el.gridstackNode;

  const widget = widgets.find(w => w.id === node.id);

  widget.w = node.w;
  widget.h = node.h;
});

Lazy-рендеринг содержимого

Даже если элемент отображается, его внутренний контент может быть тяжёлым.

Например:

  • графики
  • таблицы
  • редакторы
  • canvas

Lazy-подход:

  1. сначала создаётся пустой контейнер
  2. содержимое загружается позже
function renderWidget(widget) {
  const el = document.createElement('div');

  el.innerHTML = '<div class="loading">...</div>';

  requestIdleCallback(() => {
    el.innerHTML = widget.content;
  });

  return el;
}

Intersection Observer

Современный подход — использование IntersectionObserver.

Он определяет момент появления элемента в области просмотра.

const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadWidget(entry.target);
    }
  });
});

Подключение:

observer.observe(widgetElement);

Ограничение частоты обновлений

Scroll-события могут вызываться сотни раз в секунду.

Поэтому используется throttle.

function throttle(fn, delay) {
  let last = 0;

  return function(...args) {
    const now = Date.now();

    if (now - last >= delay) {
      last = now;
      fn(...args);
    }
  };
}

Использование:

container.addEventListener(
  'scroll',
  throttle(handleScroll, 50)
);

Использование requestAnimationFrame

Более плавный способ — обновление через animation frame.

let ticking = false;

container.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      handleScroll();
      ticking = false;
    });

    ticking = true;
  }
});

Это синхронизирует обновления с рендер-циклом браузера.


Хранение модели сетки

Полная модель сетки хранится отдельно от DOM.

Пример структуры:

const gridModel = {
  columns: 12,
  cellHeight: 80,
  widgets: []
};

Каждый элемент:

{
  id: 12,
  x: 4,
  y: 22,
  w: 3,
  h: 2,
  content: "Chart"
}

DOM становится лишь проекцией данных.


Сценарии применения

Виртуализация особенно полезна в следующих системах:

Аналитические панели

Сетки с десятками:

  • графиков
  • отчётов
  • таблиц

Конструкторы интерфейсов

Low-code редакторы могут содержать сотни компонентов.

Системы мониторинга

Дашборды с большим количеством метрик.

CMS-редакторы

Модульные страницы с большим количеством блоков.


Ограничения виртуализации

Несмотря на преимущества, виртуализация имеет ограничения.

Сложность реализации

Необходима собственная система:

  • расчёта диапазона
  • управления DOM
  • синхронизации данных

Конфликты с drag-системой

Некоторые элементы могут исчезать при перемещении.

Дополнительная логика

Требуется:

  • буферизация
  • кэширование
  • управление событиями

Комбинирование с серверной подгрузкой

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

  1. виртуализация DOM
  2. подгрузка данных по мере прокрутки

Пример:

async function loadWidgets(startRow, endRow) {
  const response = await fetch(
    `/widgets?start=${startRow}&end=${endRow}`
  );

  return await response.json();
}

Загружаются только нужные элементы.


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

Оптимальный размер буфера

5–20 строк

Количество DOM-элементов

Желательно держать:

< 200 элементов

Использование абсолютного позиционирования

Это уменьшает перерасчёт layout.

Минимизация вложенных DOM

Каждый виджет должен быть лёгким.

Отложенная инициализация

Тяжёлые компоненты создаются только при отображении.


Архитектура виртуализированной сетки

Типичная архитектура состоит из нескольких модулей.

GridModel

Хранение данных.

VirtualViewport

Расчёт видимой области.

Renderer

Отрисовка DOM.

WidgetPool

Повторное использование элементов.

ScrollController

Обработка прокрутки.

Такое разделение позволяет масштабировать систему до тысяч элементов, сохраняя плавную работу интерфейса.