Async Await JavaScript Tutorial - Warten auf den Abschluss einer Funktion in JS

Wann endet eine asynchrone Funktion? Und warum ist diese Frage so schwer zu beantworten?

Nun, es stellt sich heraus, dass das Verständnis asynchroner Funktionen viel Wissen darüber erfordert, wie JavaScript grundlegend funktioniert.

Lassen Sie uns dieses Konzept untersuchen und dabei viel über JavaScript lernen.

Sind Sie bereit? Lass uns gehen.

Was ist asynchroner Code?

JavaScript ist standardmäßig eine synchrone Programmiersprache. Dies bedeutet, dass bei der Ausführung von Code JavaScript am Anfang der Datei beginnt und den Code zeilenweise durchläuft, bis er fertig ist.

Das Ergebnis dieser Entwurfsentscheidung ist, dass immer nur eines passieren kann.

Sie können sich das so vorstellen, als würden Sie sechs kleine Bälle jonglieren. Während Sie jonglieren, sind Ihre Hände besetzt und können mit nichts anderem umgehen.

Ähnlich verhält es sich mit JavaScript: Sobald der Code ausgeführt wird, hat er alle Hände voll mit diesem Code. Wir nennen dies diese Art der synchronen Codeblockierung . Weil es effektiv verhindert, dass anderer Code ausgeführt wird.

Kehren wir zum Jonglierbeispiel zurück. Was würde passieren, wenn Sie einen weiteren Ball hinzufügen möchten? Anstelle von sechs Bällen wollten Sie sieben Bälle jonglieren. Das könnte ein Problem sein.

Du willst nicht aufhören zu jonglieren, weil es einfach so viel Spaß macht. Aber du kannst auch keinen anderen Ball holen, denn das würde bedeuten, dass du aufhören müsstest.

Die Lösung? Delegieren Sie die Arbeit an einen Freund oder ein Familienmitglied. Sie jonglieren nicht, also können sie den Ball für Sie holen und ihn dann in Ihr Jonglieren werfen, wenn Ihre Hand frei ist und Sie bereit sind, mitten im Jonglieren einen weiteren Ball hinzuzufügen.

Dies ist asynchroner Code. JavaScript delegiert die Arbeit an etwas anderes und erledigt dann sein eigenes Geschäft. Wenn es fertig ist, erhält es die Ergebnisse von der Arbeit zurück.

Wer macht die andere Arbeit?

Okay, wir wissen also, dass JavaScript synchron und faul ist. Es möchte nicht die ganze Arbeit selbst erledigen, also bewirtschaftet es sie zu etwas anderem.

Aber wer ist diese mysteriöse Entität, die für JavaScript funktioniert? Und wie wird es eingestellt, um für JavaScript zu arbeiten?

Schauen wir uns ein Beispiel für asynchronen Code an.

const logName = () => { console.log("Han") } setTimeout(logName, 0) console.log("Hi there")

Das Ausführen dieses Codes führt zu der folgenden Ausgabe in der Konsole:

// in console Hi there Han

In Ordung. Was ist los?

Es stellt sich heraus, dass die Art und Weise, wie wir die Arbeit in JavaScript auslagern, darin besteht, umgebungsspezifische Funktionen und APIs zu verwenden. Und dies ist eine Quelle großer Verwirrung in JavaScript.

JavaScript wird immer in einer Umgebung ausgeführt.

Oft ist diese Umgebung der Browser. Es kann sich aber auch mit NodeJS auf dem Server befinden. Aber was um alles in der Welt ist der Unterschied?

Der Unterschied - und das ist wichtig - besteht darin, dass der Browser und der Server (NodeJS) in Bezug auf die Funktionalität nicht gleichwertig sind. Sie sind oft ähnlich, aber sie sind nicht gleich.

Lassen Sie uns dies anhand eines Beispiels veranschaulichen. Angenommen, JavaScript ist der Protagonist eines epischen Fantasy-Buches. Nur ein gewöhnliches Bauernkind.

Nehmen wir jetzt an, dieses Farmkind hat zwei Anzüge mit speziellen Rüstungen gefunden, die ihnen Kräfte verliehen, die über ihre eigenen hinausgehen.

Wenn sie die Browser-Rüstung verwendeten, erhielten sie Zugriff auf eine Reihe von Funktionen.

Wenn sie die Server-Rüstung verwendeten, erhielten sie Zugriff auf andere Funktionen.

Diese Anzüge haben einige Überschneidungen, da die Hersteller dieser Anzüge an bestimmten Orten die gleichen Bedürfnisse hatten, an anderen jedoch nicht.

Das ist eine Umgebung. Ein Ort, an dem Code ausgeführt wird und an dem Tools vorhanden sind, die auf der vorhandenen JavaScript-Sprache aufbauen. Sie sind kein Teil der Sprache, aber die Linie ist oft unscharf, weil wir diese Tools jeden Tag verwenden, wenn wir Code schreiben.

setTimeout, fetch und DOM sind Beispiele für Web-APIs. (Die vollständige Liste der Web-APIs finden Sie hier.) Dies sind Tools, die in den Browser integriert sind und uns bei der Ausführung unseres Codes zur Verfügung gestellt werden.

Und weil wir JavaScript immer in einer Umgebung ausführen, scheinen diese Teil der Sprache zu sein. Aber das sind sie nicht.

Wenn Sie sich jemals gefragt haben, warum Sie Fetch in JavaScript verwenden können, wenn Sie es im Browser ausführen (aber ein Paket installieren müssen, wenn Sie es in NodeJS ausführen), ist dies der Grund. Jemand hielt Fetch für eine gute Idee und baute es als Tool für die NodeJS-Umgebung.

Verwirrend? Ja!

Aber jetzt können wir endlich verstehen, was die Arbeit von JavaScript übernimmt und wie sie eingestellt wird.

Es stellt sich heraus, dass es die Umgebung ist, die die Arbeit übernimmt, und der Weg, die Umgebung dazu zu bringen, diese Arbeit zu erledigen, besteht darin, Funktionen zu verwenden, die zur Umgebung gehören. Zum Beispiel fetch oder setTimeout in der Browserumgebung.

Was passiert mit der Arbeit?

Toll. Die Umwelt übernimmt also die Arbeit. Dann was?

Irgendwann müssen Sie die Ergebnisse zurückbekommen. Aber lassen Sie uns darüber nachdenken, wie das funktionieren würde.

Kehren wir von Anfang an zum Jonglierbeispiel zurück. Stellen Sie sich vor, Sie haben nach einem neuen Ball gefragt, und ein Freund hat gerade angefangen, den Ball auf Sie zu werfen, als Sie noch nicht bereit waren.

Das wäre eine Katastrophe. Vielleicht könnten Sie Glück haben und es fangen und es effektiv in Ihre Routine integrieren. Aber es besteht eine große Chance, dass Sie alle Ihre Bälle fallen lassen und Ihre Routine zum Absturz bringen. Wäre es nicht besser, wenn Sie strenge Anweisungen geben würden, wann Sie den Ball erhalten sollen?

Wie sich herausstellt, gibt es strenge Regeln, wann JavaScript delegierte Arbeit empfangen kann.

Diese Regeln werden von der Ereignisschleife geregelt und betreffen die Warteschlange für Mikrotask und Makrotask. Ja, ich weiß. Das ist viel. Aber ertrage es mit mir.

In Ordung. Wenn wir also asynchronen Code an den Browser delegieren, übernimmt und führt der Browser den Code aus und übernimmt diese Arbeitslast. Es gibt jedoch möglicherweise mehrere Aufgaben, die dem Browser zugewiesen werden. Daher müssen wir sicherstellen, dass wir diese Aufgaben priorisieren können.

Hier kommen die Mikrotask-Warteschlange und die Makrotask-Warteschlange ins Spiel. Der Browser übernimmt die Arbeit, erledigt sie und stellt das Ergebnis je nach Art der empfangenen Arbeit in eine der beiden Warteschlangen.

Versprechen werden beispielsweise in die Mikrotask-Warteschlange gestellt und haben eine höhere Priorität.

Events und setTimeout sind Beispiele für Arbeiten, die in die Makrotask-Warteschlange gestellt werden und eine niedrigere Priorität haben.

Sobald die Arbeit erledigt ist und sich in einer der beiden Warteschlangen befindet, wird die Ereignisschleife hin und her ausgeführt und überprüft, ob JavaScript bereit ist, die Ergebnisse zu empfangen.

Erst wenn JavaScript den gesamten synchronen Code ausgeführt hat und gut und bereit ist, beginnt die Ereignisschleife, aus den Warteschlangen auszuwählen und die Funktionen zur Ausführung an JavaScript zurückzugeben.

Schauen wir uns also ein Beispiel an:

setTimeout(() => console.log("hello"), 0) fetch("//someapi/data").then(response => response.json()) .then(data => console.log(data)) console.log("What soup?")

Wie wird die Bestellung hier sein?

  1. Zunächst wird setTimeout an den Browser delegiert, der die Arbeit erledigt und die resultierende Funktion in die Makrotask-Warteschlange stellt.
  2. Zweitens wird der Abruf an den Browser delegiert, der die Arbeit übernimmt. Es ruft die Daten vom Endpunkt ab und stellt die resultierenden Funktionen in die Mikrotask-Warteschlange.
  3. Javascript meldet sich ab "Welche Suppe"?
  4. Die Ereignisschleife prüft, ob JavaScript bereit ist, die Ergebnisse der Arbeit in der Warteschlange zu empfangen.
  5. When the console.log is done, JavaScript is ready. The event loop picks queued functions from the microtask queue, which has a higher priority, and gives them back to JavaScript to execute.
  6. After the microtask queue is empty, the setTimeout callback is taken out of the macrotask queue and given back to JavaScript to execute.
In console: // What soup? // the data from the api // hello

Promises

Now you should have a good deal of knowledge about how asynchronous code is handled by JavaScript and the browser environment. So let's talk about promises.

A promise is a JavaScript construct that represents a future unknown value. Conceptually, a promise is just JavaScript promising to return a value. It could be the result from an API call, or it could be an error object from a failed network request. You're guaranteed to get something.

const promise = new Promise((resolve, reject) => { // Make a network request if (response.status === 200) { resolve(response.body) } else { const error = { ... } reject(error) } }) promise.then(res => { console.log(res) }).catch(err => { console.log(err) })

A promise can have the following states:

  • fulfilled - action successfully completed
  • rejected - action failed
  • pending - neither action has been completed
  • settled - has been fulfilled or rejected

A promise receives a resolve and a reject function that can be called to trigger one of these states.

One of the big selling points of promises is that we can chain functions that we want to happen on success (resolve) or failure (reject):

  • To register a function to run on success we use .then
  • To register a function to run on failure we use .catch
// Fetch returns a promise fetch("//swapi.dev/api/people/1") .then((res) => console.log("This function is run when the request succeeds", res) .catch(err => console.log("This function is run when the request fails", err) // Chaining multiple functions fetch("//swapi.dev/api/people/1") .then((res) => doSomethingWithResult(res)) .then((finalResult) => console.log(finalResult)) .catch((err => doSomethingWithErr(err))

Perfect. Now let's take a closer look at what this looks like under the hood, using fetch as an example:

const fetch = (url, options) => { // simplified return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() // ... make request xhr.onload = () => { const options = { status: xhr.status, statusText: xhr.statusText ... } resolve(new Response(xhr.response, options)) } xhr.onerror = () => { reject(new TypeError("Request failed")) } } fetch("//swapi.dev/api/people/1") // Register handleResponse to run when promise resolves .then(handleResponse) .catch(handleError) // conceptually, the promise looks like this now: // { status: "pending", onsuccess: [handleResponse], onfailure: [handleError] } const handleResponse = (response) => { // handleResponse will automatically receive the response, ¨ // because the promise resolves with a value and automatically injects into the function console.log(response) } const handleError = (response) => { // handleError will automatically receive the error, ¨ // because the promise resolves with a value and automatically injects into the function console.log(response) } // the promise will either resolve or reject causing it to run all of the registered functions in the respective arrays // injecting the value. Let's inspect the happy path: // 1. XHR event listener fires // 2. If the request was successfull, the onload event listener triggers // 3. The onload fires the resolve(VALUE) function with given value // 4. Resolve triggers and schedules the functions registered with .then 

So we can use promises to do asynchronous work, and to be sure that we can handle any result from those promises. That is the value proposition. If you want to know more about promises you can read more about them here and here.

When we use promises, we chain our functions onto the promise to handle the different scenarios.

This works, but we still need to handle our logic inside callbacks (nested functions) once we get our results back. What if we could use promises but write synchronous looking code? It turns out we can.

Async/Await

Async/Await is a way of writing promises that allows us to write asynchronous code in a synchronous way. Let's have a look.

const getData = async () => { const response = await fetch("//jsonplaceholder.typicode.com/todos/1") const data = await response.json() console.log(data) } getData()

Nothing has changed under the hood here. We are still using promises to fetch data, but now it looks synchronous, and we no longer have .then and .catch blocks.

Async / Await is actually just syntactic sugar providing a way to create code that is easier to reason about, without changing the underlying dynamic.

Let's take a look at how it works.

Async/Await lets us use generators to pause the execution of a function. When we are using async / await we are not blocking because the function is yielding the control back over to the main program.

Then when the promise resolves we are using the generator to yield control back to the asynchronous function with the value from the resolved promise.

You can read more here for a great overview of generators and asynchronous code.

In effect, we can now write asynchronous code that looks like synchronous code. Which means that it is easier to reason about, and we can use synchronous tools for error handling such as try / catch:

const getData = async () => { try { const response = await fetch("//jsonplaceholder.typicode.com/todos/1") const data = await response.json() console.log(data) } catch (err) { console.log(err) } } getData()

Alright. So how do we use it? In order to use async / await we need to prepend the function with async. This does not make it an asynchronous function, it merely allows us to use await inside of it.

Failing to provide the async keyword will result in a syntax error when trying to use await inside a regular function.

const getData = async () => { console.log("We can use await in this function") }

Because of this, we can not use async / await on top level code. But async and await are still just syntactic sugar over promises. So we can handle top level cases with promise chaining:

async function getData() { let response = await fetch('//apiurl.com'); } // getData is a promise getData().then(res => console.log(res)).catch(err => console.log(err); 

This exposes another interesting fact about async / await. When defining a function as async, it will always return a promise.

Using async / await can seem like magic at first. But like any magic, it's just sufficiently advanced technology that has evolved over the years. Hopefully now you have a solid grasp of the fundamentals, and can use async / await with confidence.

Conclusion

If you made it here, congrats. You just added a key piece of knowledge about JavaScript and how it works with its environments to your toolbox.

This is definitely a confusing subject, and the lines are not always clear. But now you hopefully have a grasp on how JavaScript works with asynchronous code in the browser, and a stronger grasp over both promises and async / await.

If you enjoyed this article, you might also enjoy my youtube channel. I currently have a web fundamentals series going where I go through HTTP, building web servers from scratch and more.

There's also a series going on building an entire app with React, if that is your jam. And I plan to add much more content here in the future going in depth on JavaScript topics.

And if you want to say hi or chat about web development, you could always reach out to me on twitter at @foseberg. Thanks for reading!