Главная / Статьи

Завершение использования функций загрузки в режиме параллельной обработки в SvelteKit

Узнайте, почему последовательные операции await замедляют загрузку страниц, и как SvelteKit с помощью функций load, Promise.all, тщательно спланированных вызовов parent() и потоковых обещаний устраняет проблему последовательной загрузки.

Издатель: REACTAPP.TOP11 мин чтения2629 слов

Источники: 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 и последовательных 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 — это не глобальная функция. 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 мс плюс некоторые дополнительные затраты времени, это позволяет сделать страницу пригодной для использования за примерно 360 мс вместо 960 мс, причем разница увеличивается на медленных сетях или при активной работе 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 страницы получать данные, возвращаемые расположениями, находящимися выше по иерархии. Иногда это именно то, что нужно, например когда запрос страницы требует ID пользователя, который могут получить только расположения. Однако если её вызывать слишком рано, это приведет к сериализации всех операций, выполняемых после неё:

// 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. Размещайте его именно там, где требуется значение, никогда не в начале функции автоматически, и запускайте любые запросы, которые не зависят от данных родителя, до его выполнения.

Стриминг некритических данных

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(), может привести к неразрешённому отклонению на сервере. Для не критичных обещаний укажите запасной вариант.
  • Заголовки и куки фиксируются после начала стриминга. Любые операции, требующие установки куки или кода состояния, должны быть завершены до начала отклика.
  • Стримируемый контент зависит от JavaScript в браузере. Без него пользователи увидят только состояние загрузки, поэтому не следует стримить то, что необходимо для поисковых систем или пользователей без JavaScript.
  • Полный цикл жизни запроса

    Благодаря параллельным критическим запросам, целенаправленному использованию parent() и стримингу незначимых данных, запрос к роуту с большим объемом данных проходит через следующие этапы:

    1. Браузер запрашивает роут.
    2. SvelteKit выполняет функции макета и загрузки страницы роута на сервере.
    3. Все независимые данные, такие как информация о пользователе, посты и комментарии, загружаются параллельно.
  • Сервер генерирует полный HTML с уже встроенными данными, поэтому на начальном этапе отображения нет состояния загрузки.
  • Браузер получает единый готовый ответ вместо серии сетевых запросов.
  • Svelte активирует интерактивность на уже отрендеренном маркапе, в то время как обработка потоковых обещаний заполняет соответствующие разделы по мере их выполнения.
  • Сравните это с версией, реализованной на стороне клиента с самого начала. Вместо того чтобы браузер выполнял три последовательных запроса после загрузки страницы, сервер выполняет их параллельно ещё до отправки любых данных, причем задержка при получении информации обычно значительно меньше, чем у браузера пользователя.

    Чек-лист перед выпуском для маршрутов с большим объемом данных

    Ознакомьтесь с этими вопросами перед выпуском маршрута, требующего нескольких данных:

    • Загружаются ли данные для первоначальной отрисовки в компоненте при его монтаже? Переместите их в функцию load, обычно серверную.
    • Есть ли несколько независимых операций await подряд внутри load? Сначала отправьте все запросы, затем дождитесь их совместно с помощью Promise.all или Promise.allSettled.
    • Является ли await parent() первой строкой функции загрузки страницы? Переместите её туда, где фактически используются данные родителя, и выполните не связанные с ней запросы до неё.
    • Есть ли данные, не связанные с первой отрисовкой? Верните их в виде обещания без ожидания выполнения и отрисуйте с помощью {#await}, добавив блок обработки ошибок.
  • Приходит ли fetch из аргументов load? Только эта версия правильно обрабатывает относительные URL-адреса и передаёт куки во время отрисовки на сервере.
  • Заключение

    Структура в виде «водопадов» не является признаком небрежного кода. Это естественный результат, когда операции загрузки данных разбросаны по дереву компонентов. Функции load в SvelteKit имеют меньшее значение из-за того, что они выполняются на сервере, но большее — потому что они собирают все запросы, необходимые странице, в одно видимое место, где можно определить, какие из них должны выполняться одновременно, какие действительно зависят друг от друга, а какие могут прийти позже. Привычка к такому подходу проста: перестаньте ждать инстинктивно и начните ждать осознанно. Применяя её ко всем маршрутам, вы сэкономите время пользователям при медленных соединениях и загруженном сервере.