Async/Await in JavaScript an Beispielen erklärt

Der Autor des Artikels erklärt anhand von Beispielen Async/Await in JavaScript. Insgesamt ist Async/Await eine praktische Methode zur Erstellung asynchronen Codes. Vor dieser Möglichkeit wurde solcher Code mit Hilfe von Callbacks und Promises geschrieben. Der Autor des ursprünglichen Artikels erläutert die Vorteile von Async/Await anhand verschiedener Beispiele.

Wir erinnern daran: alle Leser von „Habr“ erhalten einen Rabatt von 10.000 Rubel bei der Anmeldung zu einem beliebigen Kurs von Skillbox mit dem Aktionscode „Habr“.

Skillbox empfiehlt: Online-Kurs „Java-Entwickler“.

Callback

Ein Callback ist eine Funktion, deren Aufruf auf unbestimmte Zeit verschoben wird. Zuvor wurden Callbacks in Code-Bereichen verwendet, in denen das Ergebnis nicht sofort verfügbar war.

Hier ist ein Beispiel für das asynchrone Lesen einer Datei in Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Probleme treten auf, wenn mehrere asynchrone Operationen gleichzeitig ausgeführt werden müssen. Stellen wir uns folgendes Szenario vor: Es wird eine Anfrage an die Datenbank des Benutzers Arfat gestellt, um das Feld profile_img_url auszulesen und das Bild vom Server someserver.com herunterzuladen.
Nach dem Herunterladen konvertieren wir das Bild in ein anderes Format, beispielsweise von PNG in JPEG. Wenn die Konvertierung erfolgreich war, wird dem Benutzer eine E-Mail zugeschickt. Außerdem wird die Information über das Ereignis in die Datei transformations.log mit Angabe des Datums eingetragen.

Es ist wichtig, auf die Verschachtelung von Callbacks und die große Anzahl von }) am Ende des Codes hinzuweisen. Dies wird als Callback Hell oder Pyramid of Doom bezeichnet.

Die Nachteile dieser Vorgehensweise sind offensichtlich:

  • Dieser Code ist schwer zu lesen.
  • Es ist auch schwierig, Fehler zu behandeln, was oft zu einer Verschlechterung der Codequalität führt.

Um dieses Problem zu lösen, wurden in JavaScript Promises eingeführt. Sie ermöglichen es, die tiefe Verschachtelung von Callbacks durch das Wort .then zu ersetzen.

Ein positiver Aspekt von Promises ist, dass der Code damit viel besser lesbar ist, wobei er von oben nach unten und nicht von links nach rechts gelesen wird. Dennoch haben auch Promises ihre eigenen Probleme:

  • Es müssen viele .then hinzugefügt werden.
  • Statt try/catch wird .catch zur Fehlerbehandlung verwendet.
  • Die Arbeit mit mehreren Promises innerhalb eines einzigen Zyklus ist nicht immer bequem; in einigen Fällen komplizieren sie den Code.

Hier ist eine Aufgabe, die den Wert des letzten Punktes verdeutlicht.

Angenommen, es gibt eine For-Schleife, die eine Folge von Zahlen von 0 bis 10 mit zufälligen Intervallen (0–n Sekunden) ausgibt. Mit Hilfe von Promises muss diese Schleife so geändert werden, dass die Zahlen in der Reihenfolge von 0 bis 10 ausgegeben werden. Wenn die Ausgabe der Null also 6 Sekunden dauert und die der Eins 2 Sekunden, sollte zunächst die Null ausgegeben werden, gefolgt von der Eins.

Und natürlich verwenden wir für diese Aufgabe weder Async/Await noch .sort. Ein Beispiel für die Lösung finden Sie am Ende.

Async-Funktionen

Die Einführung von async-Funktionen in ES2017 (ES8) hat die Arbeit mit Promises erleichtert. Ich möchte darauf hinweisen, dass async-Funktionen "oberhalb" von Promises arbeiten. Diese Funktionen sind keine qualitativ neuen Konzepte. Async-Funktionen wurden als Alternative zu Code entwickelt, der Promises verwendet.

Async/Await ermöglicht es, die Arbeit mit asynchronem Code im synchronen Stil zu organisieren.

Daher erleichtert das Wissen über Promises das Verständnis der Prinzipien von Async/Await.

Syntax

In der Regel besteht es aus zwei Schlüsselwörtern: async und await. Das erste Wort verwandelt die Funktion in eine asynchrone. In solchen Funktionen ist die Verwendung von await erlaubt. In jedem anderen Fall würde die Verwendung dieser Funktion einen Fehler verursachen.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async wird ganz am Anfang der Funktionsdeklaration eingefügt, und im Fall der Verwendung einer Pfeilfunktion zwischen dem Gleichheitszeichen und den Klammern.

Diese Funktionen können in ein Objekt als Methoden eingefügt oder in der Klassendeklaration verwendet werden.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

NB! Man sollte sich daran erinnern, dass Konstruktoren von Klassen sowie Getter/Setter nicht asynchron sein können.

Semantik und Ausführungsregeln

Async-Funktionen sind grundsätzlich ähnlich wie Standard-JS-Funktionen, jedoch gibt es Ausnahmen.

So geben async-Funktionen immer Promises zurück:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

Insbesondere gibt fn den String hello zurück. Nun, da dies eine asynchrone Funktion ist, wird der Wert des Strings mithilfe des Konstruktors in ein Promise eingewickelt.

Hier ist eine alternative Konstruktion ohne Async:

function fn() {
  return Promise.resolve('hello');
}

fn().then(console.log);
// hello

In diesem Fall wird die Rückgabe des Promises "manuell" durchgeführt. Eine asynchrone Funktion wird immer in ein neues Promise eingewickelt.

Wenn der Rückgabewert ein Primitiv ist, gibt die async-Funktion den Wert zurück, indem sie ihn in ein Promise einwickelt. Wenn der Rückgabewert bereits ein Promise-Objekt ist, wird dessen Lösung in einem neuen Promise zurückgegeben.

const p = Promise.resolve('hello')
p instanceof Promise;
// true

Promise.resolve(p) === p;
// true
 

Was passiert jedoch, wenn innerhalb der asynchronen Funktion ein Fehler auftritt?

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

foo().catch(console.log);

Wenn sie nicht behandelt wird, gibt foo() ein Promise mit einem Rejection zurück. In diesem Fall wird ein Promise.reject zurückgegeben, das den Fehler enthält.

Async-Funktionen geben immer ein Promise zurück, unabhängig davon, was zurückgegeben wird.

Asynchrone Funktionen werden bei jedem await angehalten.

Await beeinflusst Ausdrücke. Wenn der Ausdruck ein Promise ist, wird die async-Funktion angehalten, bis das Promise erfüllt ist. Wenn der Ausdruck kein Promise ist, wird er durch Promise.resolve in ein Promise umgewandelt und dann abgeschlossen.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

Hier ist die Beschreibung, wie die fn-Funktion funktioniert.

  • Nach ihrem Aufruf wird die erste Zeile von const a = await 9; in const a = await Promise.resolve(9); umgewandelt.
  • Nach der Verwendung von Await wird die Ausführung der Funktion angehalten, bis a seinen Wert (in diesem Fall 9) erhält.
  • delayAndGetRandom(1000) unterbricht die Ausführung der fn-Funktion, bis sie selbst (nach 1 Sekunde) abgeschlossen ist. Dies ist faktisch eine Stoppschaltung der fn-Funktion für 1 Sekunde.
  • delayAndGetRandom(1000) gibt über resolve einen zufälligen Wert zurück, der dann der Variablen b zugewiesen wird.
  • Der Fall mit der Variable c ist ähnlich dem der Variable a. Danach stoppt alles für eine Sekunde, aber nun gibt delayAndGetRandom(1000) nichts zurück, da es nicht erforderlich ist.
  • Die Werte werden schließlich nach der Formel a + b * c berechnet. Das Ergebnis wird dann mit Promise.resolve in ein Promise eingewickelt und von der Funktion zurückgegeben.

Diese Pausen können an Generatoren in ES6 erinnern, aber dafür gibt es eigene Gründe.

Das Problem löst sich

Jetzt lassen Sie uns die Lösung des oben genannten Problems betrachten.

In der Funktion finishMyTask wird Await verwendet, um auf die Ergebnisse von Operationen wie queryDatabase, sendEmail, logTaskInFile und anderen zu warten. Im Vergleich zu Lösungen, die Promises verwenden, wird die Ähnlichkeit offensichtlich. Dennoch vereinfacht die Async/Await-Version die gesamte syntaktische Komplexität erheblich. Dabei gibt es keine große Anzahl an Callbacks und Ketten wie .then/.catch.

Hier ist eine Lösung zur Ausgabe von Zahlen, dabei gibt es zwei Varianten.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

Hier ist eine Lösung mit der Verwendung von async-Funktionen.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Fehlerbehandlung

Unbehandelte Fehler werden in einem abgelehnten Promise eingekapselt. In async-Funktionen jedoch kann die try/catch-Konstruktion verwendet werden, um eine synchrone Fehlerbehandlung durchzuführen.

async function canRejectOrReturn() {
  // warte eine Sekunde
  await new Promise(res => setTimeout(res, 1000));

  // Mit ~50% Wahrscheinlichkeit ablehnen
  if (Math.random() > 0.5) {
    throw new Error('Entschuldigung, Zahl zu groß.');
  }

  return 'perfekte Zahl';
}

canRejectOrReturn() ist eine asynchrone Funktion, die entweder erfolgreich ausgeführt wird („perfekte Zahl“) oder mit einem Fehler endet („Entschuldigung, Zahl zu groß“).

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'Fehler erfasst';
  }
}

Da im obigen Beispiel die Ausführung von canRejectOrReturn erwartet wird, führt ein eigenes fehlgeschlagenes Ende zur Ausführung des catch-Blocks. Dadurch wird die Funktion foo entweder mit undefined (wenn im try-Block nichts zurückgegeben wird) oder mit error caught beendet. Infolgedessen wird diese Funktion nicht fehlschlagen, da try/catch die Fehlerbehandlung für die Funktion foo übernimmt.

Hier ist ein weiteres Beispiel:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'Fehler erfasst';
  }
}

Es ist wichtig zu beachten, dass im Beispiel von foo canRejectOrReturn zurückgegeben wird. Foo endet in diesem Fall entweder mit einer perfekten Zahl oder gibt einen Fehler Error zurück („Entschuldigung, Zahl zu groß“). Der catch-Block wird niemals ausgeführt.

Das Problem ist, dass foo das Promise zurückgibt, das von canRejectOrReturn stammt. Daher wird die Lösung der Funktion foo zur Lösung für canRejectOrReturn. In diesem Fall besteht der Code nur aus zwei Zeilen:

try {
    const promise = canRejectOrReturn();
    return promise;
}

Und so wird es aussehen, wenn await und return zusammen verwendet werden:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'Fehler erfasst';
  }
}

Im obigen Code wird foo sowohl mit perfect number als auch mit error caught erfolgreich abgeschlossen. Hier wird es keine Fehlschläge geben. Aber foo wird mit canRejectOrReturn abschließen, nicht mit undefined. Lassen Sie uns das überprüfen, indem wir die Zeile return await canRejectOrReturn() entfernen:

try {
    const value = await canRejectOrReturn();
    return value;
}
// ...

Häufige Fehler und Fallstricke

In einigen Fällen kann die Verwendung von Async/Await zu Fehlern führen.

Vergessenes await

So etwas passiert relativ häufig — vor einem Versprechen wird das Schlüsselwort await vergessen:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

Im Code fehlt sowohl await als auch return. Daher endet foo immer mit undefined, ohne eine Sekunde zu warten. Das Versprechen wird jedoch ausgeführt. Wenn es einen Fehler oder eine Ablehnung gibt, wird UnhandledPromiseRejectionWarning ausgelöst.

Async-Funktionen in Rückrufen

Async-Funktionen werden häufig in .map oder .filter als Rückrufe verwendet. Ein Beispiel wäre die Funktion fetchPublicReposCount(username), die die Anzahl der öffentlichen Repositories auf GitHub zurückgibt. Angenommen, wir benötigen die Daten von drei Benutzern. Hier ist der Code für diese Aufgabe:

const url = 'https://api.github.com/users';

// Utility-Funktion, um Repo-Zahlen abzurufen
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Wir benötigen die Konten ArfatSalman, octocat, und norvig. In diesem Fall führen wir aus:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];

const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Es ist wichtig, das Await im Rückruf von .map zu beachten. Hier ist counts ein Array von Versprechen, und .map ist ein anonymer Rückruf für jeden angegebenen Benutzer.

Übermäßige sequenzielle Verwendung von await

Nehmen wir als Beispiel diesen Code:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Hier wird die Anzahl der Repos in der Variable count gespeichert, dann wird diese Zahl dem Array counts hinzugefügt. Das Problem des Codes ist, dass alle nachfolgenden Benutzer im Wartemodus sind, solange die Daten des ersten Benutzers nicht vom Server eintreffen. Es wird also immer nur ein Benutzer gleichzeitig verarbeitet.

Wenn zum Beispiel die Verarbeitung eines Benutzers etwa 300 ms dauert, dann waren es bereits eine Sekunde für alle Benutzer, die benötigte Zeit hängt linear von der Anzahl der Benutzer ab. Da das Abrufen der Anzahl der Repos jedoch nicht voneinander abhängig ist, können die Prozesse parallelisiert werden. Dafür benötigen wir die Arbeit mit .map und Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all erhält ein Array von Promises, das ein Promise zurückgibt. Es wird beendet, nachdem alle Promises im Array abgeschlossen sind oder beim ersten Rejection. Es kann passieren, dass sie nicht alle gleichzeitig gestartet werden — um einen gleichzeitigen Start zu gewährleisten, kann p-map verwendet werden.

Fazit

Async-Funktionen werden immer wichtiger in der Entwicklung. Für eine adaptive Nutzung von Async-Funktionen sollte man auf Folgendes zurückgreifen Async-Iteratoren. Ein JavaScript-Entwickler muss sich damit gut auskennen.

Skillbox empfiehlt:

Quelle: habr.com

60GB SSD 8Gb DDR4