Strona główna / Artykuły

Przekształcanie łańcuchów Promise w Async/Await bez zmiany zachowania

Jak przekształcić łańcuchy .then() w czytelny kod async/await, co tak naprawdę zatrzymuje wykonywanie operacji przez await, kiedy należy uruchamiać kroki równolegle oraz jak try/catch zastępuje .catch().

Wydawca: REACTAPP.TOP7 min czytania1597 słów

Źródła: Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Obietnice uratowały JavaScript przed zagnieżdżonymi funkcjami zwrotnymi, ale łańcuch zależnych kroków .then() nadal przypomina układ rurociągów, a nie zwykłą sekwencję instrukcji. async/await zamienia tę sytuację, pozwalając kodowi asynchronicznemu wyglądać jak zwykły kod realizowany od góry do dołu, przy jednoczesnym zachowaniu obietnic w tle. Ten przewodnik pokazuje, jak bezpiecznie przekształcić łańcuch obietnic, co tak naprawdę wstrzymuje await, kiedy sekwencyjne użycie await marnuje czas oraz jak zmienia się obsługa błędów, aby można było przeredagować istniejący kod bez zmiany jego funkcjonowania.

Od łańcucha then do funkcji liniowej

Oto typowy łańcuch, w którym każdy krok zależy od wyniku poprzedniego: załadowanie użytkownika, następnie jego zamówień, potem paragonów do tych zamówień, przy czym istnieje jeden obsługa na wszelkie błędy.

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getReceipts(orders))
  .then(receipts => {
    console.log(receipts);
  })
  .catch(err => handleError(err));

Jest to poprawne i znacznie lepsze niż nawiasowane callbacki, ale nadal trzeba wizualnie śledzić łańcuch, aby prześledzić przepływ danych. async/await, ustandaryzowane w ES2017, zostały stworzone po to, by usunąć tę ostatnią przeszkodę. Ta sama logika napisana na nowo wygląda w ten sposób:

async function loadData(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    console.log(receipts);
  } catch (err) {
    handleError(err);
  }
}

Behawior odpowiada łańcuchowi: każdy krok rozpoczyna się dopiero po zakończeniu poprzedniego, a każda awaria trafia do jednego obsługiwacza błędów. To, co się zmieniło, to czytelność. Każda wartość pośrednia ma teraz nazwę, a przepływ jest czytelny od góry do dołu.

Miej na uwadze jedną zasadę: async/await nie zastępują obietnic. Są to jedynie syntaktyczne udogodnienia nad nimi. Obietnice nadal wykonują pracę asynchroniczną; syntakta jedynie zmienia sposób ich pisania i odczytywania.

Czym zmienia słowo kluczowe async

Umieszczenie słowa async przed funkcją ma dwa skutki. Poniższa funkcja zwraca zwykły obiekt:

async function getUser(id) {
  return { id, name: "Aarav" };
}

Zawsze zwraca obietnicę

Mimo że getUser zwraca zwykły obiekt, jego wywołanie daje obietnicę, która rozwiązuje się w tym obiekcie. Dlatego użytkownicy mogą ją używać tak jak każdą inną obietnicę:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

Jeśli funkcja rzuci błędem, zwrócona obietnica zostanie odrzucona, zamiast aby błąd rozprzestrzenił się synchronicznie. Łatwo o to zapomnieć, gdy funkcja, która wcześniej była synchroniczna, staje się async: każdy użytkownik, który wcześniej korzystał bezpośrednio z jej wartości zwrotnej, teraz otrzymuje obietnicę i musi zostać poinformowany o tym zmianie.

Zezwala na użycie await w swoim ciele

await jest dopuszczalne wewnątrz funkcji async. Współczesny JavaScript umożliwia również użycie await na najwyższym poziomie w modułach ES, co jest wygodne przy tworzeniu skryptów i inicjalizacji modułów, ale w zwykłych funkcjach nadal potrzebny jest async.

Czym właściwie zatrzymuje się await

Wyrażenie await zwraca kontrolę, dopóki jego obietnica nie zostanie rozstrzygnięta w jakiś sposób. Sukces powoduje, że wyrażenie zwraca rozstrzygniętą wartość; niepowodzenie sprawia, że ta linia rzuci błędem, tak jak to zrobiłoby polecenie throw. Przykład loguje komunikat, czeka na użytkownika, a następnie loguje wynik:

async function loadUser() {
  console.log("Fetching user...");
  const user = await getUser(1); // pauses here until getUser's promise settles
  console.log("Got user:", user);
}

Wynik pojawia się w takiej kolejności:

Fetching user...
Got user: { id: 1, name: "Aarav" }

Kluczowym elementem jest to, co oznacza „pauzowanie”. Tylko ta jedna funkcja jest wstrzymywana, dokładnie na tej linii. Reszta programu, w tym inny kod, zegary i przychodzące żądania na serwerze, nadal działa. Gdy obietnica zostanie spełniona, funkcja kontynuuje pracę od miejsca przerwy, a przypisana wartość trafia do zmiennej.

Sekwencyjne użycie awaits i ich ukryte koszty

Kilka wyrażeń awaits wykonuje się kolejno po sobie:

async function loadDashboard(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const receipts = await getReceipts(orders);
  return receipts;
}

Każda linia czeka na zakończenie swojego kroku, zanim rozpocznie się następny, co odtwarza zachowanie łańcuchowego użycia .then() bez konieczności tworzenia łańcucha. Jest to właściwe, gdy każdy krok wymaga poprzedniego wyniku, jak w tym przypadku: nie można pobrać zamówień, zanim nie będzie wiadomo o użytkowniku.

Problemy pojawiają się, gdy ten sam styl jest stosowany do operacji, które od siebie nie zależą. W poniższym ładowarze panelu sterowania użytkownik, powiadomienia i rekomendacje są niezależne, a mimo to każda prośba czeka na tę poprzedzającą ją:

async function loadDashboard() {
  const user = await getUser();
  const notifications = await getNotifications();
  const recommendations = await getRecommendations();

  return { user, notifications, recommendations };
}

Całkowity czas staje się sumą wszystkich trzech żądań, a nie czasem najwolniejszego z nich.

Rozpoczynanie niezależnych zadań jednocześnie

Ponieważ te trzy wywołania są niezależne, można je uruchomić jednocześnie i czekać na wszystkie za pomocą Promise.all():

async function loadDashboard() {
  const [user, notifications, recommendations] = await Promise.all([
    getUser(),
    getNotifications(),
    getRecommendations()
  ]);

  return { user, notifications, recommendations };
}

Wszystkie trzy żądania są realizowane jednocześnie, a dekonstrukcja pozwala uzyskać wyniki w tym samym porządku co tablica wejściowa. Warto znać jedną ważną kwestię: Promise.all() zostanie spełniony tylko wtedy, gdy wszystkie obietnice się spełnią, natomiast odrzuci wynik natychmiast po odrzuceniu choćby jednej z nich, bez czekania na pozostałe. Jeśli chcesz uzyskać wyniki wszystkich operacji, niezależnie od błędów, Promise.allSettled() jest lepszym rozwiązaniem. Porównanie tych narzędzi znajduje się w artykule wybór między Promise.all, Promise.race a sekwencyjnymi awaits.

Rozwiązywanie błędów za pomocą try i catch

W przypadku użycia async/await zamiast .catch() stosuje się znajomy blok try/catch, dzięki czemu błędy synchroniczne i asynchroniczne korzystają ostatecznie z tej samej struktury:

async function loadUser(id) {
  try {
    const user = await getUser(id);
    console.log(user);
  } catch (err) {
    console.log("Failed to load user:", err.message);
  }
}

Jeśli obietnica zwrócona przez getUser(id) zostanie odrzucona, kontrola przechodzi bezpośrednio do bloku catch, dokładnie tak jak w przypadku błędu wywołanego przez kod synchroniczny. Należy zauważyć, że ta funkcja obsługuje błąd, a następnie kończy pracę normalnie, dzięki czemu jej własna obietnica zostaje spełniona wartością undefined. Wywołujący nie mogą stwierdzić, że załadunek się nie udał. Gdy wywołujący musi zareagować, należy ponownie rzucić błąd po zapisaniu informacji lub zwrócić wyraźny wynik.

Jeden obsługiwacz dla kilku kroków

Jedyny blok try może obejmować całą sekwencję operacji await:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    return receipts;
  } catch (err) {
    console.log("Something failed in the dashboard load:", err.message);
  }
}

Jeśli któryś z kroków zawiedzie, wykonywanie przechodzi bezpośrednio do catch, więc nie jest potrzebny osobny obsługiwacz na każdy krok. Działa to podobnie do efektu centralizacji, jaki miało .catch() na końcu łańcucha. Gdy różne kroki wymagają różnych działań naprawczych, na przykład ponownej próby tylko żądania o faktury, należy otoczyć ten krok węższym blokiem try.

Porównanie obu stylów

Oto ten sam ładowacz w obu formach:

// Promises
function loadDashboard(id) {
  return getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getReceipts(orders))
    .catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return await getReceipts(orders);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

Ani jedna z wersji nie zmienia podstawowej logiki asynchronicznej. Wybór dotyczy tego, która z nich jest łatwiejsza do odczytania i konserwacji, a w przypadku logiki wieloetapowej zazwyczaj przeważa async/await. Jeden subtelny aspekt drugiej wersji: return await getReceipts(orders) umieszczone wewnątrz try ma znaczenie. Zapisanie return getReceipts(orders) bez await spowodowałoby zwrócenie obietnicy przed jej rozstrzygnięciem, więc odrzucenie z tego ostatniego kroku całkowicie umknęłoby blokowi catch.

Częste pytania

Czy await blokuje całe aplikację?

Nie. Wstrzymuje jedynie funkcję asynchroniczną, która go zawiera. Inny kod, w tym inne żądania, nadal działa, podczas gdy ta funkcja czeka.

Czy można używać await poza funkcją asynchroniczną?

Nie dotyczy to zwykłych funkcji. Wyjątkiem jest await na najwyższym poziomie w modułach ES, który działa na poziomie modułu, ale nie wewnątrz zwykłych funkcji ni async.

Czy async/await jest szybsze od promises?

Nie. Jest oparte na promises, więc nie można oczekiwać żadnej przewagi szybkościowej od zmiany składni. Korzyścią jest czytelność i łatwość konserwacji. Prawdziwe przyspieszenia wynikają z jednoczesnego wykonywania niezależnych zadań, a nie od tej słowa kluczowej.

Co się dzieje bez try/catch?

Jeśli oczekiwana obietnica zostanie odrzucona i nic jej nie przechwyci, to obietnica samej funkcji asynchronicznej również zostanie odrzucona. Jeśli żaden wywołujący też nie zajmie się tym odrzuceniem, środowisko wykonawcze zgłosi nieobsłużone odrzucenie, co w aktualnych wersjach Node.js domyślnie zakończa proces. Obsługuj błędy za pomocą try/catch wewnątrz funkcji lub za pomocą .catch() w miejscu jej wywołania. Aby dowiedzieć się więcej o tego typu problemach, zobacz powszechne błędne przekonania dotyczące async/await, które powodują błędy w produkcji.

Główne wnioski

  • async/await zmienia sposób pisania kodu asynchronicznego, a nie sposób jego wykonywania; praca nadal jest wykonywana przez obietnice.
  • Funkcja async zawsze zwraca obietnicę, więc przekształcenie funkcji na async zmienia jej interfejs dla każdego wywołującego.
  • await wstrzymuje wykonywanie tylko swojej własnej funkcji, nigdy całego programu.
  • Kroki zależne od await należy wykonywać sekwencyjnie, natomiast niezależne – razem za pomocą Promise.all() lub Promise.allSettled().
  • Użycie bloku try/catch wokół kilku instrukcji await umożliwia centralne zarządzanie błędami; pamiętaj o ponownym rzuceniu błędu, gdy osoby wywołujące muszą o nim wiedzieć, oraz o użyciu return await wewnątrz bloku try.