wSołtysiak BlogPraktycznie o tworzeniu oprogramowania i nie tylko
16 listopada 2019

Wywołania asynchroniczne na podstawie danych z tablicy

Pracując na frontendzie nie jesteśmy w stanie uniknąć operacji asynchronicznych. Mamy z nimi do czynienia praktycznie na każdym kroku, najczęściej podczas komunikacji z zewnętrznym API. Zwykle nie sprawia nam to problemów, zwłaszcza od kiedy do dyspozycji mamy Promises oraz async/await. Jednak w przypadku wywoływania wielu asynchronicznych operacji na podstawie danych, które przechowujemy w tablicy, sprawa może nie być taka oczywista.

Wywołania asynchroniczne na podstawie danych z tablicy

Przykładowa sytuacja

Na potrzeby artykułu wyobraźmy sobie następujący scenariusz: W naszym systemie mamy do dyspozycji widok, który wyświetla listę wygenerowanych przez użytkownika dokumentów. Każdy plik może zostać usunięty z serwera poprzez kliknięcie przycisku “Usuń” przy jego nazwie. Naszym zadaniem jest umożliwienie zaznaczenia wielu dokumentów i usunięcia ich jednym kliknięciem. Dodatkowo użytkownik powinien zostać poinformowany o sukcesie lub niepowodzeniu wywołanej akcji.

Pomijamy rozwiązanie dotyczące interfejsu użytkownika. Skupimy się na końcowej funkcji deleteDocuments, której zadaniem będzie wywołanie żądań do serwera. Jedynym parametrem funkcji będzie tablica z identyfikatorami dokumentów, które wybrał użytkownik. Dodatkowo posłużymy się funkcją deleteDocumentOnServer, która zastąpi wywoływanie API. Kod, od którego zaczniemy, wygląda następująco:

function deleteDocumentOnServer(id) {
  console.log(`Deleting document ${id}...`);
  return new Promise(resolve => 
    setTimeout(
      () => resolve(`Document ${id} has been deleted.`),
      2000
    )
  );
}

function deleteDocuments(ids) {
  ...
}

Opracowanie rozwiązania

Błędna intuicja — zastosowanie .forEach

function deleteDocuments(ids) {
  ids.forEach(async id => {
    const response = await deleteDocumentOnServer(id); 
    console.log(response);
  });

  console.log('All documents have been deleted.');
}

Powyższe rozwiązanie to jeden z pierwszych pomysłów, na jaki możemy wpaść. Prosty forEach oraz zastosowanie async/await. Mogłoby się wydawać, że nic złego nie może się wydarzyć. Ten kod jest przecież tak oczywisty! Co więcej, po uruchomieniu wszystkie dokumenty zostały usunięte:

Deleting document 1...
Deleting document 2...
Deleting document 3...
Deleting document 4...
Deleting document 5...
All documents have been deleted.
Document 1 has been deleted.
Document 2 has been deleted.
Document 3 has been deleted.
Document 4 has been deleted.
Document 5 has been deleted.

Przyjrzyjmy się wynikowi działania dokładniej. Zwróćmy uwagę, że komunikaty zostały zwrócone w nieprawidłowej kolejności. Przed usunięciem pierwszego pliku program poinformował o poprawnym usunięciu wszystkich dokumentów. Można z tego wywnioskować, że pomimo wysłania żądań, nie mamy kontroli nad obsługą odpowiedzi z serwera oraz ewentualnych wyjątków.

Jaka jest przyczyna błędnego działania? forEach wywołuje funkcję deleteDocumentOnServer, jednak nie czeka na jej zakończenie. Zgodnie z implementacją, forEach nie spodziewa się w parametrze funkcji asynchronicznej, a także sam nią nie jest. Z tego powodu nie warto próbować także takiego rozwiązania:

async function deleteDocuments(ids) {
  await ids.forEach(async id => {
    const response = await deleteDocumentOnServer(id); 
    console.log(response);
  });

  console.log('All documents have been deleted.');
}

Praca na tablicy obietnic — zastosowanie .map

const onSuccess = response => console.log(response);

async function deleteDocuments(ids) {
  const responses = await ids.map(deleteDocumentOnServer);
  responses.forEach(onSuccess);  

  console.log('All documents have been deleted.');
}

Po niepowodzeniach z forEach czas na zmianę podejścia. Kolejny sposób opiera się na wykorzystaniu map do utworzenia tablicy obietnic na podstawie identyfikatorów. Takie podejście jest jak najbardziej poprawne, jednak przedstawiony powyżej kod nie realizuje poprawnie tego pomysłu. Po uruchomieniu otrzymujemy błędny wynik:

Deleting document 1...
Deleting document 2...
Deleting document 3...
Deleting document 4...
Deleting document 5...
Promise { <pending> }
Promise { <pending> }
Promise { <pending> }
Promise { <pending> }
Promise { <pending> }
All documents have been deleted.

Problemem nadal jest wykonanie dalszego kodu przed zakończeniem działania funkcji asynchronicznych. Przyczyną błędu jest nieprawidłowe użycie await, które odnosi się do wyniku ids.map, czyli tablicy obietnic. await jest w stanie obsłużyć wyłącznie pojedynczy Promise. Jeśli chcemy działać na wielu obietnicach, to powinniśmy skorzystać z mechanizmów, które “opakowują” kolekcję Promise w jedną, główną obietnicę:

  • Promise.all — czeka, aż wszystkie przekazane obietnice zostaną rozwiązane (w dowolnej kolejności), a następnie zwraca tablicę z ich rezultatami. W przypadku odrzucenia którejkolwiek obietnicy główny Promise także zostaje odrzucony z tym samym błędem.
  • Promise.allSettled — główna obietnica zostaje spełniona, gdy wszystkie przekazane obietnice zostaną rozwiązane lub odrzucone. Zwrócona wartość jest zawsze tablicą, która zawiera informacje o powodzeniu lub niepowodzeniu wszystkich obietnic.
  • Promise.race — zwraca wynik pierwszej rozwiązanej lub odrzuconej obietnicy.

Do rozwiązania naszego problemu zastosujemy Promise.all. Poprawny kod wygląda następująco:

const onSuccess = response => console.log(response);

async function deleteDocuments(ids) {
  const responses = await Promise.all(
    ids.map(deleteDocumentOnServer)
  );
  responses.forEach(onSuccess);

  console.log('All documents have been deleted.');
}

Obsługa błędów

Nigdy nie możemy zakładać, że wszystkie operacje na serwerze przebiegną pomyślnie. Załóżmy, że dokumenty o id 2 i 3 nie mogą zostać usunięte, ponieważ są z chronione:

const isProtected = id => id === 2 || id === 3;

function deleteDocumentOnServer(id) {
  console.log(`Deleting document ${id}...`);
  return new Promise((resolve, reject) => 
    setTimeout(
      () => 
        isProtected(id) 
          ? reject(new Error(`Document ${id} is protected!`))
          : resolve(`Document ${id} has been deleted.`),
      2000
    )
  );
}

Przechwycenie błędu z Promise.all

const onSuccess = response => console.log(response);

async function deleteDocuments(ids) {
  try {
    const responses = await Promise.all(
      ids.map(deleteDocumentOnServer)
    );
    responses.forEach(onSuccess);
    
    console.log('All documents have been deleted.');
  } catch (err) {
    console.error(err.message);
  }
}

Jeśli zdecydujemy się przechwytywać błędy z Promise.all, to zgodnie z jej działaniem złapiemy tylko pierwszy błąd. Z tego powodu nie jesteśmy w stanie obsłużyć pozytywnie zakończonych operacji oraz pozostałych błędów. Wynik działania funkcji będzie wyglądał następująco:

Deleting document 1...
Deleting document 2...
Deleting document 3...
Deleting document 4...
Deleting document 5...
Document 2 is protected!

Obsłużenie wyjątków na poziomie wewnętrznej funkcji

async function deleteDocuments(ids) {
  await Promise.all(ids.map(async id => {
    try {
      const response = await deleteDocumentOnServer(id);
      console.log(response);
    } catch (err) {
      console.error(err.message);
    }
  }));

  console.log('Documents have been deleted.');
}

Wybierając obsłużenie błędów wewnątrz funkcji przekazanej do map, przechwycimy wszystkie błędy, a obietnica z Promise.all zawsze zostanie spełniona:

Deleting document 1...
Deleting document 2...
Deleting document 3...
Deleting document 4...
Deleting document 5...
Document 1 has been deleted.
Document 2 is protected!
Document 3 is protected!
Document 4 has been deleted.
Document 5 has been deleted.
Documents have been deleted.

Wszystko działa teraz zgodnie z naszymi oczekiwaniami. Warto jednak spojrzeć na nasz kod jeszcze raz i chwilę się zastanowić. Nasz kod poczekał, aż wszystkie obietnice zostaną rozwiązane lub odrzucone. To oznacza, że właśnie napisaliśmy własną wersje Promise.allSettled. Zastosujmy więc tę metodę w praktyce.

Zastosowanie Promise.allSettled

Uwaga! Zwróć uwagę, czy Promise.allSettled jest dostępne na twoim środowisku (poniższy kod będzie działać m.in. na Chrome 76, Firefox 72 i Node.JS 12.9.0)

const isSuccess = ({ status }) => status === 'fulfilled';
const onSuccess = ({ value }) => console.log(value);
const onError = ({ reason }) => console.error(reason.message);
const handleResponse = response => 
   isSuccess(response)
    ? onSuccess(response)
    : onError(response);

async function deleteDocuments(ids) {
  const results = await Promise.allSettled(
    ids.map(deleteDocumentOnServer)
  );
  results.forEach(handleResponse);

  console.log('Documents have been deleted.');
}

Promise.allSettled zwraca tablicę obiektów składających się z dwóch pól: status oraz value/reason w zależności od powodzenia operacji asynchronicznej. W łatwy sposób możemy więc rozdzielić logikę na dwie ścieżki: pozytywną i negatywną.

W porównaniu do poprzedniej wersji deleteDocuments, kod odpowiadający za obsługę pozytywnego scenariusza z sekcji try został przeniesiony do onSuccess, a obsługa błędu z catch trafiła do funkcji onError. Mała zmiana, a kod zrobił się czytelniejszy i zgodny ze specyfikacją EcmaScript.

Podsumowanie

Praca z kodem asynchronicznym może nas wielokrotnie zaskoczyć. Często oczywisty na pierwszy rzut oka kod działa inaczej, niż tego oczekujemy. Dlatego warto poświęcić trochę czasu, aby lepiej poznać mechanizmy, z których możemy skorzystać podczas zmagań z wywołaniami asynchronicznymi.

Każdą osobę pracującą w JS zachęcam do przeczytania You Don’t Know JS: Async & Performance. To świetna pozycja zarówno dla osób początkujących, jak i dla tych bardziej zaawansowanych. Gorąco polecam!