Async/Await in JavaScript: Ein Überblick durch Beispiele

Der Autor des Artikels erklärt anhand von Beispielen, wie Async/Await in JavaScript funktioniert. 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 Originalartikels hebt die Vorteile von Async/Await hervor und diskutiert verschiedene 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-Bildungskurs "Java-Entwickler".

Callback

Ein Callback ist eine Funktion, deren Aufruf auf unbestimmte Zeit verschoben wird. Früher wurden Callbacks in Codestellen verwendet, an 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 Nutzers Arfat gestellt, das Feld profile_img_url muss abgerufen und das Bild von dem Server someserver.com geladen werden.
Nach dem Hochladen konvertieren wir das Bild in ein anderes Format, zum Beispiel von PNG zu JPEG. Wenn die Konvertierung erfolgreich war, erhält der Benutzer eine E-Mail. Anschließend werden Informationen über das Ereignis in die Datei transformations.log mit Angabe des Datums eingetragen.

Es ist wichtig, auf die Überlappung von Rückrufen und die große Anzahl von }) im letzten Teil des Codes zu achten. Dies wird als Callback Hell oder Pyramid of Doom bezeichnet.

Die Nachteile dieser Methode sind offensichtlich:

  • Dieser Code ist schwer zu lesen.
  • Es ist auch schwierig, Fehler zu behandeln, was oft zu einem schlechteren Code führt.

Um dieses Problem zu lösen, wurden in JavaScript Promises eingeführt. Diese ermöglichen es, die tiefe Verschachtelung von Rückrufen durch .then zu ersetzen.

Ein positiver Aspekt von Promises ist, dass der Code dadurch viel besser lesbar ist, und zwar von oben nach unten, nicht von links nach rechts. Dennoch haben Promises auch ihre Probleme:

  • Es müssen viele .then hinzugefügt werden.
  • .catch wird anstelle von try/catch verwendet, um alle Fehler zu behandeln.
  • Die Arbeit mit mehreren Promises innerhalb einer Schleife ist nicht immer komfortabel; in einigen Fällen macht sie den Code komplizierter.

Hier ist eine Aufgabe, die die Bedeutung des letzten Punktes verdeutlicht.

Angenommen, es gibt eine for-Schleife, die eine Sequenz von Zahlen von 0 bis 10 mit einem zufälligen Intervall (0–n Sekunden) ausgibt. Mithilfe von Promises muss diese Schleife so geändert werden, dass die Zahlen in der Reihenfolge von 0 bis 10 ausgegeben werden. Wenn also die Ausgabe von null 6 Sekunden dauert und die von eins 2 Sekunden, sollte zunächst null ausgegeben werden, gefolgt von der Ausgabe von eins.

Und natürlich verwenden wir für diese Aufgabe weder Async/Await noch .sort. Das 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 betonen, dass async-Funktionen "über" Promises arbeiten. Diese Funktionen stellen keine qualitativ anderen Konzepte dar. Async-Funktionen wurden als Alternative zu Codes gedacht, die Promises verwenden.

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 sie aus zwei Schlüsselwörtern: async und await. Das erste Wort macht die Funktion asynchron. In solchen Funktionen ist die Verwendung von await erlaubt. Ansonsten führt die Nutzung dieser Funktion zu einem Fehler.

// 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 zu Beginn der Funktionsdeklaration eingefügt, und im Falle einer Pfeilfunktion zwischen dem Gleichheitszeichen und den Klammern.

Diese Funktionen können als Methoden in ein Objekt eingefügt oder in einer Klassenbeschreibung 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! Es ist wichtig zu beachten, dass Konstruktoren von Klassen sowie Getter und Setter nicht asynchron sein können.

Semantik und Ausführungsregeln

Async-Funktionen ähneln grundsätzlich den Standard-JS-Funktionen, weisen jedoch Ausnahmen auf.

Asynchronen Funktionen geben immer Promises zurück:

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

Insbesondere gibt fn den String hello zurück. Da es sich jedoch um eine asynchrone Funktion handelt, wird der String-Wert beim Rückgabewert mithilfe des Konstruktors in ein Promise gewickelt.

Hier ist eine alternative Konstruktion ohne Async:

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

In diesem Fall wird das Promise "manuell" zurückgegeben. Eine asynchrone Funktion wird immer in ein neues Promise eingewickelt.

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

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

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

Aber was passiert, wenn innerhalb der asynchronen Funktion ein Fehler auftritt?

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

foo().catch(console.log);

Wird der Fehler nicht behandelt, gibt foo() ein Promise mit einem Reject zurück. In diesem Fall wird anstelle von Promise.resolve 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 pausieren bei jedem await.

Await beeinflusst Ausdrücke. Wenn der Ausdruck ein Promise ist, pausiert die async-Funktion, bis das Promise ausgeführt wird. Wenn der Ausdruck kein Promise ist, wird er durch Promise.resolve in ein Promise konvertiert 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 arbeitet.

  • Nach ihrem Aufruf wird die erste Zeile von const a = await 9; in const a = await Promise.resolve(9); konvertiert.
  • Nach der Verwendung von Await wird die Funktion angehalten, bis sie ihren Wert erhält (in dieser Situation ist es 9).
  • delayAndGetRandom(1000) pausiert die Ausführung der fn-Funktion, bis sie selbst abgeschlossen ist (nach 1 Sekunde). Dies ist im Grunde eine Unterbrechung 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 Variablen c ist analog zu dem von a. Danach wird für eine Sekunde alles angehalten, aber jetzt gibt delayAndGetRandom(1000) nichts zurück, da dies nicht erforderlich ist.
  • Letztendlich werden die Werte 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 es gibt dafür gute Gründe..

Lass uns die Aufgabe lösen.

Jetzt schauen wir uns die Lösung für die oben genannte Aufgabe an.

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 nutzen, wird die Ähnlichkeit offensichtlich. Dennoch vereinfacht die Async/Await-Variante die syntaktischen Herausforderungen erheblich. In diesem Fall gibt es keine Vielzahl von Callbacks und Ketten wie .then/.catch.

Hier ist eine Lösung zur Ausgabe von Zahlen, die zwei Varianten umfasst.

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);
    });
  });
};

Und hier ist eine Lösung unter 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 rejected Promise eingekapselt. In Async-Funktionen kann jedoch die Try/Catch-Konstruktion verwendet werden, um Fehler synchron zu behandeln.

async function canRejectOrReturn() {
  // eine Sekunde warten
  await new Promise(res => setTimeout(res, 1000));
 
  // Mit ~50% Wahrscheinlichkeit abweisen
  if (Math.random() > 0.5) {
    throw new Error('Entschuldigung, Zahl zu groß.');
  }
 
  return 'perfekte Zahl';
}

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

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

Da im obigen Beispiel die Ausführung von canRejectOrReturn erwartet wird, führt ein eigenes fehlerhaftes Ende zur Ausführung des catch-Blocks. Infolgedessen wird die Funktion foo entweder mit undefined (wenn im try-Block nichts zurückgegeben wird) oder mit 'error caught' beendet. Damit hat diese Funktion kein fehlerhaftes Ende, da der try/catch-Block die Funktion foo selbst behandelt.

Hier ist ein weiteres Beispiel:

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

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

Das Problem ist, dass foo ein Promise zurückgibt, das von canRejectOrReturn übergeben wird. 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 sein, wenn await und return zusammen verwendet werden:

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

Im obigen Code wird foo sowohl mit der perfekten Zahl als auch mit einem gefangenen Fehler erfolgreich abgeschlossen. Hier wird es keine Ablehnungen geben. Allerdings wird foo mit canRejectOrReturn abgeschlossen und nicht mit undefined. Lassen Sie uns dies ü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

Das passiert ziemlich oft – das Schlüsselwort await wird vor dem Promise vergessen:

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

Im Code ist, wie zu sehen, weder await noch return vorhanden. Daher wird foo immer mit undefined ohne eine Verzögerung von 1 Sekunde abgeschlossen. Das Promise wird jedoch ausgeführt. Falls es einen Fehler oder eine Ablehnung gibt, wird in diesem Fall eine UnhandledPromiseRejectionWarning ausgelöst.

Async-Funktionen in Rückrufen

Async-Funktionen werden häufig in .map oder .filter als Rückrufe verwendet. Ein Beispiel dafür ist die Funktion fetchPublicReposCount(username), die die Anzahl der offenen Repositories auf GitHub zurückgibt. Angenommen, es gibt drei Benutzer, deren Statistiken wir benötigen. Hier ist der Code für diese Aufgabe:

const url = 'https://api.github.com/users';
 
// Utility-Funktion zum Abrufen der Repo-Anzahlen
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

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

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Es ist erwähnenswert, dass Await in der Callback-Funktion von .map verwendet wird. Hierbei ist counts ein Array von Promises, während .map eine anonyme Rückruffunktion für jeden angegebenen Benutzer darstellt.

Übermäßige sequenzielle Verwendung von await

Als Beispiel nehmen wir folgenden 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 Repositories in der Variablen count gespeichert, und diese Zahl wird dann dem Array counts hinzugefügt. Das Problem mit dem Code besteht darin, dass bis die Daten des ersten Benutzers vom Server empfangen werden, alle nachfolgenden Benutzer im Wartemodus verbleiben. Somit wird in einem einzigen Moment nur ein Benutzer verarbeitet.

Wenn beispielsweise die Verarbeitung eines Nutzers etwa 300 ms in Anspruch nimmt, so summiert sich dies für alle Nutzer auf eine Sekunde; die benötigte Zeit ist linear von der Anzahl der Nutzer abhängig. Da das Abrufen der Anzahl der Repos jedoch unabhängig voneinander erfolgt, können die Prozesse parallelisiert werden. Dazu ist die Arbeit mit .map und Promise.all notwendig:

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 selbst ein Promise zurückgibt. Dieses wird entweder abgeschlossen, wenn alle Promises im Array erfüllt sind, oder beim ersten Ablehnen. Es kann vorkommen, dass nicht alle gleichzeitig gestartet werden; um einen gleichzeitigen Start sicherzustellen, kann p-map verwendet werden.

Fazit

Async-Funktionen werden zunehmend wichtiger für die Entwicklung. Um die Nutzung von async-Funktionen anzupassen, sollte man Async-Iteratoren. Ein JavaScript-Entwickler sollte sich damit gut auskennen.

Skillbox empfiehlt:

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster