Skip to main content

Що таке прогресивний рендеринг у веб-розробці

Прогресивний рендеринг (progressive rendering), це підхід, при якому контент сторінки з'являється по мірі готовності, а не після завантаження всього документа.

Теорія

TL;DR

  • Аналогія: газета, яка друкується посторінково. Заголовок читаєш одразу, поки 10-та сторінка ще в роботі
  • Головна ідея: спочатку малюємо контент вище згину, решту відкладаємо
  • Скорочує Time to Interactive на 50-70% на повільних мережах
  • Використовуй, якщо FCP перевищує 1с або мобільні користувачі йдуть зі сторінки. Пропускай для сторінок менше 100KB
  • Основні інструменти: loading="lazy", IntersectionObserver, React Suspense, SSR streaming

Швидкий приклад

html
<!-- Вище згину: завантажуємо одразу --> <img src="hero.jpg" fetchpriority="high" width="1200" height="600" /> <!-- Нижче згину: відкладаємо до прокрутки --> <img loading="lazy" src="product.jpg" width="300" height="300" alt="Продукт" />
jsx
// React: показуємо skeleton, рендеримо компонент коли дані прийшли <Suspense fallback={<Skeleton />}> <HeavyDashboard /> </Suspense>

Браузер малює hero-зображення відразу. Зображення продукту підтягується лише тоді, коли користувач прокручує до нього. React-компонент показує skeleton до того, як дані будуть готові. Три різні інструменти, один результат.

Головна відмінність від звичайного завантаження

При стандартному завантаженні браузер чекає на повну HTML-відповідь, перш ніж намалювати щось корисне. При прогресивному рендерингу він починає малювати DOM-вузли одразу, як тільки HTML-парсер отримує перші чанки. Preloader thread паралельно сканує потік наперед у пошуку тегів <script>, <link> і <img>, щоб запустити завантаження ресурсів раніше (HTML spec, §12.2.6). Результат: користувач бачить реальний контент за 200ms замість порожнього екрана протягом 2-3 секунд.

Чотири основні техніки

Ліниве завантаження (lazy loading) відкладає зображення, відео і компоненти до моменту, коли вони потрапляють у viewport. Нативний атрибут loading="lazy" покриває більшість кейсів у Chrome 76+. Для компонентів у React: React.lazy() з Suspense.

SSR streaming надсилає HTML з сервера частинами. React 18 через renderToPipeableStream і Next.js App Router підтримують цей підхід. Браузер рендерить перший чанк, поки сервер ще генерує решту. Типовий приклад: шапка дашборду з профілем приходить за 200ms, стрічка постів за 800ms.

Skeleton screens і плейсхолдери замінюють порожнє місце приблизними контурами майбутнього контенту. Це стабілізує макет і дає користувачу сигнал, що завантаження відбувається. Без стрибків контенту, без порожнього екрана.

Прогресивна гідратація (progressive hydration) підключає JavaScript-поведінку тільки до тих частин сторінки, які потрібні користувачу першими. <Script strategy="lazyOnload" /> у Next.js запускає сторонні скрипти після того, як сторінка вже стала інтерактивною.

Як це працює всередині браузера

HTML-парсер обробляє байти по мірі надходження і будує DOM поступово. Вузли передаються рендереру, як тільки вони в повному стані. Preloader thread паралельно сканує вперед у пошуку <script>, <link> і <img>, щоб запустити завантаження ресурсів раніше.

У React 18 межі Suspense дозволяють серверу пропустити піддерево, яке ще не готове, надіслати решту HTML, а потім стрімнути пропущений шматок окремим чанком, коли його дані вирішаться. Клієнт гідратує кожен чанк незалежно. Саме тому шапка може бути вже робочою, поки стрічка ще завантажується.

Коли використовувати

  • E-commerce сітки з 50+ зображеннями: loading="lazy" на все, що нижче згину
  • Дашборди з незалежними джерелами даних: Suspense навколо кожного віджета, щоб швидкі дані з'являлися одразу
  • Контентні сайти на Next.js: streaming через App Router скорочує FCP з 3с до менше 1с
  • SPA-додатки з 50+ компонентами: React.lazy + code splitting тримають початковий бандл малим
  • Пропускай для статичних сторінок менше 100KB і сторінок, де SEO-краулеру потрібен повний HTML без SSR

Типові помилки

1. loading="lazy" на hero-зображення.

html
<!-- Неправильно: затримує LCP на 2+ секунди --> <img loading="lazy" src="hero.jpg" /> <!-- Правильно: кажемо браузеру, що це зображення важливе --> <img fetchpriority="high" src="hero.jpg" />

loading="lazy" прибирає зображення зі сканера попереднього завантаження. Для hero це рівно протилежне тому, що потрібно.

2. React.lazy без межі Suspense.

jsx
// Неправильно: unhandled promise rejection, білий екран на 3с const Chart = React.lazy(() => import('./Chart')); <Chart /> // Правильно <Suspense fallback={<Spinner />}> <Chart /> </Suspense>

Без межі відхилений Promise спливає вгору і ламає дерево рендерингу.

3. SSR streaming без обробки помилок.

tsx
// Неправильно: запит падає посеред стріму, Node некоректно обриває з'єднання async function UserData() { const data = await fetch('/api/user').then(r => r.json()); return <div>{data.name}</div>; } // Правильно: огорни в error.js (Next.js) або ReactErrorBoundary

До моменту помилки частина HTML вже надіслана клієнту. Без error boundary клієнт отримує зламану розмітку і гідратація падає.

4. CLS від контенту, що з'являється пізно.

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

css
img { width: 100%; aspect-ratio: 16 / 9; /* тримає місце до завантаження */ }

Або встановлюй явні width і height на тегах <img>. Обидва підходи дають браузеру інформацію про розмір до того, як зображення прийде, і тримають CLS нижче 0.1.

5. Несумісність гідратації з lazy-зображеннями в SSR.

jsx
// Неправильно: сервер рендерить без атрибута, клієнт додає loading="lazy" function Post({ post }) { return <img loading="lazy" src={post.image} />; } // React 18 виводить попередження і перерендерює повністю, // губляться переваги streaming // Правильно: next/image генерує однаковий вивід на сервері і клієнті import Image from 'next/image'; <Image src={post.image} priority={false} />

Де застосовується

  • Next.js 14 App Router: Suspense + React Server Components стрімлять RSC-пейлоади. Дашборд Vercel побудований на цьому підході
  • Remix v2: defer() стрімить повільні дані без блокування швидких на тому самому роуті
  • SvelteKit 2: функції load стрімлять чанки, популярно на контентних сайтах
  • Shopify Hydrogen: побудований на streaming-моделі Remix для сторінок продуктів
  • YouTube: IntersectionObserver + loading="lazy" на сітках мініатюр
  • Express / Node.js: res.write() стрімить HTML до того, як всі дані готові

Питання на співбесіді

Q: Яка різниця між прогресивним рендерингом і code splitting?
A: Code splitting ділить JS на окремі бандли, якими керує webpack або Vite. Прогресивний рендеринг контролює, коли контент з'являється у DOM. Разом вони працюють через React.lazy(): він ділить бандл, Suspense прогресивно рендерить результат.

Q: Як прогресивний рендеринг впливає на Core Web Vitals?
A: При правильному підході скорочує FCP і LCP, стрімлячи критичний HTML раніше. Головний ризик: CLS. Якщо вставляти контент без зарезервованого місця, макет стрибатиме і цей показник погіршиться.

Q: Як реалізувати lazy loading зображень без нативного loading="lazy"?
A: Через IntersectionObserver. Спостерігаєш за елементом. Коли він входить у viewport, переносиш data-src у src. Працює в Chrome 58+, Firefox 55+ і будь-якому середовищі з підтримкою Observer API.

Q: Як React 18 streaming поводиться з межею Suspense, яка так і не вирішується?
A: Сервер тримає стрім відкритим до налаштованого тайм-ауту, потім надсилає те, що готово. У Next.js це налаштовується через loading.js. Невирішені межі надсилають HTML fallback, тому клієнт ніколи не зависне на порожній відповіді.

Q: Як SSR streaming у React Server Components взаємодіє з графом модулів під час збірки?
A: RSC-межі виступають точками розбиття стріму. Next.js 14 з Turbopack заздалегідь завантажує RSC-чанки у фоні, щоб сусідні модулі не блокували один одного. За внутрішніми бенчмарками Next.js це дало приблизно 35% приріст продуктивності порівняно з webpack.

Приклади

Базовий: ліниве завантаження сітки зображень

html
<!DOCTYPE html> <html> <head> <title>Сітка продуктів</title> </head> <body> <!-- Hero: малюємо одразу, резервуємо місце --> <img src="hero.jpg" fetchpriority="high" width="1200" height="600" alt="Розпродаж" /> <!-- Продукти: завантажуємо по мірі прокрутки --> <div class="grid"> <img loading="lazy" src="prod1.jpg" width="300" height="300" alt="Продукт 1" /> <img loading="lazy" src="prod2.jpg" width="300" height="300" alt="Продукт 2" /> <img loading="lazy" src="prod3.jpg" width="300" height="300" alt="Продукт 3" /> </div> </body> </html>

Hero має fetchpriority="high", тому браузер завантажує його першим. Зображення продуктів мають явні width і height, тому браузер резервує для них місце ще до завантаження. Без CLS, без затримки hero. Це мінімальна конфігурація для будь-якої сторінки з великою кількістю зображень.

Середній рівень: SSR streaming у Next.js App Router

tsx
// app/dashboard/page.tsx import { Suspense } from 'react'; async function Profile() { // Швидкий запит: ~100ms const user = await fetch('https://api.example.com/user').then(r => r.json()); return <div>{user.name}'s Dashboard</div>; } async function Posts() { // Повільний запит: ~900ms const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; } export default function Dashboard() { return ( <Suspense fallback={<div>Завантаження профілю...</div>}> <Profile /> <Suspense fallback={<div>Завантаження постів...</div>}> <Posts /> </Suspense> </Suspense> ); } // Результат: HTML профілю стрімиться за ~100ms, пости за ~900ms. // FCP: 0.8с проти 3.2с при блокуючому SSR.

Дві вкладені межі Suspense дозволяють кожному джерелу даних стрімитися незалежно. Профіль не чекає на API постів. Якщо запит постів падає, лише внутрішня межа показує помилку. Профіль залишається цілим. Цей патерн скорочує FCP дашборду вдвічі і більше.

Просунутий рівень: несумісність гідратації при SSR streaming

jsx
// Патерн, що викликає попередження гідратації React 18 у продакшені // Неправильно: сервер рендерить зображення без атрибута loading. // Клієнтський компонент додає loading="lazy" після монтування. // React бачить розбіжність і перерендерює весь піддерево. function PostCard({ post }) { return ( <Suspense fallback={<div>Завантаження...</div>}> {/* Сервер: немає loading. Клієнт: loading="lazy". Мismatch. */} <img loading="lazy" src={post.coverImage} alt={post.title} /> </Suspense> ); } // Console: Warning: Prop `loading` did not match. // React викидає стрімлений HTML і перерендерює з нуля. // Правильно: next/image генерує однаковий вивід на сервері і клієнті import Image from 'next/image'; function PostCard({ post }) { return ( <Image src={post.coverImage} alt={post.title} width={800} height={400} priority={false} // однаково на сервері і клієнті /> ); }

Несумісність виникає тому, що гідратація React порівнює серверний HTML з тим, що клієнт згенерував би самостійно. При розбіжності React викидає серверний HTML і перерендерює з нуля. Весь виграш від streaming для цього піддерева втрачається. next/image з priority={false} генерує однаковий вивід з обох боків, тому гідратація проходить без помилок.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?