Scrivere attività asincrone in JavaScript moderno
Pubblicato: 2022-03-10JavaScript ha due caratteristiche principali come linguaggio di programmazione, entrambe importanti per capire come funzionerà il nostro codice. In primo luogo è la sua natura sincrona , il che significa che il codice verrà eseguito riga dopo riga, quasi mentre lo si legge, e in secondo luogo che è a thread singolo , viene eseguito un solo comando alla volta.
Con l'evoluzione del linguaggio, nella scena sono apparsi nuovi artefatti per consentire l'esecuzione asincrona; gli sviluppatori hanno provato approcci diversi risolvendo algoritmi e flussi di dati più complicati, il che ha portato all'emergere di nuove interfacce e modelli attorno a loro.
Esecuzione sincrona e modello dell'osservatore
Come accennato nell'introduzione, JavaScript esegue il codice che scrivi riga per riga, la maggior parte delle volte. Anche nei suoi primi anni, il linguaggio ha avuto eccezioni a questa regola, anche se erano poche e potresti già conoscerle: Richieste HTTP, eventi DOM e intervalli di tempo.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Se aggiungiamo un listener di eventi, ad esempio il clic di un elemento e l'utente attiva questa interazione, il motore JavaScript metterà in coda un'attività per il callback del listener di eventi ma continuerà a eseguire ciò che è presente nel suo stack corrente. Dopo aver terminato con le chiamate presenti lì, ora eseguirà la richiamata dell'ascoltatore.
Questo comportamento è simile a quello che accade con le richieste di rete e i timer, che sono stati i primi artefatti ad accedere all'esecuzione asincrona per gli sviluppatori web.
Sebbene queste fossero eccezioni dell'esecuzione sincrona comune in JavaScript, è fondamentale capire che il linguaggio è ancora a thread singolo e sebbene possa accodare le operazioni, eseguirle in modo asincrono e quindi tornare al thread principale, può eseguire solo un pezzo di codice Al tempo.
Ad esempio, esaminiamo una richiesta di rete.
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();
Quando il server ritorna, viene accodata un'attività per il metodo assegnato a onreadystatechange
(l'esecuzione del codice continua nel thread principale).
Nota : Spiegare come i motori JavaScript accodano le attività e gestiscano i thread di esecuzione è un argomento complesso da trattare e probabilmente merita un articolo a parte. Tuttavia, consiglio di guardare "What The Heck Is The Event Loop comunque?" di Phillip Roberts per aiutarti a capire meglio.
In ogni caso menzionato, stiamo rispondendo a un evento esterno. Un determinato intervallo di tempo raggiunto, un'azione dell'utente o una risposta del server. Non siamo stati in grado di creare un'attività asincrona di per sé, abbiamo sempre osservato eventi che si verificano al di fuori della nostra portata.
Questo è il motivo per cui il codice modellato in questo modo è chiamato Observer Pattern , che in questo caso è rappresentato meglio dall'interfaccia addEventListener
. Presto fiorirono librerie o framework di emettitori di eventi che esponevano questo modello.
Node.js ed emettitori di eventi
Un buon esempio è Node.js, la cui pagina si descrive come "un runtime JavaScript asincrono guidato da eventi", quindi gli emettitori di eventi e le callback erano cittadini di prima classe. Aveva anche un costruttore EventEmitter
già implementato.
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!');
Questo non era solo l'approccio to-go per l'esecuzione asincrona, ma un modello fondamentale e una convenzione del suo ecosistema. Node.js ha aperto una nuova era di scrittura di JavaScript in un ambiente diverso, anche al di fuori del Web. Di conseguenza, erano possibili altre situazioni asincrone, come la creazione di nuove directory o la scrittura di file.
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'); }) } })
Potresti notare che i callback ricevono un error
come primo argomento, se è previsto un dato di risposta, va come secondo argomento. Questo è stato chiamato Error-first Callback Pattern , che è diventato una convenzione che autori e contributori hanno adottato per i propri pacchetti e librerie.
Promesse e la catena infinita di richiamate
Poiché lo sviluppo web ha dovuto affrontare problemi più complessi da risolvere, è emersa la necessità di migliori artefatti asincroni. Se osserviamo l'ultimo frammento di codice, possiamo vedere un concatenamento di richiamata ripetuto che non si adatta bene all'aumentare del numero di attività.
Ad esempio, aggiungiamo solo altri due passaggi, la lettura dei file e la preelaborazione degli stili.
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'); }) }) }) })
Possiamo vedere come man mano che il programma che stiamo scrivendo diventa più complesso, il codice diventa più difficile da seguire per l'occhio umano a causa del concatenamento multiplo di callback e della ripetuta gestione degli errori.
Promesse, involucri e modelli di catena
Promises
non hanno ricevuto molta attenzione quando sono state annunciate per la prima volta come la nuova aggiunta al linguaggio JavaScript, non sono un concetto nuovo poiché altri linguaggi avevano implementazioni simili decenni prima. La verità è che hanno cambiato molto la semantica e la struttura della maggior parte dei progetti su cui ho lavorato sin dalla sua comparsa.
Promises
non solo ha introdotto una soluzione integrata per consentire agli sviluppatori di scrivere codice asincrono, ma ha anche aperto una nuova fase nello sviluppo Web che funge da base per la costruzione di nuove funzionalità successive delle specifiche Web come fetch
.
La migrazione di un metodo da un approccio callback a uno basato su promesse è diventato sempre più comune nei progetti (come librerie e browser) e anche Node.js ha iniziato a migrare lentamente verso di essi.
Ad esempio, avvolgiamo il metodo readFile
di Node:
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); }) }); }
Qui oscuriamo il callback eseguendo all'interno di un costruttore Promise, chiamando resolve
quando il risultato del metodo ha esito positivo e reject
quando viene definito l'oggetto errore.
Quando un metodo restituisce un oggetto Promise
, possiamo seguirne la risoluzione passando una funzione a then
, il suo argomento è il valore con cui è stata risolta la promessa, in questo caso data
.
Se viene generato un errore durante il metodo, verrà chiamata la funzione catch
, se presente.
Nota : se hai bisogno di capire in modo più approfondito come funzionano le promesse, ti consiglio l'articolo "JavaScript Promises: An Introduction" di Jake Archibald che ha scritto sul blog di sviluppo web di Google.
Ora possiamo usare questi nuovi metodi ed evitare catene di callback.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Avere un modo nativo per creare attività asincrone e un'interfaccia chiara per seguirne i possibili risultati ha consentito al settore di uscire dal modello Observer. Quelli basati su promesse sembravano risolvere il codice illeggibile e soggetto a errori.
Poiché una migliore evidenziazione della sintassi o messaggi di errore più chiari aiutano durante la codifica, un codice più facile da ragionare diventa più prevedibile per lo sviluppatore che lo legge, con un'immagine migliore del percorso di esecuzione più facile da cogliere una possibile trappola.
L'adozione di Promises
è stata così globale nella comunità che Node.js ha rilasciato rapidamente versioni integrate dei suoi metodi I/O per restituire oggetti Promise come l'importazione di operazioni sui file da fs.promises
.
Ha anche fornito un programma di promisify
per avvolgere qualsiasi funzione che ha seguito il pattern di callback Error-first e trasformarlo in uno basato su Promise.
Ma le promesse aiutano in tutti i casi?
Immaginiamo nuovamente la nostra attività di preelaborazione dello stile scritta con Promises.
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))
C'è una chiara riduzione della ridondanza nel codice, in particolare per quanto riguarda la gestione degli errori poiché ora ci affidiamo a catch
, ma Promises in qualche modo non è riuscito a fornire una chiara indentazione del codice che si riferisce direttamente alla concatenazione delle azioni.
Ciò si ottiene effettivamente sulla prima istruzione then
dopo che readFile
è stato chiamato. Quello che succede dopo queste righe è la necessità di creare un nuovo ambito in cui possiamo prima creare la directory, per poi scrivere il risultato in un file. Ciò provoca un'interruzione nel ritmo di rientro, non rendendo facile determinare la sequenza delle istruzioni a prima vista.
Un modo per risolvere questo problema è creare un metodo personalizzato che lo gestisca e consenta la corretta concatenazione del metodo, ma introdurremmo un'ulteriore profondità di complessità in un codice che sembra già avere ciò di cui ha bisogno per raggiungere l'attività vogliamo.
Nota : tieni conto che questo è un programma di esempio e abbiamo il controllo su alcuni metodi e tutti seguono una convenzione del settore, ma non è sempre così. Con concatenazioni più complesse o l'introduzione di una libreria con una forma diversa, il nostro stile di codice può facilmente interrompersi.
Fortunatamente, la comunità JavaScript ha imparato di nuovo dalle sintassi di altri linguaggi e ha aggiunto una notazione che aiuta molto in questi casi in cui la concatenazione di attività asincrone non è piacevole o semplice da leggere come lo è il codice sincrono.
Asincrono e attendi
Una Promise
è definita come un valore non risolto al momento dell'esecuzione e la creazione di un'istanza di una Promise
è una chiamata esplicita di questo artefatto.
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))
All'interno di un metodo asincrono, possiamo utilizzare la parola await
riservato per determinare la risoluzione di una Promise
prima di continuare la sua esecuzione.
Rivisitiamo o snippet di codice usando questa sintassi.
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()
Nota : si noti che era necessario spostare tutto il codice in un metodo perché oggi non è possibile utilizzare await
al di fuori dell'ambito di una funzione asincrona.
Ogni volta che un metodo asincrono trova un'istruzione await
, interrompe l'esecuzione fino a quando il valore o la promessa non vengono risolti.
C'è una chiara conseguenza dell'uso della notazione async/await, nonostante la sua esecuzione asincrona, il codice sembra come se fosse synchronous , che è qualcosa che noi sviluppatori siamo più abituati a vedere e ragionare.
E la gestione degli errori? Per questo, utilizziamo affermazioni che sono presenti da molto tempo nella lingua, 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()
Siamo certi che qualsiasi errore generato nel processo verrà gestito dal codice all'interno catch
. Abbiamo un punto centrale che si occupa della gestione degli errori, ma ora abbiamo un codice più facile da leggere e da seguire.
Avere azioni conseguenti che restituiscono valore non ha bisogno di essere memorizzato in variabili come mkdir
che non interrompono il ritmo del codice; non è inoltre necessario creare un nuovo ambito per accedere al valore del result
in un passaggio successivo.
È sicuro affermare che le promesse erano un artefatto fondamentale introdotto nel linguaggio, necessario per abilitare la notazione async/await in JavaScript, che puoi utilizzare sia sui browser moderni che sulle ultime versioni di Node.js.
Nota : recentemente in JSConf, Ryan Dahl, creatore e primo collaboratore di Node, si è rammaricato di non essersi attenuto a Promises durante il suo sviluppo iniziale principalmente perché l'obiettivo di Node era creare server basati su eventi e gestione dei file per i quali il modello Observer serviva meglio.
Conclusione
L'introduzione di Promises nel mondo dello sviluppo web ha cambiato il modo in cui accodiamo le azioni nel nostro codice e ha cambiato il modo in cui ragioniamo sull'esecuzione del codice e il modo in cui creiamo librerie e pacchetti.
Ma allontanarsi dalle catene di callback è più difficile da risolvere, penso che dover passare un metodo ad then
non ci abbia aiutato ad allontanarci dal filo dei pensieri dopo anni in cui ci siamo abituati all'Observer Pattern e agli approcci adottati dai principali fornitori nella comunità come Node.js.
Come dice Nolan Lawson nel suo eccellente articolo sugli usi sbagliati nelle concatenazioni Promise, le vecchie abitudini di callback sono dure a morire ! In seguito spiega come sfuggire ad alcune di queste insidie.
Credo che le promesse fossero necessarie come passaggio intermedio per consentire un modo naturale di generare attività asincrone, ma non ci ha aiutato molto ad andare avanti su modelli di codice migliori, a volte è necessaria una sintassi del linguaggio più adattabile e migliorata.
Mentre cerchiamo di risolvere enigmi più complessi utilizzando JavaScript, vediamo la necessità di un linguaggio più maturo e sperimentiamo architetture e modelli che non eravamo abituati a vedere prima sul web.
“
Non sappiamo ancora come appariranno le specifiche ECMAScript negli anni poiché estendiamo sempre la governance di JavaScript al di fuori del Web e cerchiamo di risolvere enigmi più complicati.
È difficile dire ora di cosa avremo bisogno esattamente dal linguaggio affinché alcuni di questi enigmi si trasformino in programmi più semplici, ma sono contento di come il web e lo stesso JavaScript stiano spostando le cose, cercando di adattarsi alle sfide e ai nuovi ambienti. Sento che in questo momento JavaScript è un luogo più asincrono rispetto a quando ho iniziato a scrivere codice in un browser oltre un decennio fa.
Ulteriori letture
- "JavaScript Promise: un'introduzione", Jake Archibald
- "Promise Anti-Patterns", una documentazione della libreria Bluebird
- "Abbiamo un problema con le promesse", Nolan Lawson