Способи оптимізації додатків
Способи оптимізації додатків - набір технік, що скорочують час завантаження, знижують вартість рендерингу і прискорюють взаємодію на рівні мережі, ресурсів і runtime.
Теорія
TL;DR
- Мережа: стискай через Gzip/Brotli, підключай CDN, вмикай HTTP/2, виставляй заголовки
Cache-Controlдля статики - Завантаження: code splitting на рівні маршрутів, lazy loading зображень і компонентів, конвертуй зображення в WebP/AVIF
- Runtime: debounce для полів вводу, Web Workers для важких обчислень, віртуалізація довгих списків
- React:
useMemo,useCallback,React.memoмають сенс тільки якщо профайлер підтвердив реальну проблему - Вимірюй першим: Lighthouse, Web Vitals (LCP, FID, CLS), вкладка Performance у DevTools
Мережевий рівень
Gzip скорочує розмір HTML/CSS/JS при передачі в середньому на 60-80%. Brotli стискає на 15-20% краще за Gzip для текстових ресурсів. Більшість серверів (Nginx, Apache) підтримують обидва формати. Вмикай для всіх текстових відповідей, не тільки для HTML.
CDN розміщує статику (зображення, шрифти, скрипти) на серверах ближче до користувачів. Cloudflare і Amazon CloudFront - найпоширеніші варіанти. Користувач у Варшаві, що завантажує ресурси з франкфуртської edge-ноди замість US origin, отримує round-trip ~15мс замість ~120мс. На кожному ресурсі сторінки ця різниця накопичується.
HTTP/2 змінює спосіб спілкування браузера і сервера. HTTP/1.1 ставить запити в чергу на одному з'єднанні. HTTP/2 мультиплексує їх одночасно: 20 паралельних запитів не блокують один одного. Також він стискає заголовки через HPACK. Перехід на HTTP/2 найбільше помітний на сторінках з багатьма дрібними ресурсами.
Заголовки кешування
Встанови Cache-Control: max-age=31536000, immutable для версіонованих статичних ресурсів (файли з хешем у назві, на кшталт main.a3f9c2.js). Браузер не зробить жодного мережевого запиту протягом року. Для HTML використовуй Cache-Control: no-cache разом з ETag: браузер перевіряє актуальність, але не завантажує файл знову якщо нічого не змінилось.
Code splitting і lazy loading
Webpack і Vite автоматично розбивають бандл при динамічних викликах import(). React-додаток з одним бандлом 800KB стає кількома чанками по 50-100KB, що завантажуються за потреби. Браузер отримує тільки код для поточного маршруту.
React.lazy обгортає динамічний імпорт, а Suspense показує заглушку під час завантаження:
const Dashboard = React.lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
);
}Для зображень нативний атрибут loading="lazy" на тегу <img> відкладає завантаження до появи елемента у viewport. Без JavaScript. Додай width і height, щоб уникнути зміщення верстки (layout shift).
Оптимізація зображень
Переходь на WebP або AVIF. WebP в середньому на 25-35% менший за JPEG при еквівалентній якості. AVIF стискає краще, але підтримка кодувальників з'явилась пізніше. Використовуй <picture> для фолбеків:
<picture>
<source srcset="photo.avif" type="image/avif">
<source srcset="photo.webp" type="image/webp">
<img src="photo.jpg" alt="..." loading="lazy" width="800" height="600">
</picture>Якщо роздаєш зображення через CDN з автоматичним вибором формату (Cloudinary, Imgix), CDN сам підбирає формат за URL-параметрами і цей HTML не потрібен.
Debounce і throttle
Поле пошуку, що відправляє запит при кожному натисканні клавіші, може генерувати 10-20 мережевих запитів на секунду. Debounce затримує виконання до паузи у введенні:
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce((query) => fetchResults(query), 300);Throttle працює інакше: виконує функцію з фіксованим інтервалом незалежно від частоти викликів. Підходить для обробників scroll і resize, де потрібні рівномірні оновлення без пропусків.
Web Workers
JavaScript виконується в одному потоці. Важкі обчислення на головному потоці блокують рендеринг і заморожують інтерфейс. Web Workers запускаються в окремому потоці, спілкуються через postMessage і не мають доступу до DOM.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = (e) => console.log(e.data.result);
// worker.js
self.onmessage = (e) => {
const result = processData(e.data.data); // виконується поза головним потоком
self.postMessage({ result });
};Типові кейси: парсинг CSV, обробка зображень, сортування великих масивів, шифрування.
Віртуалізація для довгих списків
Рендеринг 10 000 рядків <tr> створює 10 000 DOM-вузлів. Браузер малює і відстежує їх всіх, навіть якщо на екрані видно тільки 20. Віртуалізація рендерить лише видимі рядки. Бібліотеки react-window і @tanstack/virtual беруть математику на себе. Список, що рендерився 3 секунди з повним DOM, може впасти до 100мс з віртуалізацією.
React: мемоізація
useMemo, useCallback і React.memo не безкоштовні: вони займають пам'ять і додають порівняння при кожному рендері. Типова помилка - додавати їх скрізь превентивно. Спочатку відкрий React Profiler і підтверди, що компонент справді зайво перерендерюється. Тоді вже застосовуй мемоізацію точково.
React.memo пропускає перерендер компонента, якщо пропси не змінились (поверхневе порівняння). useMemo кешує результат дорогого обчислення. useCallback зберігає посилання на функцію, щоб дочірні компоненти, які отримують її як проп, не перерендерювались зайвий раз.
Вимірювання продуктивності
Чотири інструменти покривають більшість випадків:
- Lighthouse (вкладка Audits у Chrome DevTools): оцінює LCP, FID/INP, CLS і дає конкретні рекомендації
- Web Vitals: LCP менше 2.5с, FID менше 100мс, CLS менше 0.1 - пороги "добре" за версією Google
- Вкладка Performance у DevTools: записує трейс рендерингу, скриптів і малювання, допомагає знайти причину повільної взаємодії
- WebPageTest: тестує з реальних локацій і пристроїв, включно з емуляцією 3G
React Profiler (розширення браузера або API <Profiler>) показує які компоненти рендеряться, як часто і скільки часу це займає.
Типові помилки
Мемоізувати все. useMemo і useCallback скрізь додають накладні витрати без користі, якщо компонент і так дешевий у рендерингу.
Надто агресивний code splitting. Забагато дрібних чанків означає забагато HTTP-запитів. Розбивка на рівні маршрутів - правильна гранулярність у більшості проектів.
CDN без заголовків кешування. Файли за CDN з Cache-Control: no-store змушують CDN щоразу звертатись до origin. Географічний розподіл є, кешування немає.
Lazy loading для зображень вище згину (above-the-fold). Головне зображення або логотип не повинні мати loading="lazy". Це безпосередньо затримує LCP.
Пропустити вимірювання. Надмірний splitting або зайві інвалідації кешу можуть погіршити ситуацію. Профілюй перед змінами.
Follow-up питання
Q: Яка різниця між debounce і throttle?
A: Debounce чекає паузу у викликах і спрацьовує один раз після неї. Throttle спрацьовує з фіксованою частотою незалежно від кількості викликів. Для пошуку - debounce, для scroll - throttle.
Q: Коли не варто використовувати React.memo?
A: Коли компонент дешевий у рендерингу, або коли пропси змінюються при кожному рендері батька. Вартість порівняння перевищує вартість рендерингу в таких випадках.
Q: Які HTTP-заголовки відповідають за кешування браузера?
A: Cache-Control задає політику (max-age, no-cache, immutable). ETag - відбиток файлу, який браузер надсилає назад для перевірки актуальності. Last-Modified робить те ж через часову мітку.
Q: Чому HTTP/2 швидший за HTTP/1.1?
A: HTTP/1.1 обробляє один запит на з'єднання за раз. HTTP/2 мультиплексує багато запитів через одне TCP-з'єднання, усуваючи head-of-line blocking для завантаження ресурсів.
Q: Що таке Core Web Vitals і чому Google враховує їх у ранжуванні?
A: LCP, FID/INP і CLS вимірюють реальний досвід користувача: час завантаження головного контенту, відгук на введення, стабільність верстки. Google включив їх у сигнали ранжування з 2021 року.
Q: Яка різниця між віртуалізацією і пагінацією для великих списків?
A: Пагінація завантажує підмножину даних і вимагає навігації між сторінками. Віртуалізація завантажує всі дані, але тримає у DOM тільки видимі рядки. Віртуалізація дає плавніший скролінг, пагінація скорочує обсяг переданих даних.
Приклади
Code splitting з React.lazy
import React, { Suspense } from 'react';
// UserProfile збирається окремим чанком, завантажується тільки коли isLoggedIn стає true
const UserProfile = React.lazy(() => import('./UserProfile'));
function App({ isLoggedIn }) {
return (
<div>
{isLoggedIn && (
<Suspense fallback={<div>Завантаження профілю...</div>}>
<UserProfile />
</Suspense>
)}
</div>
);
}Webpack або Vite видадуть UserProfile як окремий чанк. Браузер завантажить його тільки якщо isLoggedIn дорівнює true. Початковий бандл іде без нього, що скорочує розмір першого завантаження.
Debounce для поля пошуку
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delay);
};
}
const searchInput = document.querySelector('#search');
const fetchSuggestions = debounce(async (query) => {
const res = await fetch(`/api/suggest?q=${query}`);
const data = await res.json();
renderSuggestions(data);
}, 300);
searchInput.addEventListener('input', (e) => {
fetchSuggestions(e.target.value);
});Без debounce введення слова "react hooks" (10 символів) відправляє 10 запитів. З debounce 300мс - один запит після паузи. У продукті з 50 тис. активних користувачів на день це скорочує навантаження на API на 80-90%.
Віртуальний список з react-window
import { FixedSizeList } from 'react-window';
const rows = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Елемент ${i}` }));
function Row({ index, style }) {
return (
<div style={style}>
{rows[index].name}
</div>
);
}
function VirtualList() {
return (
<FixedSizeList
height={600}
itemCount={rows.length}
itemSize={40}
width="100%"
>
{Row}
</FixedSizeList>
);
}react-window рендерить тільки рядки, що вміщаються в контейнер 600px. При висоті рядка 40px це 15 елементів у DOM замість 10 000. Я бачив як цей підхід скорочував початковий рендер звітної таблиці з 4 секунд до 150мс.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.