Asynchrone Aufgaben in modernem JavaScript schreiben
Veröffentlicht: 2022-03-10JavaScript hat als Programmiersprache zwei Hauptmerkmale, die beide wichtig sind, um zu verstehen, wie unser Code funktioniert. Erstens ist es synchron , was bedeutet, dass der Code Zeile für Zeile ausgeführt wird, fast so, wie Sie ihn lesen, und zweitens, dass er Single-Threaded ist, es wird immer nur ein Befehl ausgeführt.
Als sich die Sprache weiterentwickelte, erschienen neue Artefakte in der Szene, um eine asynchrone Ausführung zu ermöglichen; Entwickler versuchten verschiedene Ansätze, während sie kompliziertere Algorithmen und Datenflüsse lösten, was zur Entstehung neuer Schnittstellen und Muster um sie herum führte.
Synchrone Ausführung und das Beobachtermuster
Wie in der Einleitung erwähnt, führt JavaScript den von Ihnen geschriebenen Code meistens Zeile für Zeile aus. Schon in den Anfangsjahren der Sprache gab es Ausnahmen von dieser Regel, obwohl es nur wenige waren und Sie sie vielleicht bereits kennen: HTTP-Anforderungen, DOM-Ereignisse und Zeitintervalle.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Wenn wir einen Ereignis-Listener hinzufügen, z. B. das Klicken auf ein Element, und der Benutzer diese Interaktion auslöst, stellt die JavaScript-Engine eine Aufgabe für den Rückruf des Ereignis-Listeners in die Warteschlange, führt aber weiterhin aus, was in ihrem aktuellen Stack vorhanden ist. Nachdem es mit den dort vorhandenen Anrufen fertig ist, wird es nun den Rückruf des Listeners ausführen.
Dieses Verhalten ähnelt dem, was bei Netzwerkanfragen und Timern passiert, die die ersten Artefakte für den Zugriff auf die asynchrone Ausführung für Webentwickler waren.
Obwohl dies Ausnahmen der üblichen synchronen Ausführung in JavaScript waren, ist es wichtig zu verstehen, dass die Sprache immer noch Single-Threaded ist und zwar Tasks in die Warteschlange stellen, sie asynchron ausführen und dann zum Haupt-Thread zurückkehren kann, aber nur ein Stück Code ausführen kann zu einer Zeit.
Sehen wir uns zum Beispiel eine Netzwerkanfrage an.
var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();
Wenn der Server zurückkehrt, wird eine Aufgabe für die onreadystatechange
zugewiesene Methode in die Warteschlange gestellt (die Codeausführung wird im Hauptthread fortgesetzt).
Hinweis : Zu erklären, wie JavaScript-Engines Aufgaben in die Warteschlange stellen und Ausführungs-Threads handhaben, ist ein komplexes Thema, das behandelt werden muss und wahrscheinlich einen eigenen Artikel verdient. Trotzdem empfehle ich, „Was zum Teufel ist die Ereignisschleife überhaupt?“ anzuschauen. von Phillip Roberts zum besseren Verständnis.
In jedem der genannten Fälle reagieren wir auf ein externes Ereignis. Ein bestimmtes Zeitintervall ist erreicht, eine Benutzeraktion oder eine Serverantwort. Wir waren nicht in der Lage, eine asynchrone Aufgabe per se zu erstellen, wir haben immer Ereignisse beobachtet , die sich außerhalb unserer Reichweite abspielten.
Aus diesem Grund wird der so geformte Code als Observer Pattern bezeichnet, das in diesem Fall besser durch die Schnittstelle addEventListener
dargestellt wird. Bald florierten Event-Emitter-Bibliotheken oder -Frameworks, die dieses Muster offenlegten.
Node.js und Event-Emitter
Ein gutes Beispiel ist Node.js, dessen Seite sich selbst als „eine asynchrone ereignisgesteuerte JavaScript-Laufzeit“ beschreibt, sodass Ereignisemitter und Callback erstklassige Bürger waren. Es hatte sogar bereits einen EventEmitter
-Konstruktor implementiert.
const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');
Dies war nicht nur der To-Go-Ansatz für die asynchrone Ausführung, sondern ein Kernmuster und eine Konvention seines Ökosystems. Node.js eröffnete eine neue Ära des Schreibens von JavaScript in einer anderen Umgebung – sogar außerhalb des Webs. Infolgedessen waren andere asynchrone Situationen möglich, wie das Erstellen neuer Verzeichnisse oder das Schreiben von Dateien.
const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })
Möglicherweise stellen Sie fest, dass Rückrufe als erstes Argument einen error
erhalten. Wenn Antwortdaten erwartet werden, werden diese als zweites Argument verwendet. Dies wurde als Error-first Callback Pattern bezeichnet und wurde zu einer Konvention, die Autoren und Mitwirkende für ihre eigenen Pakete und Bibliotheken übernahmen.
Versprechen und die endlose Rückrufkette
Als die Webentwicklung immer komplexere Probleme zu lösen hatte, entstand der Bedarf an besseren asynchronen Artefakten. Wenn wir uns das letzte Code-Snippet ansehen, sehen wir eine wiederholte Callback-Verkettung, die mit zunehmender Anzahl von Aufgaben nicht gut skaliert.
Lassen Sie uns beispielsweise nur zwei weitere Schritte hinzufügen, das Lesen von Dateien und die Vorverarbeitung von Stilen.
const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })
Wir können sehen, wie das Programm, das wir schreiben, komplexer wird und der Code für das menschliche Auge aufgrund mehrfacher Callback-Verkettung und wiederholter Fehlerbehandlung schwieriger zu verfolgen ist.
Versprechen, Verpackungen und Kettenmuster
Promises
erhielten nicht viel Aufmerksamkeit, als sie zum ersten Mal als neue Ergänzung der JavaScript-Sprache angekündigt wurden. Sie sind kein neues Konzept, da andere Sprachen Jahrzehnte zuvor ähnliche Implementierungen hatten. Die Wahrheit ist, dass sie die Semantik und Struktur der meisten Projekte, an denen ich seit ihrem Erscheinen gearbeitet habe, stark verändert haben.
Promises
führte nicht nur eine integrierte Lösung für Entwickler ein, um asynchronen Code zu schreiben, sondern eröffnete auch eine neue Stufe in der Webentwicklung, die als Konstruktionsbasis für spätere neue Funktionen der Webspezifikation wie fetch
diente.
Die Migration einer Methode von einem Callback-Ansatz zu einem Promise-basierten Ansatz wurde in Projekten (wie Bibliotheken und Browsern) immer üblicher, und sogar Node.js begann langsam, zu ihnen zu migrieren.
Lassen Sie uns zum Beispiel die readFile
-Methode von Node umschließen:
const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }
Hier verschleiern wir den Rückruf, indem wir ihn innerhalb eines Promise-Konstruktors ausführen, resolve
wenn das Ergebnis der Methode erfolgreich ist, und reject
, wenn das Fehlerobjekt definiert ist.
Wenn eine Methode ein Promise
Objekt zurückgibt, können wir seine erfolgreiche Auflösung verfolgen, indem wir eine Funktion an then
übergeben, deren Argument der Wert ist, mit dem das Promise aufgelöst wurde, in diesem Fall data
.
Wenn während der Methode ein Fehler geworfen wurde, wird die catch
-Funktion aufgerufen, falls vorhanden.
Hinweis : Wenn Sie die Funktionsweise von Promises genauer verstehen möchten, empfehle ich den Artikel „JavaScript Promises: An Introduction“ von Jake Archibald, den er im Webentwicklungsblog von Google geschrieben hat.
Jetzt können wir diese neuen Methoden verwenden und Callback-Ketten vermeiden.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Eine native Möglichkeit zur Erstellung asynchroner Aufgaben und eine klare Schnittstelle zur Nachverfolgung möglicher Ergebnisse ermöglichten es der Branche, sich aus dem Beobachtermuster herauszubewegen. Promise-basierte schienen den unlesbaren und fehleranfälligen Code zu lösen.
Da eine bessere Syntaxhervorhebung oder klarere Fehlermeldungen beim Codieren helfen, wird ein Code, der einfacher zu begründen ist, für den Entwickler, der ihn liest, vorhersehbarer, und mit einem besseren Bild des Ausführungspfads ist es einfacher, mögliche Fallstricke zu erkennen.
Die Akzeptanz von Promises
war in der Community so global, dass Node.js schnell integrierte Versionen seiner E/A-Methoden veröffentlichte, um Promise-Objekte wie das Importieren von Dateioperationen aus fs.promises
.
Es stellte sogar ein promisify
-Utility zur Verfügung, um jede Funktion, die dem Fehler-zuerst-Callback-Muster folgte, zu verpacken und in ein Promise-basiertes umzuwandeln.
Aber helfen Versprechen in allen Fällen?
Stellen wir uns unsere mit Promises geschriebene Stilvorverarbeitungsaufgabe neu vor.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))
Es gibt eine deutliche Reduzierung der Redundanz im Code, insbesondere bei der Fehlerbehandlung, da wir uns jetzt auf catch
verlassen, aber Promises hat es irgendwie versäumt, einen klaren Codeeinzug zu liefern, der sich direkt auf die Verkettung von Aktionen bezieht.
Dies wird tatsächlich mit der ersten then
-Anweisung nach dem Aufruf von readFile
erreicht. Was nach diesen Zeilen passiert, ist die Notwendigkeit, einen neuen Bereich zu erstellen, in dem wir zuerst das Verzeichnis erstellen können, um das Ergebnis später in eine Datei zu schreiben. Dadurch wird der Einrückrhythmus unterbrochen , was es nicht einfach macht, die Reihenfolge der Anweisungen auf den ersten Blick zu bestimmen.
Eine Möglichkeit, dies zu lösen, besteht darin, eine benutzerdefinierte Methode zu erstellen, die dies handhabt und die korrekte Verkettung der Methode ermöglicht, aber wir würden einem Code, der bereits das zu haben scheint, was er zum Erfüllen der Aufgabe benötigt, eine weitere Komplexitätstiefe hinzufügen wir wollen.
Hinweis : Beachten Sie, dass dies ein Beispielprogramm ist, und wir haben die Kontrolle über einige der Methoden, und sie folgen alle einer Branchenkonvention, aber das ist nicht immer der Fall. Bei komplexeren Verkettungen oder der Einführung einer Bibliothek mit einer anderen Form kann unser Codestil leicht brechen.
Glücklicherweise hat die JavaScript-Community wieder von anderen Sprachsyntaxen gelernt und eine Notation hinzugefügt, die in diesen Fällen sehr hilfreich ist, in denen die Verkettung von asynchronen Aufgaben nicht so angenehm oder einfach zu lesen ist wie synchroner Code.
Async und warten
Ein Promise
ist als ein nicht aufgelöster Wert zur Ausführungszeit definiert, und das Erstellen einer Instanz eines Promise
ist ein expliziter Aufruf dieses Artefakts.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))
Innerhalb einer asynchronen Methode können wir das reservierte Wort await
verwenden, um die Auflösung eines Promise
zu bestimmen, bevor wir mit seiner Ausführung fortfahren.
Sehen wir uns dieses Code-Snippet mit dieser Syntax noch einmal an.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()
Hinweis : Beachten Sie, dass wir unseren gesamten Code in eine Methode verschieben mussten, da wir await
heute nicht außerhalb des Bereichs einer asynchronen Funktion verwenden können.
Jedes Mal, wenn eine asynchrone Methode eine await
-Anweisung findet, stoppt sie die Ausführung, bis der fortschreitende Wert oder das Versprechen aufgelöst wird.
Die Verwendung der async/await-Notation hat eine klare Konsequenz: Trotz der asynchronen Ausführung sieht der Code so aus, als wäre er synchron , was wir Entwickler eher gewohnt sind zu sehen und zu begründen.
Was ist mit der Fehlerbehandlung? Dafür verwenden wir Aussagen, die schon lange in der Sprache vorhanden sind, try
and catch
.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()
Wir können sicher sein, dass jeder im Prozess ausgelöste Fehler vom Code in der catch
-Anweisung behandelt wird. Wir haben einen zentralen Ort, der sich um die Fehlerbehandlung kümmert, aber jetzt haben wir einen Code, der einfacher zu lesen und zu befolgen ist.
Folgeaktionen, die einen Wert zurückgeben, müssen nicht in Variablen wie mkdir
gespeichert werden, die den Code-Rhythmus nicht unterbrechen; Es ist auch nicht erforderlich, einen neuen Bereich zu erstellen, um in einem späteren Schritt auf den Wert von result
zuzugreifen.
Man kann mit Sicherheit sagen, dass Promises ein grundlegendes Artefakt waren, das in die Sprache eingeführt wurde und notwendig war, um die Async/await-Notation in JavaScript zu aktivieren, die Sie sowohl in modernen Browsern als auch in den neuesten Versionen von Node.js verwenden können.
Hinweis : Kürzlich bedauerte Ryan Dahl, Schöpfer und erster Mitwirkender von Node, in JSConf, dass er sich bei seiner frühen Entwicklung nicht an Promises gehalten hatte, hauptsächlich weil das Ziel von Node darin bestand, ereignisgesteuerte Server und Dateiverwaltung zu erstellen, für die das Observer-Muster besser geeignet war.
Fazit
Die Einführung von Promises in die Welt der Webentwicklung veränderte die Art und Weise, wie wir Aktionen in unserem Code in die Warteschlange stellen, und veränderte die Art und Weise, wie wir über unsere Codeausführung nachdenken und wie wir Bibliotheken und Pakete schreiben.
Aber die Abkehr von Callback-Ketten ist schwieriger zu lösen. Ich denke, dass die Übergabe einer Methode an then
uns nicht geholfen hat, uns von dem Gedankengang zu entfernen, nachdem wir uns jahrelang an das Beobachtermuster und die von großen Anbietern übernommenen Ansätze gewöhnt hatten in der Community wie Node.js.
Wie Nolan Lawson in seinem exzellenten Artikel über falsche Verwendungen in Promise-Verkettungen sagt, sterben alte Callback-Gewohnheiten nur schwer ! Später erklärt er, wie man einigen dieser Fallstricke entkommen kann.
Ich glaube, Promises wurden als Mittelschritt benötigt, um auf natürliche Weise asynchrone Aufgaben zu generieren, aber sie haben uns nicht viel dabei geholfen, bessere Codemuster voranzutreiben, manchmal braucht man tatsächlich eine anpassungsfähigere und verbesserte Sprachsyntax.
Während wir versuchen, komplexere Rätsel mit JavaScript zu lösen, sehen wir die Notwendigkeit einer ausgereifteren Sprache und experimentieren mit Architekturen und Mustern, die wir zuvor im Web nicht zu sehen gewöhnt waren.
„
Wir wissen immer noch nicht, wie die ECMAScript-Spezifikation in Jahren aussehen wird, da wir die JavaScript-Governance immer außerhalb des Webs erweitern und versuchen, kompliziertere Rätsel zu lösen.
Es ist jetzt schwer zu sagen, was wir genau von der Sprache brauchen werden, damit einige dieser Puzzles in einfachere Programme umgewandelt werden können, aber ich bin zufrieden damit, wie das Web und JavaScript selbst Dinge bewegen und versuchen, sich an Herausforderungen und neue Umgebungen anzupassen. Ich habe das Gefühl, dass JavaScript im Moment asynchroner ist als zu der Zeit, als ich vor über einem Jahrzehnt damit begann, Code in einem Browser zu schreiben.
Weiterführende Lektüre
- „JavaScript-Versprechen: Eine Einführung“, Jake Archibald
- „Promise Anti-Patterns“, eine Bluebird-Bibliotheksdokumentation
- „Wir haben ein Problem mit Versprechungen“, Nolan Lawson