Оптимизация рендеринга страниц (Virtual DOM, Virtual Scroll) сайта

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

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

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

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Оптимизация рендеринга страниц (Virtual DOM, Virtual Scroll) сайта
Сложная
~2-3 рабочих дня
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Оптимизация рендеринга страниц (Virtual DOM, Virtual Scroll) сайта

Медленный рендеринг проявляется конкретно: список из 10 000 строк вешает браузер при прокрутке, дашборд с графиками тормозит при обновлении данных, модальное окно открывается с задержкой. Причины всегда измеримы — лишние ре-рендеры, DOM с тысячами узлов, синхронные вычисления в render-пути. Оптимизация начинается с профилирования, а не с угадывания.

Инструменты измерения

Перед любой оптимизацией — установить baseline:

React DevTools Profiler — записать сессию взаимодействия, найти компоненты с высоким render duration и частыми ре-рендерами. Особое внимание на why did this render — показывает что изменилось.

Chrome PerformanceCtrl+Shift+PStart profiling and reload page. В flame chart ищем длинные task'и (>50ms), Recalculate Style, Layout thrashing.

// Быстрая проверка без DevTools
const start = performance.now();
// ... операция
console.log(`Took: ${performance.now() - start}ms`);

// Для компонентов — React Profiler API
import { Profiler } from 'react';

<Profiler id="ProductList" onRender={(id, phase, actualDuration) => {
  if (actualDuration > 16) {
    console.warn(`Slow render: ${id} took ${actualDuration}ms (${phase})`);
  }
}}>
  <ProductList />
</Profiler>

Виртуальный скролл: почему DOM убивает производительность

1000 строк в таблице = 1000 DOM-узлов (плюс ячейки). Браузер держит всё в памяти, пересчитывает стили при любом изменении, scroll handler перебирает все элементы. При 5000+ строк страница начинает тормозить на любом железе.

Виртуализация рендерит только видимые строки + небольшой overscan. При прокрутке — заменяет содержимое, сохраняя один набор DOM-узлов.

TanStack Virtual (ранее react-virtual)

Headless — не диктует стили, работает с любым CSS:

npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

interface VirtualListProps<T> {
  items: T[];
  itemHeight: number;
  renderItem: (item: T, index: number) => React.ReactNode;
}

function VirtualList<T>({ items, itemHeight, renderItem }: VirtualListProps<T>) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => itemHeight,
    overscan: 5,  // сколько строк рендерить за видимой областью
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Контейнер с полной высотой — для правильного скроллбара */}
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {renderItem(items[virtualItem.index], virtualItem.index)}
          </div>
        ))}
      </div>
    </div>
  );
}

Для строк переменной высоты — measureElement:

const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 80,  // начальная оценка
  measureElement: (el) => el.getBoundingClientRect().height,  // реальный размер
});

// В рендере строки:
<div
  ref={virtualizer.measureElement}
  data-index={virtualItem.index}
>
  {/* переменный контент */}
</div>

React Window для таблиц

Для таблиц с фиксированными размерами строк react-window проще:

npm install react-window react-window-infinite-loader
npm install --save-dev @types/react-window
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

function VirtualTable({ data }: { data: Row[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className={`row ${index % 2 === 0 ? 'row--even' : ''}`}>
      <div className="cell">{data[index].id}</div>
      <div className="cell">{data[index].name}</div>
      <div className="cell">{data[index].status}</div>
    </div>
  );

  return (
    <AutoSizer>
      {({ height, width }) => (
        <FixedSizeList
          height={height}
          itemCount={data.length}
          itemSize={48}
          width={width}
          overscanCount={5}
        >
          {Row}
        </FixedSizeList>
      )}
    </AutoSizer>
  );
}

Устранение лишних ре-рендеров

Profiler показывает что компонент рендерится слишком часто. Причины и решения:

Новые объекты/функции при каждом рендере:

// Проблема: новая функция на каждый рендер родителя
function Parent() {
  const handleClick = (id: number) => doSomething(id);  // новый объект
  return <Child onClick={handleClick} />;
}

// Решение: useCallback
function Parent() {
  const handleClick = useCallback((id: number) => doSomething(id), []);
  return <Child onClick={handleClick} />;
}

memo для стабильных компонентов:

const ProductCard = memo(function ProductCard({ product, onAddToCart }: Props) {
  return (
    <div className="card">
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>В корзину</button>
    </div>
  );
}, (prev, next) => {
  // Кастомный компаратор — рендерить только если изменились нужные поля
  return prev.product.id === next.product.id
    && prev.product.price === next.product.price
    && prev.onAddToCart === next.onAddToCart;
});

useMemo для дорогих вычислений:

function ProductList({ products, filters }: Props) {
  // Без useMemo — фильтрация на каждый ре-рендер
  const filtered = useMemo(
    () => products.filter(applyFilters(filters)).sort(sortBy('price')),
    [products, filters]
  );

  return filtered.map((p) => <ProductCard key={p.id} product={p} />);
}

Context: изоляция обновлений

Context вызывает ре-рендер у всех потребителей при любом изменении значения. Решение — разделять контексты по частоте изменений:

// Плохо: всё в одном контексте
const AppContext = createContext({ user, cart, theme, settings });

// Хорошо: разделить по частоте обновлений
const UserContext = createContext(user);          // редко
const CartContext = createContext(cart);          // часто (по item'ам)
const ThemeContext = createContext(theme);         // очень редко

Для высокочастотных обновлений (курс, тикер) — use-context-selector:

import { createContext, useContextSelector } from 'use-context-selector';

const StoreContext = createContext({ price: 0, volume: 0 });

// Рендерится только при изменении price, игнорирует volume
function PriceDisplay() {
  const price = useContextSelector(StoreContext, (s) => s.price);
  return <span>{price}</span>;
}

Deferred rendering: React 18 transitions

Тяжёлые обновления (фильтрация большого списка) не должны блокировать ввод:

import { useTransition, useDeferredValue } from 'react';

function SearchWithHeavyList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  // Обновление query — срочное (нужно показать введённый символ)
  // Обновление отфильтрованного списка — несрочное
  const deferredQuery = useDeferredValue(query);

  const filtered = useMemo(
    () => items.filter((item) => item.name.includes(deferredQuery)),
    [items, deferredQuery]
  );

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Поиск..."
      />
      {isPending && <span className="loading-indicator" />}
      <VirtualList items={filtered} itemHeight={48} renderItem={...} />
    </>
  );
}

Web Workers для тяжёлых вычислений

Сортировка/фильтрация миллиона записей не должна блокировать main thread:

// filter.worker.ts
self.onmessage = ({ data: { items, filters } }) => {
  const result = items.filter(applyFilters(filters));
  self.postMessage(result);
};
import { useEffect, useRef, useState } from 'react';

function useWorkerFilter(items: Item[], filters: Filters) {
  const [filtered, setFiltered] = useState(items);
  const workerRef = useRef<Worker>();

  useEffect(() => {
    workerRef.current = new Worker(
      new URL('./filter.worker.ts', import.meta.url),
      { type: 'module' }
    );

    workerRef.current.onmessage = ({ data }) => setFiltered(data);
    return () => workerRef.current?.terminate();
  }, []);

  useEffect(() => {
    workerRef.current?.postMessage({ items, filters });
  }, [items, filters]);

  return filtered;
}

Layout thrashing

Чередование чтения и записи DOM-свойств вызывает принудительный reflow:

// Плохо: read → write → read → write = 4 reflow
const h1 = el1.offsetHeight;
el2.style.height = h1 + 'px';
const h2 = el3.offsetHeight;
el4.style.height = h2 + 'px';

// Хорошо: сначала все чтения, потом все записи
const h1 = el1.offsetHeight;
const h2 = el3.offsetHeight;
el2.style.height = h1 + 'px';
el4.style.height = h2 + 'px';

// Или через requestAnimationFrame
requestAnimationFrame(() => {
  const heights = elements.map(el => el.offsetHeight);
  requestAnimationFrame(() => {
    targets.forEach((el, i) => el.style.height = heights[i] + 'px');
  });
});

Checklist оптимизации

До начала оптимизации измерить с Profiler. Затем по приоритету:

  1. Виртуализация списков > 200 элементов — наибольший эффект
  2. memo + useCallback на компоненты с дорогими рендерами
  3. Разбить контексты, изолировать частые обновления
  4. useDeferredValue / startTransition для несрочных обновлений
  5. useMemo для дорогих вычислений (> 1–2 ms)
  6. Web Workers для CPU-intensive задач

Сроки

  • Аудит производительности (профилирование, отчёт с рекомендациями): 1 день
  • Виртуализация одного тяжёлого списка/таблицы: 1–2 дня
  • Комплексная оптимизация (виртуализация + устранение ре-рендеров + workers): 3–7 дней
  • Полный performance-рефакторинг крупного SPA: 2–4 недели