Funkcje asynchroniczne: składanie obietnic przyjazne

Funkcje asynchroniczne umożliwiają tworzenie kodu opartego na obietnicach tak, jakby był on synchroniczny.

Jake Archibald
Jake Archibald

Funkcje asynchroniczne są domyślnie włączone w Chrome, Edge, Firefox i Safari i są naprawdę wspaniałe. Umożliwiają one pisanie kodu opartego na obietnicach tak, jakby był on synchroniczny, ale bez blokowania wątku głównego. Dzięki nim asynchroniczny kod jest mniej „sprytny”, a bardziej czytelny.

Funkcje asynchroniczne działają w ten sposób:

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  } catch (rejectedValue) {
    // …
  }
}

Jeśli przed definicją funkcji użyjesz słowa kluczowego async, w ramach tej funkcji możesz użyć funkcji await. Gdy await obietnica, funkcja jest wstrzymywana w sposób niezablokowany do czasu jej spełnienia. Jeśli obietnica zostanie spełniona, zwrócimy wartość. Jeśli obietnica zostanie odrzucona, odrzucona wartość zostanie wyrzucona.

Obsługa przeglądarek

Obsługa przeglądarek

  • Chrome: 55.
  • Edge: 15.
  • Firefox: 52.
  • Safari: 10.1.

Źródło

Przykład: rejestrowanie pobierania

Załóżmy, że chcesz pobrać adres URL i zapisać odpowiedź jako tekst. Oto jak to wygląda w przypadku obietnic:

function logFetch(url) {
  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      console.log(text);
    })
    .catch((err) => {
      console.error('fetch failed', err);
    });
}

A to ten sam kod, ale z wykorzystaniem funkcji asynchronicznych:

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  } catch (err) {
    console.log('fetch failed', err);
  }
}

Liczba wierszy jest taka sama, ale wszystkie funkcje wywołania zwrotnego zostały usunięte. Dzięki temu będzie ono znacznie łatwiejsze w czytaniu, zwłaszcza dla osób, które nie są zaznajomione z obietnicami.

Asynchroniczne wartości zwracane

Funkcje asynchroniczne zawsze zwracają obietnicę, niezależnie od tego, czy używasz await, czy nie. Obiet na obietnicy jest rozwiązywany z wartością zwracaną przez funkcję asynchroniczną lub odrzucany z wartością rzucaną przez funkcję asynchroniczną. W przypadku:

// wait ms milliseconds
function wait(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

…wywołanie hello() zwraca obietnicę, która spełnia obietnicę "world".

async function foo() {
  await wait(500);
  throw Error('bar');
}

…wywołanie foo() zwraca obietnicę, która odrzuca z wartością Error('bar').

Przykład: strumieniowanie odpowiedzi

Zalety funkcji asynchronicznych są coraz bardziej widoczne w bardziej złożonych przykładach. Załóżmy, że chcesz przesłać odpowiedź podczas wylogowywania fragmentów i zwrócić ostateczny rozmiar.

Oto obietnice:

function getResponseSize(url) {
  return fetch(url).then((response) => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    });
  });
}

Poznaj Jake’a „Wypełniającego obietnice” Archibalda. Czy widzisz, że wywołuję funkcję processResult() wewnątrz siebie, aby skonfigurować pętlę asynchroniczną? Pisanie tego tekstu sprawiło, że poczułem się bardzo mądry. Jednak podobnie jak w przypadku większości „inteligentnych” kodów, aby zrozumieć, co się dzieje, trzeba na niego wpatrywać się przez długi czas. Jest to trochę jak z obrazami Magic Eye z lat 90.

Spróbujmy jeszcze raz z funkcjami asynchronicznymi:

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

Nie ma już „inteligentnych” funkcji. Asymetryczna pętla, która sprawiła, że czułem się tak samolubny, została zastąpiona przez niezawodną, nudną pętlę while. O wiele lepiej. W przyszłości otrzymasz asynchroniczne liczniki, które zastąpią pętlę while pętlą for-of, co uczyni ją jeszcze bardziej przejrzystą.

Inna składnia funkcji asynchronicznej

Wcześniej pokazałem Ci już async function() {}, ale słowo kluczowe async można też używać z inną składnią funkcji:

Funkcje strzałek

// map some URLs to json-promises
const jsonPromises = urls.map(async (url) => {
  const response = await fetch(url);
  return response.json();
});

Metody obiektów

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then();

Metody klasy

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then();

Uwaga! Unikaj zbyt ścisłej kolejności

Podczas pisania kodu, który wygląda na synchroniczny, nie zapomnij o możliwości wykonywania zadań równolegle.

async function series() {
  await wait(500); // Wait 500ms…
  await wait(500); // …then wait another 500ms.
  return 'done!';
}

Wykonanie tego zadania zajmuje 1000 ms, podczas gdy:

async function parallel() {
  const wait1 = wait(500); // Start a 500ms timer asynchronously…
  const wait2 = wait(500); // …meaning this timer happens in parallel.
  await Promise.all([wait1, wait2]); // Wait for both timers in parallel.
  return 'done!';
}

Wykonanie tego działania zajmuje 500 ms, ponieważ oba oczekiwania występują w tym samym czasie. Przyjrzyjmy się praktycznemu przykładowi.

Przykład: wyprowadzanie danych w kolejności

Załóżmy, że chcesz pobrać serię adresów URL i jak najszybciej zapisać je w prawidłowej kolejności.

Głębokie oddychanie – tak to wygląda w przypadku obietnic:

function markHandled(promise) {
  promise.catch(() => {});
  return promise;
}

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map((url) => {
    return markHandled(fetch(url).then((response) => response.text()));
  });

  // log them in order
  return textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise).then((text) => console.log(text));
  }, Promise.resolve());
}

Tak, używam reduce do łańcucha obietnic. Jestem tak smart. To jednak bardzo sprytny kod, z którego lepiej zrezygnować.

Jednak podczas konwertowania powyższego kodu na funkcję asynchroniczną łatwo jest użyć zbyt sekwencyjnego podejścia:

Nie zalecane – zbyt sekwencyjne
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
Wygląda to znacznie lepiej, ale drugie pobieranie rozpocznie się dopiero po zakończeniu pierwszego, i tak dalej. Jest to znacznie wolniejsze niż przykład z obietnic, który wykonuje pobieranie w drodze równoległej. Na szczęście istnieje idealny kompromis.
Zalecane – ładne i równoległe
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
W tym przykładzie adresy URL są pobierane i czytane równolegle, ale element „smart” reduce został zastąpiony standardowym, nudnym, czytelnym pętlą for.

Obsługa przeglądarek: generatory

Jeśli kierujesz reklamy na przeglądarki obsługujące generatory (w tym najnowszą wersję każdej głównej przeglądarki), możesz w pewien sposób polyfillować funkcje asynchroniczne.

Babel zrobi to za Ciebie. Oto przykład w Babel REPL:

Polecam podejście polegające na transpilacji, ponieważ możesz je wyłączyć, gdy przeglądarki docelowe będą obsługiwać funkcje asynchroniczne. Jeśli jednak naprawdę nie chcesz korzystać z transpilatora, możesz użyć polyfill Babel. Zamiast:

async function slowEcho(val) {
  await wait(1000);
  return val;
}

…uwzględnij polyfill i napisz:

const slowEcho = createAsyncFunction(function* (val) {
  yield wait(1000);
  return val;
});

Pamiętaj, że musisz przekazać generator (function*) do createAsyncFunction, a zamiast await musisz użyć yield. Poza tym działa tak samo.

Rozwiązanie: regenerator

Jeśli kierujesz się na starsze przeglądarki, Babel może też transpilować generatory, co pozwala używać funkcji asynchronicznych aż do IE8. Aby to zrobić, musisz użyć wstępnie ustawionego profilu es2017 w Babelu oraz wstępnie ustawionego profilu es2015.

Dane wyjściowe nie są tak ładne, więc uważaj na błąd typu code-bloat.

Asynchronicznie wszystko!

Gdy asynchroniczne funkcje będą dostępne we wszystkich przeglądarkach, używaj ich we wszystkich funkcjach zwracających obietnicę. Nie tylko sprawiają, że kod jest bardziej uporządkowany, ale też zapewniają, że funkcja zawsze zwróci obietnicę.

W 2014 r. bardzo ucieszyły mnie funkcje asynchroniczne i bardzo się cieszę, że w końcu są dostępne w przeglądarkach. Whoop!