Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «Продуктивність»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Увімкнути компілятор на великому існуючому проєкті одним кроком ризиковано: код, що порушує правила React, але «випадково працював», може поводитися інакше після мемоізації. Документація радить поступове впровадження.

Три способи обмежити область дії:

1. За каталогами - через overrides Babel:

// babel.config.js
module.exports = {
  overrides: [
    { test: './src/features/checkout/**/*.{js,jsx,ts,tsx}', plugins: ['babel-plugin-react-compiler'] },
  ],
};

2. Режим анотацій - компілюються лише функції з директивою:

['babel-plugin-react-compiler', { compilationMode: 'annotation' }]
function ProductGrid({ products }) {
  'use memo';   // лише цей компонент оптимізується
  // ...
}

3. Runtime-гейтинг (gating) - компілятор генерує обидві версії, і вибір між ними робить прапорець функцій: можна порівняти метрики на частині користувачів.

Виключення компонента - "use no memo":

function LegacyDataGrid(props) {
  'use no memo';   // TODO: прибрати після виправлення мутації props у сортуванні (JIRA-123)
  // ...
}

Директиви - тимчасові аварійні виходи: документація радить пояснювати причину коментарем і планувати їх видалення.

Режими компіляції: infer (за замовчуванням - компілятор сам визначає компоненти й хуки за іменуванням), annotation, all; директиви перевизначають рішення режиму.

Типові причини проблем після ввімкнення:

  • мутація props чи стану - раніше рендер «підхоплював» зміну, тепер мемоізований результат лишається старим;
  • читання ref.current під час рендеру;
  • бібліотеки з власним механізмом відстеження змін (інкрементальні сховища, деякі бібліотеки форм і таблиць) - правило incompatible-library попереджає про відомі випадки;
  • ефекти, що залежали від нестабільних залежностей і раніше спрацьовували частіше.

Порядок дій:

  1. увімкнути правила компілятора в eslint-plugin-react-hooks і виправити порушення;
  2. ввімкнути компілятор на частині коду з хорошими тестами;
  3. перевірити в DevTools, які компоненти оптимізовано (мітка «Memo ✨»);
  4. порівняти метрики (INP, час рендеру), розширювати область.

Існуючі useMemo/useCallback не обов'язково прибирати одразу - компілятор їх враховує, а правило preserve-manual-memoization попередить, якщо не зможе зберегти ручну мемоізацію.

Докладніше в документації: React Compiler: директиви

React вирішує, чи зберегти компонент між рендерами, за двома ознаками: позиція в дереві і тип (функція-компонент). Якщо на тій самій позиції опинився інший тип - старий компонент знищується разом зі станом і DOM, новий створюється з нуля.

Типова помилка:

function VacancyPage({ vacancy }) {
  const [tab, setTab] = useState('description');

  // нова функція на КОЖНОМУ рендері VacancyPage
  function ApplyForm() {
    const [email, setEmail] = useState('');
    return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
  }

  return (
    <>
      <Tabs value={tab} onChange={setTab} />
      <ApplyForm />
    </>
  );
}

Кожен рендер VacancyPage створює нову функцію ApplyForm - для React це новий тип компонента. Наслідки:

  • стан скидається: введений email зникає при перемиканні вкладки;
  • DOM перестворюється - втрачається фокус, позиція курсору, анімації;
  • дорожче: монтування з нуля замість оновлення.

Рішення: оголошувати компоненти на верхньому рівні модуля і передавати дані через props. Правило static-components з eslint-plugin-react-hooks ловить цю помилку автоматично.

Та сама механіка свідомо - key для скидання стану:

<ProfileForm key={userId} user={user} />

Зміна key - новий компонент: форма скидається при переході до іншого користувача. Це краще, ніж синхронізувати стан з props в ефекті.

Але key має ціну. Перемонтування - це знищення й створення всього піддерева: ефекти очищення й запуску, нові запити, втрата стану в усіх нащадках. Зміна key на великому дереві без потреби (наприклад, key={Math.random()} чи key={JSON.stringify(filters)} на всій сторінці) - джерело повільності й «миготіння».

Інші випадки втрати стану через позицію:

  • умовний рендер, що змінює структуру: {isEditing ? <Form /> : <Preview />} на тій самій позиції - стан форми зникає при перемиканні. Якщо стан треба зберегти - тримати його вище чи ховати компонент CSS;
  • різні обгортки: <div><Counter /></div> проти <section><Counter /></section> - інший тип батька, лічильник скидається;
  • індекс у key списку - при видаленні першого елемента стан «переїжджає» на сусідні.

Правило: тип і позиція компонента мають бути стабільними, а скидання стану - явним, через key.

Докладніше в документації: Збереження й скидання стану

При серверному рендері (Next.js, Inertia SSR, React Router) сторінка приходить готовим HTML і швидко з'являється. Але інтерактивною вона стає лише після гідратації: браузер завантажує JavaScript, React заново виконує всі компоненти й «прив'язує» обробники подій до існуючого HTML.

import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root')!, <App />);

Чому це дорого:

  • завантаження й розбір JavaScript усіх компонентів сторінки, навіть статичних;
  • повторне виконання всіх компонентів на клієнті - обсяг роботи як у першого клієнтського рендеру;
  • поки гідратація триває, кліки не працюють або обробляються із затримкою (погіршується INP).

Як прискорити:

1. Server Components (у фреймворках, що їх підтримують) - компоненти, які виконуються лише на сервері. Їхній код не потрапляє в бандл і не гідратується. Клієнтськими ('use client') лишаються лише інтерактивні частини.

2. Вибіркова гідратація через Suspense:

<Layout>
  <Header />
  <Suspense fallback={<CommentsSkeleton />}>
    <Comments />
  </Suspense>
</Layout>

Межі Suspense дають React змогу гідратувати частини незалежно: з потоковим рендером (renderToReadableStream/renderToPipeableStream) вміст надходить порціями, а при кліку на ще не гідратовану частину React пріоритетно гідратує саме її.

3. Менше JavaScript на сторінці: розділення коду, ліниве завантаження компонентів нижче першого екрана, легші бібліотеки.

4. Розбіжності гідратації (hydration mismatch) - HTML з сервера не збігається з першим клієнтським рендером (дата й час, Math.random(), window у рендері, розширення браузера змінили DOM). React 19 показує зрозумілу різницю в консолі, але виправлення - перерендер частини дерева на клієнті, тобто додаткова робота. Для неминучих відмінностей (час) - suppressHydrationWarning точково, для залежного від браузера - рендер після монтування.

5. Не гідратувати те, що не інтерактивне. Якщо сторінка переважно статична, «острівці» інтерактивності (окремі createRoot для віджетів) чи Server Components дешевші, ніж гідратація всього документа.

Метрики: Total Blocking Time і INP у Lighthouse/вебвіталсах, вкладка Performance - довгі задачі під час гідратації.

Докладніше в документації: hydrateRoot

Повільна сторінка часто повільна не через рендер, а через водоспад завантажень: компонент рендериться - лише тоді дізнається, що потрібен шрифт, скрипт чи дані - запитує - чекає. React 19 додав API, щоб компонент міг оголосити ресурс заздалегідь.

Функції з react-dom:

import { preload, preinit, preconnect, prefetchDNS } from 'react-dom';

function ProductPage({ product }) {
  preconnect('https://cdn.example.com');                         // відкрити з'єднання
  preload(product.heroImage, { as: 'image', fetchPriority: 'high' });   // завантажити, не виконувати
  preinit('https://maps.example.com/sdk.js', { as: 'script' });   // завантажити й виконати
  preload('/fonts/Inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });

  return <Gallery images={product.images} />;
}
  • prefetchDNS - лише розв'язати домен;
  • preconnect - встановити з'єднання (DNS + TLS);
  • preload - завантажити ресурс (зображення, шрифт, стиль, скрипт), щоб він був у кеші, коли знадобиться;
  • preinit - завантажити й застосувати скрипт чи таблицю стилів;
  • preloadModule / preinitModule - те саме для ES-модулів.

Як це працює: виклики можна робити прямо під час рендеру чи в обробниках подій. При серверному рендері React виводить відповідні <link rel="preload"> у <head> якомога раніше в потоці HTML, а повторні виклики для того самого ресурсу дедуплікуються.

Метадані й стилі прямо в компонентах:

function Article({ post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.excerpt} />
      <link rel="stylesheet" href="/css/article.css" precedence="default" />
      ...
    </article>
  );
}

React 19 піднімає <title>, <meta>, <link> у <head> документа. Таблиці стилів з precedence завантажуються до показу вмісту, що від них залежить (без «миготіння» нестилізованого вмісту), і вставляються в правильному порядку.

Практичні застосування:

  • зображення першого екрана (LCP) - preload з високим пріоритетом;
  • попереднє завантаження при наведенні: onMouseEnter={() => preload(nextPageImage, { as: 'image' })};
  • сторонні SDK (карти, оплата) - preconnect заздалегідь, preinit лише на сторінках, де потрібні.

Обережно: зайвий preload конкурує за пропускну здатність з дійсно критичними ресурсами. Попередньо завантажувати варто те, що точно знадобиться найближчим часом.

Докладніше в документації: API попереднього завантаження ресурсів