Галоўная / Артыкулы

Заканчэнне выкарыстоўвання функцый Parallel load у SvelteKit для адкрыцья водападоў

Дазвольце дазнаць, чаму абыякшыя вызовы awaits спрычынаюць медленную роботу сторанак, і як SvelteKit выкарыстоўвае функцыі load, Promise.all, абераганыя вызовы parent() і стрімаваныя promises, каб усунуць гэты эфект.

Выдавец: 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, роботу сервера і перадачу данных. Тады на старонцы немае нічога корыстнага для адображэння прыблізна на весь час трох гэтых процесаў, а не толькі на час самага дзейснага.

Нічога не выклікае памылак, ніякі тэст не збываецца, і кожны компанент делае тое, што яму было сказана. Карыстальнікі проста спаглядаюць індыкатор загрузкі прыблізна у тры разы дэльней, чым трэба. Саме гэтая невиднасць робіць водоспадны падход такім распашчытым.

Чаму водоспадны падходы продовжаюць з’являцца

Большасць іх стварае трое звычайных прычын:

  • Кожны компанент запрашоўвае свае сабе прызначаныя даны. У дапрынтовых аплікацыях родны компанент часта должен завершыць загрузку, перш чым падкомпанент зможа запрацаваць, а падкомпанент не можа пачаць свой запит, пакуль не отрымае параметра ад роднага компанента. Дрэва компанента стае ланцугам запытоў.
  • Чаканне лінія за лініяй з прыzwычкі. Абарочныя запэўнення 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 не ёсць глобальным. SvelteKit аддае расширэную версію, якая прымае вялікія URL-адрэсы, такія як /api/user, перадае кукі і заголовкі прыходзячай заявкі пад час атрымлення тэкста на серверы, а калі мета — іншы маршрут у той жа прыемлік, яна безпасебна вызывае той хэндлер, а не стварае рэальную HTTP-заявку. Імпорт глобальнага fetch пазбярогае ўсё гэта.

Значэнне, якое вяртаецца, стае атрыбутам дадзеных сторанкі

Што бы ні не вернула функцыя, гэта будзе доступна для компонента сторнкі як 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 сторанкі доступ да дадзейнаў, якія вернулі лэйауты, якія знаходзяцца вышэй. Іноды самэ гэта і ўсё, што трэба, напрыклад, калі запит сторанкі выкалічвае 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.

На сторанцы блок Svelte {#await} керуе обомі станамі. Унутры {#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 выкананая функцыі распакоўкі маршруту і функцыі load сторанкі на серверы.
    3. Усе незалежныя дадзеныя, такія як корыстувач, паспэўдзі і каментары, запрашаныя паралельна.
  • Сервер генеруе цэлы HTML-код, у якому тыя данні вжо ўбудованы, таму пачатковы адгляд не мае стану завантажэння.
  • Браузер атрымляе адзін готовы адказ у замяну на серію сеансаў з’язку па сеті.
  • Svelte актывае сторонню частку стораніцы, дадаючы інтерактыўнасьць маркапу, які вжо атрыманы, тады калі будь-якія обетаванні у формате стріму заполняюць сваія секцыі праз ўсуненне прычын іх нерэалізавання.
  • Пораўнайце гэта з версіяй, якая выконвалася на стороне кліента з самага пачатку. У замяну на тое, каб браузер адправляў тры послідовныя запыты пасля завантажэння стораніцы, сервер адправляе іх паралельна ўсё раней, чым што-небудзь будзе атрымана, і зазвычай з набліжна меншай затрымкай па адносу да джэрел даных, чым у браузера корыстніка.

    Спіс пераконтраць пры выкладзе маршрутаў з великай кантэнтай даных

    Расглядзіце гэтыя пытанні пры выкладзе маршрута, які патрэбуе калькі дадзеных:

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

    «Водоспады» не ўскладненняя кода — гэта нормальны наследак таго, калі запрашанні дадзейнаў розбросаны ў дрэве компонентаў. Функціі load у SvelteKit маюць меншое значэнне таму, што яны выкананы на серверы, а большое — таму, што яны збіраюць усе запрашанні, якія патрэбны сторанцы, en аднам візуабельным месца, дзе можна выбраць, калія з іх трэба выканаць разам, калія насправды залежна ад іншых, а калія можа прыйсці пазней. Неабходна толькі адна прычынка для змян: перастаць чакаць інстынктыўна і начаць чакаць свядома. Якщо гэта застосаваць да кожнай маршруту, гэта заўсёды зарабіць час для корыстнікаў нават пад спакойным з’яўленнем дадзейнаў і пад завантажаным серверам.