Як припинити використання Request Waterfalls у SvelteKit за допомогою функцій Parallel load
Дізнайтеся, чому послідовні await уповільнюють сторінки, та як SvelteKit використовує функції завантаження, Promise.all, обережні виклики parent() та потокові обіцянки для усунення ефекту «водоспаду».
Джерела: Killing Request Waterfalls: Server-Side Data Fetching in SvelteKit
Сторінка може виглядати повільною навіть при швидкому з’єднанні та швидкому бекенді, якщо її запити виконуються один за одним, а не паралельно. Кожен цикл обробки чекає на завершення попереднього, тому час отримання першого значущого контенту стає сумою часів усіх запитів, а не тривалістю найповільнішого з них. У цьому посібнику пояснюється, звідки береться цей ефект «водоспаду», та показано, як структурувати функції load у SvelteKit так, щоб критично важливі дані надходили паралельно, розмітка та завантаження сторінки не блокували одне одного, а повільні, неважливі дані надходили після першого відображення.
Як виглядає «водоспад» запитів
Відкрийте панель Мережа у інструментах розробника браузера на типовій панелі керування React чи Vue, створеній клієнтом, та завантажте сторінку, якій потрібно кілька даних. Стовпчики часто утворюють схожість із сходами: спочатку виконується перше запитання, лише після цього починається друге, а третє чекає на друге. Кожен крок додає один повний цикл мережевих операцій.
Код, який стоїть за цією схожістю із сходами, зазвичай виглядає безневинно. Компонент завантажується в браузері, і лише тоді з’ясовує, що йому потрібно. Усередині функції effect чи гука mount виконується щось на кшталт const user = await getUser(), потім const posts = await getPosts(), а далі const comments = await getComments(). Три рядки, три запити типу await — нічого очевидно неправильного.
Проблема полягає у тому, що означає await. Він призупиняє виконання функції до тих пір, поки обіцянка не буде виконана, тож запит posts навіть не може бути надісланий, поки повна відповідь від user не надійде, а comments у свою чергу чекає на posts. Припустимо, що кожен виклик займає близько 300 мс, якщо врахувати роботу DNS, TLS, сервера та передачу даних. Тоді сторінка не має нічого корисного для відображення протягом приблизно загального часу всіх трьох операцій, а не протягом часу найдовшої з них.
Ніщо не викидає помилок, жоден тест не провалюється, і кожен компонент робить саме те, що йому було сказано. Користувачі просто дивляться на індикатор завантаження приблизно в три рази довше, ніж це необхідно. Саме ця невидимість робить підхід типу «водоспад» настільки поширеним.
Чому підхід типу «водоспад» продовжує з’являтися
Більшість з них виникають через три звичні привички:
- Кожен компонент отримує власні дані. У додатках, які рендеруються на клієнті, батьківський компонент часто мусить завершити завантаження, перш ніж дочірній зможе бути створений, а дочірній не може розпочати власний запит, поки не отримає параметр від батьківського компонента. Дерево компонентів перетворюється на ланцюг запитів.
- Чекання рядок за рядком через звичку. Послідовні оператори
awaitвиглядають зрозуміло, і саме тому вони приховують той факт, що вони серіалізують запити, які ніколи не залежали один від одного. - Немає єдиного погляду на потреби даних сторінки. Коли код завантаження розташований у трьох файлах, важко помітити, що всі три запити могли б бути розпочаті одночасно.
Написання швидшого коду не допомагає. Допомагає перенесення процедури отримання даних у інше місце та в інший час: до створення будь-якого компонента, у єдиному координованому місці. У SvelteKit цим місцем є функція load. Щоб побачити варіанти паралельної обробки без урахування конкретної фреймворку, перегляньте порівняння у блозі Promise.all, Promise.race та sequential awaits.
Як функції load у SvelteKit змінюють стандартну поведінку
Будь-який маршрут SvelteKit може експортувати функцію load, яка виконується перед відображенням компонента сторінки. Компоненти більше не отримують дані після монтажу; маршрут спочатку збирає всі дані та передає їх сторінці як один атрибут data.
Існує два варіанти, і вибір між ними має значення:
- Універсальне навантаження у
+page.jsвиконується на сервері під час серверного рендерингу та в браузері під час клієнтського навігування. Воно підходить для викликів публічних API та повернення значень, які не піддаються серіалізації. - Навантаження на сервер у
+page.server.jsвиконується лише на сервері. Його код ніколи не надсилається до браузера, тому він може безпечно використовувати клієнтів баз даних, приватні змінні середовища та токени автентифікації.
Лейаути також дотримуються цього ж паттерну за допомогою +layout.js та +layout.server.js.
У більшості реальних додатків все, що стосується бази даних, внутрішнього сервісу чи секретних даних, має знаходитися у +page.server.js. Його мінімальна форма виглядає так:
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
const res = await fetch('/api/user');
const user = await res.json();
return {
user
};
}
Є три моменти щодо цього фрагмента, які варто засвоїти якомога раніше.
Функція навантаження завершується до того, як сторінка буде рендерована
До моменту, коли починається відображення +page.svelte, user вже є звичайним об’єктом. Не існує гачка mount, жодного індикатора завантаження чи моменту, коли значення дорівнює undefined.
Використовуйте fetch, який надає SvelteKit
Аргумент fetch — це не глобальний fetch. SvelteKit пропонує покращену версію, яка приймає відносні URL-адреси на кшталт /api/user, передає куки та заголовки вхідного запиту під час серверної обробки та, коли метою є інший шлях у тій самій додатку, безпосередньо викликає відповідний обробник, замість того щоб виконувати справжній HTTP-запит. Імпорт глобального fetch позбавляє нас усього цього.
Значення, яке повертається, стає властивістю data сторінки
Будь-яке значення, яке повертає функція, стає доступним для компонента сторінки як data. У компоненті Svelte 5 його отримують за допомогою let { data } = $props(); (у старішому коді Svelte 4 використовується export let data;), а потім відображають data.user.name у маркапі.
Концепція проста: спочатку отримується дані, потім відображається контент, і компонент отримує готові значення замість обіцянок, якими потрібно керувати всередині компонента.
load саме по собі не усуває проблему послідовних викликів. Три послідовні операції await усередині load знову створюють таку саму послідовність операцій на сервері. Справжнє рішення приходить далі.
Початок виконання кожного незалежного запиту одночасно
Рішення полягає у негайному відправленні всіх незалежних запитів та спільному очікуванні їх результатів:
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
// Kick off all three requests immediately - none of them
// are awaited yet, so none of them block each other.
const userPromise = fetch('/api/user').then(r => r.json());
const postsPromise = fetch('/api/posts').then(r => r.json());
const commentsPromise = fetch('/api/comments').then(r => r.json());
// NOW wait for all of them to finish, in parallel.
const [user, posts, comments] = await Promise.all([
userPromise,
postsPromise,
commentsPromise
]);
return { user, posts, comments };
}
Чому це швидше
Вирішальним фактором є інтервал між надсиланням запиту та очікуванням його результату. Виклик fetch(...) негайно надсилає запит; він не чекає на await. Додавання .then() лише описує, що робити з відповіддю, коли вона нарешті надійде. Отже, у наведеному вище коді:
- перший рядок надсилає запит
/api/user; - другий рядок надсилає запит
/api/posts, поки перший ще у процесі виконання; - третій рядок надсилає запит
/api/comments, поки два попередні ще у процесі виконання; - лише
Promise.allнасправді зупиняється та продовжує роботу, як тільки завершиться найповільніший з трьох запитів.
Загальний час скорочується з суми трьох запитів до приблизно тривалості найповільнішого з них. Запити, сервер та дані залишаються незмінними; змінюється лише місце розташування оператора await. У ілюстративному прикладі, де кожен запит займає близько 300 мс плюс додаткові витрати, це дозволяє зробити сторінку придатною для використання протягом приблизно 960 мс, а потім — протягом приблизно 360 мс, причому різниця збільшується на повільних мережах або при активному використанні API.
Лише небагато змін у сторінці з великою кількістю даних можуть принести такий ефект при мінімальних зусиллях. Не потрібно додавати жодних бібліотек чи переробляти архітектуру; достатньо лише переупорядкувати кілька рядків коду.
На що звернути увагу під час використання Promise.all
Promise.all відхиляється як тільки будь-яке з його обіцянок відхиляється. Якщо невдалий запит comments не повинен зруйнувати всю сторінку, використовуйте Promise.allSettled або надайте кожній обіцянці власний метод .catch(), який повертає запасне значення. Також зверніть увагу, що r.json() не перевіряє значення r.ok; відповідь 404 або 500 із тілом помилки у форматі JSON буде оброблена та повернута, ніби це дані, тому перевіряйте статус, коли від цього залежить коректність.
Прихована послідовність дій між лейаутами та сторінками
Розробники, які опанували техніку Promise.all, часто все одно створюють ще один тип послідовності дій, яка охоплює файли: тобто як завантаження даних лейауту взаємодіє з завантаженням даних сторінки під ним.
За замовчуванням SvelteKit запускає функції load з файлів +layout.server.js та +page.server.js одночасно, так само як у прикладі з паралельним виконанням вище. Вихідний механізм — це функція parent(), яка надає функції load сторінки доступ до даних, повернутих верхніми лейаутами. Іноді саме це і потрібно, наприклад, коли запит сторінки вимагає ідентифікатора користувача, який може бути отриманий лише через лейаут. Однак якщо її викликати занадто рано, це призведе до серіалізації всього, що знаходиться після неї:
// src/routes/dashboard/+page.server.js
// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
const { user } = await parent(); // blocks here until layout's load finishes
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
return { posts };
}
Якщо запит на публікації дійсно потребує user.id, такий порядок є неминучим та цілком прийнятним; це справжня залежність. Проблема виникає, коли сторінці також потрібні дані, які не мають відношення до форматування. Очікування parent() у першому рядку затримує всі наступні операції, включаючи незалежні запити, доки не завершиться форматування.
Рішення полягає у зміні порядку: спочатку виконувати незалежні завдання, а parent() очікувати лише там, де потрібна його значення.
// src/routes/dashboard/+page.server.js
// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
const commentsPromise = fetch('/api/comments').then(r => r.json());
const { user } = await parent(); // runs concurrently with the fetch above
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
const comments = await commentsPromise;
return { user, posts, comments };
}
Тут запит на коментарі надсилається раніше, ніж сторінка чекає на форматування, тому вони перетинаються. Запит на публікації все одно мусить чекати на user.id, що є правильним, а обіцянка щодо коментарів зазвичай вже буде виконана до моменту її очікування наприкінці.
Основне правило: ставіться до await parent() як до будь-якого іншого await. Розміщуйте його саме там, де потрібне значення, ніколи не в кінці функції, та запускайте будь-які запити, які не залежать від даних parent, до нього.
Стрімування даних, які не є критичними
Promise.all не завжди є правильним рішенням. Якщо один запит працює повільно, а його дані не потрібні для першого того, що бачить користувач, очікування на нього робить всю сторінку такою ж повільною, як її найменш важлива частина. На сторінці продукту назва, ціна та зображення є критичними; розділ відгуків, який знаходиться кілька екранів далі, — ні.
У такому випадку SvelteKit підтримує стрімування. Поверніть обіцянку з сервера через метод load без її очікування, і SvelteKit негайно надсилатиме оформлену сторінку, а потім доставить значення обіцянки до браузера, як тільки вона буде виконана.
// src/routes/product/[id]/+page.server.js
export async function load({ fetch, params }) {
// Critical: awaited, blocks the initial render - but it's fast.
const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
// Non-critical: NOT awaited. This is handed to the page as a
// pending Promise, and SvelteKit streams it in once it resolves.
const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
return {
product, // resolved value
reviews: reviewsPromise // still-pending promise
};
}
Продукт очікується, оскільки його потрібно для початкової відображення, а запит виконується швидко. Запит на огляди починається, але не очікується, тому на сторінці зберігається обіцянка з статусом «у процесі» під data.reviews.
На сторінці блок {#await} у Svelte обробляє обидва стани. Усередині {#await data.reviews} відображається легкий замінник, наприклад рядок «Завантаження оглядів...», а у гілці {:then reviews} обробляються результати. Опційна гілка {:catch error} відображає повідомлення у разі невдачі запиту.
Користувач майже відразу бачить деталі продукту, у розділі з оглядами відображається статус завантаження, а справжні огляди замінюють його як тільки надходять. Не потрібен додатковий код для отримання даних на клієнтській стороні та жодних гачків для монтажу.
Обмеження стрімінгу
Майте на увазі ці обмеження:
- Стрімінг функціонує завдяки механізмам обробки навантаження на сервері. Обіцянка створюється на сервері та передається у стріму з файлів
+page.server.jsабо+layout.server.js. Універсальний файл+page.jsтакож може повертати обіцянку, але вона не передається у стріму з сервера таким самим способом. - Ваш адаптер та хостинг мають підтримувати стрімовані відповіді. Адаптери Node та Vercel підтримують це, як і більшість сучасних платформ, але переконайтеся у цьому для менш поширених середовищ. Проксі, які буферують відповіді, також можуть беззвучно скасувати цю перевагу.
- Обробляйте відмови. Обіцянка у стріму, яка відмовляється без використання гілки
{:catch}чи обробника.catch(), може призвести до неконтрольованої відмови на сервері. Надайте некритичним обіцянкам альтернативний варіант обробки.
Повний цикл життя запиту
За допомогою паралельних критичних запитів, свідомого використання parent() та стрімінгу для неважливих даних запит до ресурсу з великою кількістю даних проходить через наступні етапи:
- Браузер запитує цей ресурс.
- SvelteKit виконує функції макету та
loadсторінки на сервері. - Усі незалежні дані, такі як інформація про користувача, публікації та коментарі, завантажуються паралельно.
Порівняйте це з версією, яка обробляється на клієнтській стороні з самого початку. Замість того, щоб браузер виконував три послідовні запити після завантаження сторінки, сервер виконує їх паралельно ще до надсилання будь-чого, і зазвичай з значно меншою затримкою у взаємодії з джерелами даних, ніж у браузера користувача.
Чек-лист перед розгортанням для маршрутів із великою кількістю даних
Розгляньте ці питання перед тим, як розгорнути маршрут, який потребує кількох даних:
- Чи завантажуються дані для початкового відображення у компоненті під час його ініціалізації? Перемістіть їх у функцію
load, зазвичай серверну. - Чи є кілька незалежних операцій await поспіль усередині
load? Спочатку виконайте всі запити, а потім почекайте на них разом за допомогоюPromise.allабоPromise.allSettled. - Чи є
await parent()першим рядком у функціїloadсторінки? Перемістіть його туди, де насправді використовуються дані батьківського елемента, та виконайте не пов’язані запити перед ним. - Чи є якісь дані, які не мають значення для першого відображення? Поверніть їх у вигляді обіцянки без очікування та відобразіть їх за допомогою
{#await}, додавши блок обробки помилок.
fetch від аргументів load? Лише ця версія правильно обробляє відносні URL-адреси та передає куки під час рендерингу на сервері.Підсумок
„Водоспади“ коду не є ознакою недбалості. Це природний наслідок того, коли отримання даних розкидане по дереву компонентів. Функції load у SvelteKit мають менше значення через те, що вони виконуються на сервері, а більше через те, що вони збирають усі запити, необхідні сторінці, в одному видимому місці, де можна вирішити, які з них мають виконуватися разом, які справді залежать від інших, а які можуть надійти пізніше. Звичка будувати код проста: припиніть очікувати інстинктивно та почніть очікувати навмисно. Застосовуючи це до кожного маршруту, ви економите час користувачів під час повільних з’єднань та при завантаженні вантажених серверів.