Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Стрімінг та завантаження інтерфейсу в Next.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Стрімінг (streaming) у Next.js** відправляє HTML браузеру частинами, коли кожна частина готова, зменшуючи TTFB замість очікування на всі дані. ```tsx // app/dashboard/loading.tsx export default function Loading() { return <div className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> } ``` `loading.tsx` для простих сторінок. Для дашбордів з кількома джерелами даних загортай кожен повільний компонент у `Suspense`, щоб секції з'являлися незалежно. **Ключове:** `loading.tsx` = один fallback на весь маршрут. `Suspense` = контроль стрімінгу на рівні компонента.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Стрімінг (streaming) у Next.js** відправляє HTML браузеру частинами, коли кожна частина готова. Користувач бачить контент поступово, а не чекає на порожній екран. ## Теорія ### TL;DR - Без стрімінгу: сервер чекає на всі дані, потім відправляє повний HTML. TTFB може досягати кількох секунд. - Зі стрімінгом: скелет сторінки приходить одразу, дані підвантажуються по мірі готовності запитів. - `loading.tsx` = один fallback на всю сторінку, без додаткового налаштування. - `Suspense` = незалежні fallback'и для кожного компонента, потребує ручного обгортання. - Під капотом: одне HTTP-з'єднання залишається відкритим, сервер надсилає чанки через невеликі `<script>`-теги. ### Швидкий приклад ```tsx // app/problems/loading.tsx // Next.js підхопить цей файл автоматично export default function Loading() { return ( <div className="space-y-4"> {[...Array(5)].map((_, i) => ( <div key={i} className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> ))} </div> ) } ``` Поклади цей файл поруч із `page.tsx`. Next.js загорне сторінку в `Suspense` з цим компонентом як fallback. На цьому налаштування закінчується. ### Чому традиційний SSR блокує сторінку Типовий дашборд звертається до трьох джерел: запит до бази для статистики користувача, API-виклик для останньої активності, ще один запит для прогресу по дорожній карті. Навіть якщо запустити їх паралельно через `Promise.all`, сервер однаково чекатиме на найповільніший перед тим, як відправити хоч байт HTML. Найповільніший запит визначає TTFB всієї сторінки. Одна повільна залежність затримує все. ### Як стрімінг змінює час відповіді Next.js відправляє оболонку сторінки (layout, навігація, скелети) одразу, як тільки React починає рендерити. Кожна границя `Suspense` - це точка фlushing'у. Коли дані за цією границею готові, сервер надсилає невеликий скрипт, який замінює скелет на реальний контент. Користувач бачить щось корисне вже за 200ms. Важкі запити виконуються у фоні. ### Suspense для тонкого контролю `loading.tsx` трактує всю сторінку як одну границю. Для простих сторінок з одним джерелом даних це нормально. Але для дашбордів один повільний запит блокує весь підхід зі скелетом. Загортай кожен повільний компонент у власний `Suspense`: ```tsx // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div> <h1>Дашборд</h1> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* запит 2с */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* запит 800ms */} </Suspense> <Suspense fallback={<ProgressSkeleton />}> <RoadmapProgress /> {/* запит 1.5с */} </Suspense> </div> ) } ``` `RecentActivity` завершується першим і рендериться приблизно на 800ms. `UserStats` і `RoadmapProgress` з'являються коли їхні запити вирішуються. Жодна границя не чекає на іншу. ### Як це працює під капотом Браузер відкриває одне HTTP-з'єднання до сервера. Сервер відправляє початковий HTML зі скелетами-заглушками і тримає з'єднання живим. Коли запит вирішується, сервер відправляє `<script>`-тег: ```tsx // 1. Початковий HTML, який отримує браузер <div id="$stats"><!-- StatsSkeleton HTML --></div> // 2. Сервер надсилає цей чанк приблизно через 800ms <div hidden id="S:1"> <div>Вирішено задач: 42</div> </div> <script>$RC("$stats", "S:1")</script> ``` `$RC` - внутрішня функція React для заміни заглушки. Браузер не робить жодного додаткового запиту. Я перевіряв це у вкладці Network: один довгий запит, кілька чанків даних що приходять у різний час. ### loading.tsx проти Suspense | | loading.tsx | Suspense | |---|---|---| | Область | Вся сторінка (page.tsx) | Окремий компонент | | Гранулярність | Один fallback на сторінку | Кілька незалежних fallback'ів | | Налаштування | Автоматичне (файлова конвенція) | Ручне | | Підходить для | Прості сторінки, одне джерело даних | Дашборди, кілька повільних запитів | Обидва використовують однаковий механізм всередині. `loading.tsx` - це файловий shortcut, який Next.js перетворює на границю `Suspense` на рівні маршруту. ### Типові помилки **Загортати все в одну границю.** Якщо обгорнути весь дашборд одним `Suspense`, повертаємось до тієї ж проблеми. Швидкі компоненти чекатимуть на повільні. ```tsx // Погано: одна границя, одне очікування для всього <Suspense fallback={<DashboardSkeleton />}> <UserStats /> <RecentActivity /> <RoadmapProgress /> </Suspense> // Краще: кожен компонент стрімить незалежно <Suspense fallback={<StatsSkeleton />}><UserStats /></Suspense> <Suspense fallback={<ActivitySkeleton />}><RecentActivity /></Suspense> <Suspense fallback={<ProgressSkeleton />}><RoadmapProgress /></Suspense> ``` **Використовувати `loading.tsx` для дашбордів.** Він покриває весь маршрут. Якщо один блок завантажується 3 секунди, користувач бачить повний скелет 3 секунди, навіть якщо два інших блоки були готові вже на 500ms. **Стрімінг із клієнтськими компонентами.** Патерн працює тільки якщо компонент сам очікує даних на сервері: ```tsx // Стрімить коректно - async серверний компонент async function UserStats() { const stats = await db.user.getStats(userId) return <div>{stats.problemsSolved}</div> } // Не стрімить - клієнтський компонент отримує дані після гідратації 'use client' function UserStats() { const [stats, setStats] = useState(null) useEffect(() => { fetch('/api/stats').then(r => r.json()).then(setStats) }, []) return stats ? <div>{stats.problemsSolved}</div> : null } ``` Клієнтський варіант викликає waterfall: сторінка завантажується, гідратується, потім робить запит. Жодного скелета під час реального отримання даних. ### Де зустрічається в реальних проєктах - Продуктові дашборди: кожен віджет отримує власну границю `Suspense`, оболонка з'являється миттєво. - Блог з коментарями: стаття рендериться відразу, секція коментарів завантажується у фоні. - Сторінка товару в e-commerce: деталі товару стрімляться першими, відгуки завантажуються незалежно. - Будь-який маршрут де один запит повільніший за інші: виноси той компонент за `Suspense`. ### Питання на співбесіді **Q:** Чи працює стрімінг із `getServerSideProps`? **A:** Ні. Стрімінг потребує App Router. `getServerSideProps` - це Pages Router, він не підтримує границі `Suspense` таким чином. **Q:** Що станеться, якщо async компонент кидає помилку під час стрімінгу? **A:** React шукає найближчу границю `error.tsx`. Якщо вона є поруч із `page.tsx`, вона перехопить помилку. Без неї помилка може зіпсувати вже надіслану оболонку у браузері. **Q:** Чи можна вкладати `Suspense` одне в одне? **A:** Так. Внутрішні границі вирішуються і стрімляться першими. Зовнішні перехоплюють компоненти без внутрішньої границі. Вкладеність необмежена, але варто тримати її навмисною. **Q:** Як стрімінг впливає на SEO? **A:** Googlebot добре обробляє стрімінг. Повний HTML присутній у тілі відповіді, просто доставляється частинами. Контент, відрендерений на сервері таким чином, індексується. **Q:** У чому різниця між стрімінгом та ISR? **A:** ISR кешує відрендерений HTML і ревалідує його за розкладом, це зменшує навантаження на сервер. Стрімінг генерує свіжий HTML на кожен запит, але відправляє його поступово, що зменшує TTFB для динамічних сторінок. Вони вирішують різні проблеми. ## Приклади ### Базовий: loading.tsx для списку задач ```tsx // app/problems/loading.tsx export default function Loading() { return ( <div className="space-y-4"> {[...Array(5)].map((_, i) => ( <div key={i} className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> ))} </div> ) } // app/problems/page.tsx export default async function ProblemsPage() { const problems = await db.problems.findMany() // може займати 1-2с return ( <ul> {problems.map(p => <li key={p.id}>{p.title}</li>)} </ul> ) } ``` Користувач одразу бачить п'ять скелетних рядків. Коли запит до бази повертає результат, реальний список замінює їх. Жодного додаткового налаштування. ### Середній рівень: дашборд з незалежними границями ```tsx // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div className="grid gap-6"> <Suspense fallback={<StatsSkeleton />}> <UserStats /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> </div> ) } async function UserStats() { const stats = await db.users.getStats() // 2с return <div>Вирішено задач: {stats.count}</div> } async function RecentActivity() { const activity = await db.activity.recent() // 500ms return <ul>{activity.map(a => <li key={a.id}>{a.label}</li>)}</ul> } ``` `RecentActivity` з'являється на ~500ms. `UserStats` - на ~2с. Без окремих границь обидва чекали б повні 2 секунди. ### Senior: обробка помилок під час стрімінгу ```tsx // app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error reset: () => void }) { return ( <div> <p>Не вдалось завантажити цю секцію.</p> <button onClick={reset}>Спробувати ще раз</button> </div> ) } // app/dashboard/page.tsx export default function DashboardPage() { return ( <div> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* якщо кинуть помилку, error.tsx перехопить */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* рендериться нормально незважаючи ні на що */} </Suspense> </div> ) } ``` Якщо `UserStats` кидає помилку під час стрімінгу, `error.tsx` перехоплює її і показує кнопку повтору. `RecentActivity` знаходиться в окремій границі і рендериться нормально. Один невдалий запит не ламає решту сторінки.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.