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 się spełni, odzyskasz 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. Tak to wygląda z użyciem obietnic:

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

To samo przy użyciu 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 do odczytania, 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. Ta obietnica zwraca wartość zwracaną przez funkcję asynchroniczną lub odrzuca wartość zgłaszaną 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ę spełniającą wartość "world".

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

...wywołanie foo() zwraca obietnicę odrzucającą z funkcją Error('bar').

Przykład: strumieniowanie odpowiedzi

W bardziej złożonych przykładach korzyści przynoszą funkcje asynchroniczne. 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);
    });
  });
}

Zobaczcie mi. Jake „władca obietnic” Archibald. 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 wieki, jak w przypadku obrazów 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. Asynchroniczna pętla, która sprawiła, że poczułam się tak zadowolona, została zastąpiona zaufaną, nudną pętlą. Dużo 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

Mimo że piszesz kod, który wygląda na synchroniczny, nie trać okazji do 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. Jednak ta technika kodowania jest na tyle sprytna, że bez niej powinno być lepiej.

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 rozpoczyna się dopiero po całkowitym odczytaniu 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 plikiem for-loop.

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 naprawdę nie chcesz korzystać z transpilatora, możesz użyć polyfill Babel. Zamiast:

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

...uwzględnij parametr polyfill i wpisz:

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

Pamiętaj, że musisz przekazać generatora (function*) do usługi createAsyncFunction i użyć yield zamiast await. 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 skonfigurowanej wersji Babel es2017 i wstępnie skonfigurowanej wersji es2015.

Wyniki nie są tak atrakcyjne, więc uważaj na przerost kodu.

Asynchronicznie wszystko!

Gdy funkcje asynchroniczne pojawią się we wszystkich przeglądarkach, używaj ich w każdej funkcji zwracającej obietnicę. Nie tylko upraszczają one kod, ale też gwarantują, ż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!