React.lazy і Suspense: як розбити бандл на розумні шматки

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт, спільното! Мене звати Михайло, я Front-end / Full Stack Engineer. Мій основний стек — React.js + Next.js.

React.lazy і Suspense часто звучать як магічні слова в розмовах про продуктивність. Типу як «Machine learning» або «Big Data» — всі кивають головою, але мало хто розуміє, що відбувається під капотом.

У цій статті я покажу, як React.lazy працює зсередини — на реальних прикладах із замірами та скріншотами.

Поясню, чому в Dev все здається «зламаним», а справжня магія відбувається лише в Production. Як саме React.lazy + Suspense розбиває JS bundle на чанки. Розберемо помилки, про які ви навіть не здогадувались, і розвіємо ілюзії про «продуктивність».

А заодно відповімо на питання: чи є сенс у React.lazy без Suspense? А в Suspense без React.lazy? Спойлер: один без іншого працювати не буде, але у кожного є свої додаткові застосування.

Готуйтесь пройти класичні стадії: заперечення («не може бути!»), гнів («чому ніхто про це не говорить?») і прийняття («тепер я знаю як це працює насправді»).

І звичайно ж стадію «торг»: «а може у мене особливий випадок?»

Важкий компонент та звичайний імпорт

Уявімо компонент HeavyChart, в який імпортовано купу різних бібліотек. Можете сказати, що приклад притягнутий за вуха, але ви почекайте — в реальних проєктах буває і гірше. Особливо якщо це legacy проєкт, де кожен розробник додавав свою улюблену бібліотеку. Там може бути і jQuery, і Lodash, і Moment.js одночасно — свята трійця важкості HeavyChart.

Вони займають загалом більше 1MB. Це як завантажити весь jQuery 20 разів підряд, або всю документацію React в одному файлі.

Якщо ми просто імпортуємо цей компонент і відрендеримо його:

import HeavyChart from './HeavyChart';

function App() {
  return <HeavyChart />;
}

Відкриємо DevTools → Network відфільтруємо по JS. Ми побачимо один гігантський JS-бандл — bundle.js (в нашому випадку: 1.03 MB). Все завантажується відразу, незалежно від того, буде компонент відрендерений чи ні (dev збірка).

так так саме так

Браузер може завантажити весь JS-бандл, але це не означає, що всі компоненти будуть негайно відрендерені. Рендеринг залежить від умов, станів, маршрутів та ледачого завантаження.

Розбиття на дрібні компоненти

Ну, нічого дивного, скажете ви, а ти спробуй розбий на різні більш дрібні компоненти! Ми так на проєкті робимо: одна сторінка, один компонент, де все збирається. І знову відкрий Network. React же про компонентний підхід, правда?

Давайте зробимо так і подивимось в Network:

import ChartComponent from './ChartComponent';
import DataProcessor from './DataProcessor';
import 3DVisualization from './3DVisualization';

function Dashboard() {
  return (
    <div>
      <ChartComponent />
      <DataProcessor />
      <3DVisualization />
    </div>
  );
}

Відкриваємо Network і...

Але в Network нічого не змінюється. Чому?

Тому що:

  • Всі ці компоненти імпортуються синхронно в одному і тому ж файлі
  • .
  • Розділення по файлах не дорівнює розбиттю чанків
  • .

Тобто в плані initial load та JS-завантаження нічого не виграється. Навіть якщо компонентний підхід покращує читабельність (що саме по собі важливо!), на продуктивність він не впливає.

Аргументи, які ви точно почуєте (і чому вони не працюють) 🎭

«Кожна частина UI у своєму компоненті. Компонент за компонентом — зручно підтримувати»

  • Це про архітектуру, а не оптимізацію продуктивності.
  • Це як пофарбувати машину в красивий колір і чекати, що вона поїде швидше.

«Якщо треба буде — легко обгорнемо в React.lazy»

Потрібно переконатись, що компонент:

  • повинен бути поза потоком рендеру — якщо він рендериться за замовчуванням, він все одно потрапить в початковий бандл.
  • не передається в props, children.
  • не бере участь в layout до користувацької дії (інакше ми поламаємо UI при умовному рендері).

«У нас в компоненті є useEffect(fetch) — значить дані прийдуть пізніше, а UI з’явиться раніше»

Це вже UX-фактор, а не оптимізація завантаження JavaScript.

Так, UI може з’явитись трохи раніше — але:

  • Це не зменшує initial JS.
  • Важкі бібліотеки все одно будуть завантажені та розпарсені браузером.

Наприклад, fetch поверне JSON за 200мс, а ось chart.js, d3, moment — будуть завантажуватись і парситись 1–2 секунди.

Навіть якщо API відповідає швидко, користувач не побачить результат, поки не завантажиться і не виконається компонент, який повинен відобразити ці дані. React.lazy дозволяє відкласти завантаження важких компонентів, щоб UI не чекав відразу обидва ресурси — і дані, і JavaScript.

А тепер — що ми побачимо після використання React.lazy + Suspense

Перепишемо наш основний файл з використанням React.lazy + Suspense для нашого HeavyComponent:

import { Suspense, lazy } from 'react';

const LazyHeavyChart = lazy(() => import('./HeavyChart'));

function App() {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <LazyHeavyChart />
    </Suspense>
  );
}

Відкриємо Network і побачимо:

  • Основний бандл (bundle.js) — завжди завантажується.
  • Окремий чанк з компонентом HeavyChart — завантажується лише при рендері.
  • Загальні бібліотеки (lodash, moment, chart.js, d3) — Webpack вирізає їх в окремий чанк.

— Супер! — скажете ви.

І тут починається найцікавіше...

Але чи є в цьому користь?

Що відбувається при React.lazy і першому рендері:

➕ Webpack ділить код на чанки.

➕ main.js стає легшим.

➖Але якщо компонент все одно рендериться, браузер завантажить обидва чанки одночасно.

Сумарний initial JS буде майже тим же самим, що і при звичайному import.

Це як перекласти речі з однієї валізи в дві, але нести їх одночасно — вага та сама, але виглядає акуратніше.

Підсумок:

React.lazy корисний навіть для компонентів, які відображаються на першому екрані — тому що він виносить їх в окремий чанк, знижуючи розмір main.js. Однак щоб отримати справжнє відкладене завантаження і покращити перформанс користувача, компонент повинен рендеритись по події або по умові, а не відразу.

Правильне використання або як не вистрелити собі в ногу

Давайте тепер все-таки зробимо так, щоб витягти максимальну користь від React.lazy + Suspense. Перепишемо наш Dashboard-компонент, щоб він обгортав в Suspense-використання кожного окремого компонента з відкладеним завантаженням:

import { Suspense, lazy, useState } from 'react';

const LazyChart = lazy(() => import('./ChartComponent'));
const LazyDataProcessor = lazy(() => import('./DataProcessor'));
const Lazy3D = lazy(() => import('./3DVisualization'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  const [showProcessor, setShowProcessor] = useState(false);
  const [show3D, setShow3D] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      <button onClick={() => setShowProcessor(true)}>Show Data Processor</button>
      <button onClick={() => setShow3D(true)}>Show 3D</button>

      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <LazyChart />
        </Suspense>
      )}

      {showProcessor && (
        <Suspense fallback={<div>Loading processor...</div>}>
          <LazyDataProcessor />
        </Suspense>
      )}

      {show3D && (
        <Suspense fallback={<div>Loading 3D...</div>}>
          <Lazy3D />
        </Suspense>
      )}
    </div>
  );
}  

Все, що ми бачимо — це початково розвантажений bundle.js, який раніше важив більше 1Mb (все ще dev режим).

Давайте тепер натиснемо на кнопку «Show chart».

Завантаження цих файлів відбувається окремо та асинхронно завдяки React.lazy.

Тепер натиснемо на «Show 3D»:

І нарешті «Show Data Processor»:

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

Продуктивність покращується не тоді, коли ви ділите код на компоненти, а коли ви контролюєте, коли ці компоненти реально завантажуються в браузері. Ось тут вступають в гру React.lazy, Suspense, next/dynamic, а також роутинг.

Особливо спостережливі можуть помітити, що у мене більшість розбитих після React.lazy чанків важать 0.3kB, але як так вийшло, якщо спочатку був 1Mb?

Це не магія, це просто dev-режим тролить нас.

Чому в Dev-збірці чанки були 0.3 KB

Якщо ви уважно стежили за Network tab, могли помітити: багато чанків, типу LazyChartComponent.chunk.js, важили підозріло мало — по 0.3 KB, хоча в сумі все застосунок займав більше мегабайта.

Так чому так відбувається?

Development vs Production

Development (npm start):

  • Не мініфікований код.
  • Source maps + HMR (hot module replacement).
  • Webpack DevServer використовує заглушки.
  • Розміри файлів відображаються неточно.

Production (npm run build + npx serve -s build):

  • Мініфікація, tree shaking.
  • Розміри JS-файлів — справжні.
  • Браузер завантажує лише те, що реально використовується.

npx serve -s build — це простий спосіб локально запустити production-збірку вашого React-застосунку. Він запускає статичний сервер, який віддає вміст папки build, як це робить реальний хостинг. Таким чином можна перевірити справжні розміри та поведінку чанків після збірки.

Приклад з Network після npx serve -s build:

Dev:

  • main.js — 228 KB
  • LazyComponent.chunk.js — 0.3 KB (фіктивно)

Prod:

  • main.50fa7e0c.js — 60 KB
  • 408.f85503.chunk.js — 122 KB
  • 571.8f5de.chunk.js — 44 KB
  • і так далі...

В Production видно справжнє розбиття: main.js став легким, важкі компоненти винесені в окремі чанки.

Це і є реальна користь від React.lazy + відкладеного рендеру: ви не платите за JS, який не використовуєте відразу, і завантажуєте важкі залежності лише тоді, коли вони дійсно потрібні.

Як в ресторані «все включено» — можете взяти все, але краще брати лише те, що будете їсти.

Чому я всю статтю показував результати в dev-збірці

Сповідь автора... Тому що саме в Dev-режимі більшість розробників знаходяться кожен день. Спочатку я хотів показати, чому нічого не змінюється, навіть якщо ви ділите код на компоненти.

А вже після — ми перейшли до Production-збірки, щоб показати реальну користь від ледачого завантаження.

React.lazy без Suspense і Suspense без React.lazy

Перш ніж закінчити, давайте розберемо два питання:

➖ React.lazy без Suspense — не працює

const LazyComponent = lazy(() => import('./Component')); 

function App() {
   return <LazyComponent />; // Немає Suspense!
}

Що відбувається:

  • React.lazy повертає компонент, який асинхронно завантажує код.
  • Поки код завантажується, React потрібно щось показати користувачу.
  • Без Suspense React не знає, що відрендерити, і викидає помилку.
  • Це як замовити піцу, але не сказати адресу доставки.

Помилка: A React component suspended while rendering, but no fallback UI was specified

➕ Suspense без React.lazy — працює, і це корисно.

Suspense можна використовувати не лише з React.lazy, але й з іншими речами, які викидають проміс. Наприклад:

  • Завантаження даних через use() (в React 18+).
  • Використання Suspense сумісних бібліотек, наприклад React Query.
  • Відкладені компоненти з динамічним імпортом.

Приклад:

function DataComponent() {
  const data = use(fetchUserData()); // React 18+ API
  return <div>{data.name}</div>;
}

function App() {
  return (

    <Suspense fallback={<div>Loading user...</div>}>
      <DataComponent />
    </Suspense>
  );
}

Це дозволяє показати користувачу індикатор завантаження, поки дані або компонент ще не готові. Навіть без React.lazy.

Suspense — це як ввічливий офіціант, який говорить «ваше замовлення готується» замість того, щоб мовчки стояти біля столика.

Технічна деталь для допитливих

Щоб Suspense спрацював — проміс повинен бути викинутий під час рендеру, а не як ефект після рендеру. Це фундаментальна відмінність.

Коли ви пишете:

const LazyComponent = React.lazy(() => import('./HeavyComponent'));

React отримує функцію, яка повертає проміс. І при першому рендері, коли React намагається відрендерити LazyComponent, він внутрішньо «викидає» цей проміс (throw promise).

Висновок

React.lazy і Suspense — це не просто модні словечки для враження колег. Це реальні інструменти для оптимізації, які:

  • Працюють — але лише при правильному використанні.
  • Покращують UX — користувач не чекає код, який не потрібен зараз.
  • Знижують initial load — особливо помітно на повільних з’єднаннях.
  • Масштабуються — чим більше застосунок, тим більша вигода.
  • Не працюють магічно — просто розділити файли недостатньо.
  • Не завжди потрібні — для маленьких застосунків можуть бути оверхедом.
  • Не замінюють інші оптимізації — мініфікація, gzip, CDN все ще важливі.

Пам’ятайте: найкраща оптимізація — це та, яка дійсно вирішує проблему користувачів, а не просто красиво виглядає в коді.

Практичні поради на дорогу:

  1. Тестуйте в Production — dev-режим бреше про розміри.
  2. Використовуйте з умовним рендерингом — максимальна користь від відкладеного завантаження.

І не забувайте: код повинен не лише працювати, але й працювати швидко. А ще краще — працювати швидко і приносити радість розробникам.

Чекайте другу частину про порівняння цього підходу та dynamic import в Next.js! В наступній статті розберемо, як Next.js вирішує ті ж самі задачі з next/dynamic, порівняємо продуктивність та зручність використання, а також з’ясуємо, коли використовувати що. Спойлер: Next.js має кілька хитрих трюків у рукаві!

👍ПодобаєтьсяСподобалось21
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Коли проблему можна вирішити через React.lazy() це супер. А ось коли потрібно лейзілоаднути якісь методи з ліби, а не компонент... ось там починається справжній содом

Дякую за коментар)
Повністю згоден, для таких випадків може підійти dynamic import. Його можна обернути у кастомний React Hook, щоб отримати стан завантаження та дані. Але за умови великої кількості таких компонентів, треба бути оборежним)

Читайте мою наступну статтю про next/dynamic в Next.js яка вже є на сторінкці) dou.ua/forums/topic/55221