Strona główna / Artykuły / Dziewięć wzorców obietnic dla niezawodnego JavaScript asynchronicznego w produkcji

Dziewięć wzorców obietnic dla niezawodnego JavaScript asynchronicznego w produkcji

Naucz się praktycznych wzorców Promise — zapytań równoległych, czasów wygaśnięcia, ponawiania prób, ograniczeń współbieżności oraz anulowania — aby tworzyć odporny, gotowy do użycia asynchroniczny JavaScript.

1998 słów

Ciągle korzystasz z Promises, ale kilka mniej znanych wzorców może przekształcić skomplikowany kod asynchroniczny w coś przewidywalnego i łatwego do zrozumienia.

Większość ludzi poznaje Promises dzięki przykładowi takiemu jak ten:

fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));

A w przypadku prostych skryptów to rzeczywiście wszystko, czego potrzebujesz.

Problemy pojawiają się, gdy przechodzisz do aplikacji klasy produkcyjnej.

Nagle potrzebujesz kilku żądań wykonywanych jednocześnie, a nie jeden po drugim.

Potrzebujesz sposobu na anulowanie zadań, które już nie są istotne.

Potrzebujesz możliwości ponawiania prób w przypadku nieudanego żądania.

Potrzebujesz możliwości kontynuowania pracy nawet wtedy, gdy tylko część operacji się powiedzie.

Potrzebujesz zapobiegania dwukrotnemu wywołaniu tej samej funkcji API przez pomyłkę.

A czasami musisz ograniczyć liczbę operacji asynchronicznych wykonywanych jednocześnie, aby twój backend nie został przytłoczony wszystkim naraz.

To jest moment, w którym Promises przestają być tematem dla początkujących i stają się prawdziwym narzędziem projektowym.

Poniżej znajduje się dziewięć wzorców, które warto mieć w swoim zestawie narzędzi podczas pisania nowoczesnego JavaScriptu.

1. Wykonywanie niezależnych żądań równolegle

Jednym z najprostszych rozwiązań w kodzie asynchronicznym jest również to, które najłatwiej przeoczyć.

Załóżmy, że strona wymaga trzech rzeczy: danych użytkownika, powiadomień oraz informacji analitycznych.

Pierwsza, powszechna próba wygląda tak:

const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.

Każde await blokuje wykonywanie aż do zakończenia poprzedniej funkcji, więc żądania są realizowane sekwencyjnie.

Załóżmy, że każde żądanie trwa około 500 ms – taka sekwencyjna kolejność może skutkować około 1,5 sekundy oczekiwania.

Jeśli żadne z tych żądań faktycznie nie zależy od siebie, nie ma powodu do czekania.

Dokładnie do tego służy Promise.all():

const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);

Wszystkie trzy żądania są teraz wysyłane jednocześnie, zamiast na zmianę.

W przypadku paneli sterowania lub dowolnego ekranu ładowającego kilka niezależnych źródeł danych może to znacząco skrócić czas ładowania.

Ważna kwestia

Promise.all() zawodzi szybko: w momencie, gdy którykolwiek z obietnic odrzuci żądanie, cała grupa odrzuca je razem z nim.

To w porządku, gdy każde żądanie jest obowiązkowe, ale jeśli niektóre dane są opcjonalne, potrzebna będzie bardziej elastyczna metoda.

2. Użyj Promise.allSettled(), gdy częściowe niepowodzenie jest do przyjęcia

Załóżmy panel administracyjny, który wyświetla:

  • Przychody
  • Użytkownicy
  • Powiadomienia
  • Sytuacja systemu

Czy jeśli usługa powiadomień jest niedostępna, cały ekran powinien stać się pusty?

Zazwyczaj nie – utrata jednego elementu nie powinna wpłynąć na resztę strony.

Promise.allSettled() rozwiązuje dokładnie ten problem.

const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});

Jedna nieudana próba już nie niszczy wyników udanych operacji znajdujących się obok niej.

Taki model sprawdza się na panelach kontrolnych, ekranach analitycznych i narzędziach monitoringu, gdzie pokazanie częściowych danych jest lepsze niż brak żadnych danych.

3. Dodanie czasu wygaśnięcia do Promise

Ranо czy późno napotkasz tę sytuację: co się stanie, jeśli żądanie po prostu nigdy nie wróci?

Bez zabezpieczenia interfejs może utknąć na nieokreślony czas.

Możesz stworzyć mały, wielokrotnie używalny wrapper, który wprowadza ograniczenie czasowe:

function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}

Następnie użyj go tam, gdzie wysyłasz żądania:

try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}

Jeśli próba nie zostanie zakończona w ciągu pięciu sekund, otoczony Promise odrzuci się sam.

To znacznie lepsze doświadczenie niż pozostawianie użytkowników przy spinerze, który nigdy się nie rozwiązuje.

4. Próba ponownego wykonania nieudanych operacji

Sieci czasami przerywają połączenia.

Serwery mogą mieć problemy.

API dostawców zewnętrznych czasami zachowują się niewłaściwie.

To wszystko nie oznacza koniecznie, że użytkownik musi natychmiast zobaczyć komunikat o błędzie.

W przypadku nieudanych prób, które są prawdopodobnie tymczasowe, mały pomocnik do ponawiania prób może wiele zdziałać.

async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}

Można to wywołać w ten sposób:

const data = await retry(
() => fetch("/api/data"),
3
);

Operacja otrzymuje teraz kilka dodatkowych szans, zanim zostanie uznana za prawdziwy błąd.

Ale nie próbuj wszystkiego ślepo ponownie

Stan 500 często wskazuje na tymczasowy problem z serwerem.

Z kolei błąd 401 Unauthorized nie zostanie rozwiązany poprzez wysłanie tej samej prośby jeszcze trzy razy.

Dobra logika ponawiania prób pozwala odróżnić nieudane operacje, które warto spróbować ponownie, od tych, które tego nie wymagają.

5. Dodawanie opóźnień między próbami

Próba ponownego wysłania żądania natychmiast po jego niepowodzeniu nie zawsze jest mądrym posunięciem.

Rozważmy serwer, który i tak ma trudności przy dużej obciążeniu.

Jeśli tysiące klientów spróbuje ponownie wysłać żądania w tym samym momencie, dodamy jeszcze większe obciążenie na system i tak już narażony na stres.

Bazowa funkcja opóźnienia rozwiązuje ten problem:

function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

Można ją bezpośrednio wstawić do pętli ponownych prób:

async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}

Systemy produkcyjne często idą o krok dalej i stosują eksponencjalne opóźnienia, rozkładając próby w ten sposób:

1 second
2 seconds
4 seconds
8 seconds

Taki rozkład prób daje serwerowi czas na odzyskanie, zamiast wywołać falę ponownych prób.

6. Kontrola współbieżności

Istnieje problem, który Promise.all() może potajemnie wprowadzić.

Załóżmy, że musimy przetworzyć 1000 żądań API.

Naiwny podejście wydaje się dość nieszkodliwe:

await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.

Ale oznacza to, że możesz uruchomić wszystkie 1000 operacji dokładnie w tym samym momencie.

Rzadko jest to to, czego faktycznie chcesz.

Często wolisz ograniczyć liczbę operacji wykonywanych równolegle.

Pomyśl o czymś takim:

1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time

Stworzenie prostego ogranicznika współbieżności nie jest trudne:

async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}

Można go używać w ten sposób:

const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);

Teraz sam decydujesz, ile zadań ma być wykonywanych jednocześnie, zamiast pozwalać na to systemowi operacyjnemu.

Ta technika przynosi ogromne korzyści, gdy masz do czynienia z dużymi zbiorami danych, zewnętrznymi API, przetwarzaniem plików lub kolejkami zadań w tle.

7. Zapobieganie duplikatowym żądaniom

Oto problem, który pojawia się częściej, niż by się spodziewało.

Użytkownik ładuje stronę panelu kontrolnego.

Trzy oddzielne komponenty potrzebują tych samych danych profilu użytkownika.

Zamiast wysyłać trzy oddzielne żądania:

Component A → /api/user
Component B → /api/user
Component C → /api/user

można sprawić, że będą one dzielić się jednym obietnicą (Promise).

const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}

W takim ustawieniu, jeśli trzy komponenty poproszą o tego samego użytkownika mniej więcej w tym samym momencie, wszystkie połączą się z jednym trwającym żądaniem zamiast uruchamiać trzy osobne.

Innymi słowy:

3 żądania → 1 żądanie

Tę technikę często nazywa się deduplikacją żądań.

Dla większych baz kodu narzędzia takie jak TanStack Query już implementują dla Ciebie logikę cache’owania i deduplikacji w ten sposób.

8. Użyj Promise.any(), gdy potrzebujesz tylko jednego udanego wyniku

Czasami ta sama informacja jest dostępna z kilku różnych źródeł.

Naprzимер:

API Server A
API Server B
API Server C

Jeśli Twoja aplikacja potrzebuje tylko jednej udanej odpowiedzi z którychkolwiek z nich, Promise.any() jest do tego odpowiednie.

const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);

To obietnica, która pierwsza się wypełni, wygrywa „wyścig”.

Należy zauważyć, że zachowuje się to inaczej niż w przypadku Promise.race().

Promise.race() rozstrzyga sprawę na podstawie tego, która obietnica uzyska wartość pierwsza – niezależnie od tego, czy chodzi o sukces, czy o błąd.

Promise.any() natomiast czeka konkretnie na pierwszą obietnicę, która udanie się wypełni, ignorując odrzucenia, chyba że wszystkie one zawiodą.

Różnica ta może wydawać się drobna, ale może całkowicie zmienić sposób radzenia sobie z błędami.

9. Anuluj prace, których już nie potrzebujesz

To wzorzec należy do tych najbardziej satysfakcjonujących pod względem zastosowania.

Pomyśl o polu wyszukiwania w czasie rzeczywistym:

user types: react
user types: react dashboard
user types: react dashboard ui

Prawdopodobnie nie chcesz, aby żądania z wcześniejszych nacisków klawiszy nadal działały w tle po tym, jak staną się przestarzałe.

To jest dokładnie sytuacja, do której został stworzony AbortController.

const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});

Gdy żądanie przestaje być potrzebne, wystarczy wywołać:

controller.abort();
The request can then be cancelled.

Ten wzorzec pojawia się ciągle w takich scenariuszach jak:

  • Sugestie wyszukiwania
  • Pola z autodopasowaniem
  • Przejścia między trasami lub stronami
  • Demontowanie/konserwacja komponentów
  • Żądania zastąpione nowszymi

Prawdziwą umiejętnością nie jest tu samo wywołanie abort().

Jest nią rozpoznawanie momentu, w którym wcześniejsze działania przestają być przydatne.

Bardziej ważna lekcja

Promesy nie wydają się trudne, ponieważ .then() to jakaś skomplikowana API.

Wydają się trudne, ponieważ aplikacje w praktyce obejmują chaotyczne, wielowarstwowe zachowania asynchroniczne.

Ciągle musisz rozwiązywać takie problemy jak:

Czy te operacje powinny być wykonywane jednocześnie?

Jaki jest plan na wypadek, gdy jedna z nich zawiedzie?

Ile czasu to za długo na oczekiwanie?

Czy próby ponownego wykonania są tu opłacalne?

Ile operacji powinno być wykonywanych równocześnie?

Czy mogę uniknąć powtarzania pracy, która jest już w trakcie?

Czy ta prośba nadal ma znaczenie?

Gdy zaczynasz zadawać takie pytania, Promises przestają być zwykłą cechą języka.

Stają się prawdziwym narzędziem architektonicznym.

Moje 9 wzorców Promise w pigułce

Promise.all() jest najlepszy do jednoczesnego wykonywania niezależnych zadań. Promise.allSettled() w sposób uprzejmy radzi sobie z częściowymi błędami. Promise.race() nadaje się do obsługi czasów wygaśnięcia lub reakcji na to, które zadanie zostanie ukończone pierwsze. Logika ponawiania prób pomaga odzyskać kontrolę w przypadku tymczasowych błędów. Strategie opóźnienia i stopniowego zwiększania interwałów zapobiegają zbyt agresywnemu ponawianiu prób. Ograniczanie równoczesności pomaga kontrolować duże obciążenia pracy. Deduplikacja żądań zapobiega powtarzającym się wywołaniom API. Promise.any() zwraca pierwszy udany wynik spośród kilku. AbortController umożliwia anulowanie zadań, które nie są już potrzebne.

Nie musisz używać wszystkich tych wzorców w każdym projekcie.

W rzeczywistości ich przymusowe stosowanie byłoby kontraproduktywne.

Prawdziwa umiejętność polega na określeniu jakiego problemu próbujesz rozwiązać, zanim sięgniesz po konkretny wzorzec.

Ostateczna refleksja

Najlepszy kod asynchroniczny to nie ten wypełniony najsprytniejszymi sztuczkami z Promise.

To kod, w którym obsługa błędów, timing, równoczesność i anulowanie zostały przemyślane z góry, zanim przerodziły się w problemy w produkcji.

Zacznij od najprostszej wersji.

Zwróć uwagę na to, co faktycznie powoduje problemy.

Dopiero wtedy wprowadź odpowiedni wzorzec, który rozwiąże ten konkretny problem.

Ponieważ czasami najbardziej zaawansowanym posunięciem w JavaScript jest rozpoznanie momentu, gdy wzorzec w ogóle nie jest potrzebny.

Pozycje pokrewne

  • Wybór między Promise.all, Promise.race a sekwencyjnymi oczekiwaniami — Dowiedz się, kiedy Promise.all() przyspiesza API Node.js, dlaczego szybko zawodzi przy każdym odrzuceniu oraz jak wybrać odpowiedni wzorzec asynchroniczny.
  • Async/Await kontra Promises: Co naprawdę różni się w tle — Wyjaśnia rzeczywiste różnice w wykonywaniu, zużyciu pamięci oraz śladach stosu między async/await a Promises, a także kiedy warto skorzystać z surowych API Promise.