Оптимізація рендерингу сторінок (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-шляху. Оптимізація починається з профілювання, а не з угадування.

Інструменти вимірювання

Перед будь-якою оптимізацією — установлення базової лінії:

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

Chrome PerformanceCtrl+Shift+PStart profiling and reload page. У flame chart шукаємо довгих задач (>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 reflows
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');
  });
});

Чеклист оптимізації

Перед оптимізацією вимірюйте з 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 тижні