Scrierea sarcinilor asincrone în JavaScript modern
Publicat: 2022-03-10JavaScript are două caracteristici principale ca limbaj de programare, ambele importante pentru a înțelege cum va funcționa codul nostru. În primul rând, este natura sa sincronă , ceea ce înseamnă că codul va rula linie după linie, aproape pe măsură ce îl citiți, iar în al doilea rând că este cu un singur thread , doar o comandă este executată în orice moment.
Pe măsură ce limbajul a evoluat, noi artefacte au apărut în scenă pentru a permite execuția asincronă; dezvoltatorii au încercat abordări diferite în timp ce rezolvau algoritmi și fluxuri de date mai complicate, ceea ce a condus la apariția de noi interfețe și modele în jurul lor.
Execuția sincronă și modelul de observator
După cum sa menționat în introducere, JavaScript rulează codul pe care îl scrieți linie cu linie, de cele mai multe ori. Chiar și în primii ani, limbajul a avut excepții de la această regulă, deși erau câteva și s-ar putea să le cunoașteți deja: solicitări HTTP, evenimente DOM și intervale de timp.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Dacă adăugăm un ascultător de evenimente, de exemplu clicul pe un element, iar utilizatorul declanșează această interacțiune, motorul JavaScript va pune în coadă o sarcină pentru apelarea ascultătorului de evenimente, dar va continua să execute ceea ce este prezent în stiva sa curentă. După ce se termină cu apelurile prezente acolo, acum va rula apelul invers al ascultătorului.
Acest comportament este similar cu ceea ce se întâmplă cu solicitările de rețea și temporizatoarele, care au fost primele artefacte care au acces la execuția asincronă pentru dezvoltatorii web.
Deși acestea au fost excepții ale execuției sincrone comune în JavaScript, este esențial să înțelegem că limbajul este încă cu un singur thread și, deși poate pune în coadă sarcini, le poate rula asincron și apoi se întoarce la firul principal, poate executa doar o singură bucată de cod. la un moment dat.
De exemplu, să verificăm o solicitare de rețea.
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();
Când serverul revine, o sarcină pentru metoda atribuită onreadystatechange
este pusă în coadă (execuția codului continuă în firul principal).
Notă : explicarea modului în care motoarele JavaScript pun în coadă sarcinile și gestionează firele de execuție este un subiect complex de tratat și, probabil, merită un articol propriu. Totuși, recomand să vizionați „What Heck Is The Event Loop Anyway?” de Phillip Roberts pentru a vă ajuta să înțelegeți mai bine.
În fiecare caz menționat, răspundem la un eveniment extern. Un anumit interval de timp atins, o acțiune a utilizatorului sau un răspuns al serverului. Nu am reușit să creăm o sarcină asincronă în sine, am observat întotdeauna evenimente care se întâmplă în afara accesului nostru.
Acesta este motivul pentru care codul astfel format se numește Observer Pattern , care este mai bine reprezentat de interfața addEventListener
în acest caz. În curând, bibliotecile sau cadrele de emitere de evenimente care expun acest tipar au înflorit.
Node.js și emițători de evenimente
Un exemplu bun este Node.js, a cărui pagină se descrie ca „un timp de rulare JavaScript asincron, bazat pe evenimente”, astfel încât emitenții de evenimente și apelurile inverse erau cetățeni de primă clasă. Avea chiar și un constructor EventEmitter
deja implementat.
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!');
Aceasta nu a fost doar abordarea la îndemână pentru execuția asincronă, ci și un model de bază și o convenție a ecosistemului său. Node.js a deschis o nouă eră a scrierii JavaScript într-un mediu diferit - chiar și în afara web. În consecință, au fost posibile alte situații asincrone, cum ar fi crearea de directoare noi sau scrierea fișierelor.
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'); }) } })
S-ar putea să observați că apelurile înapoi primesc o error
ca prim argument, dacă sunt așteptate date de răspuns, aceasta va merge ca un al doilea argument. Acesta a fost numit Error-first Callback Pattern , care a devenit o convenție pe care autorii și colaboratorii au adoptat-o pentru propriile pachete și biblioteci.
Promisiuni și lanțul nesfârșit de apel invers
Pe măsură ce dezvoltarea web se confrunta cu probleme mai complexe de rezolvat, a apărut nevoia unor artefacte asincrone mai bune. Dacă ne uităm la ultimul fragment de cod, putem vedea o înlănțuire repetată a apelurilor care nu se scalează bine pe măsură ce numărul sarcinilor crește.
De exemplu, să mai adăugăm doar doi pași, citirea fișierelor și preprocesarea stilurilor.
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'); }) }) }) })
Putem vedea cum, pe măsură ce programul pe care îl scriem devine mai complex, codul devine mai greu de urmărit pentru ochiul uman din cauza înlănțuirii multiple a apelurilor și a gestionării repetate a erorilor.
Promisiuni, ambalaje și modele de lanț
Promises
nu au primit prea multă atenție când au fost anunțate pentru prima dată ca noua adăugare la limbajul JavaScript, nu sunt un concept nou, deoarece alte limbi au avut implementări similare cu decenii înainte. Adevărul este că s-au dovedit că au schimbat foarte mult semantica și structura majorității proiectelor la care am lucrat de la apariția sa.
Promises
nu numai că a introdus o soluție încorporată pentru dezvoltatori pentru a scrie cod asincron, dar a deschis și o nouă etapă în dezvoltarea web, servind drept bază de construcție a noilor caracteristici ulterioare ale specificațiilor web, cum ar fi fetch
.
Migrarea unei metode de la o abordare de apel invers la una bazată pe promisiuni a devenit din ce în ce mai obișnuită în proiecte (cum ar fi biblioteci și browsere) și chiar și Node.js a început să migreze încet la acestea.
Să împachetăm, de exemplu, metoda readFile
a lui 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); }) }); }
Aici ascundem callback-ul executând în interiorul unui constructor Promise, apelând resolve
când rezultatul metodei este de succes și reject
când obiectul de eroare este definit.
Când o metodă returnează un obiect Promise
, putem urmări rezoluția cu succes trecând o funcție la then
, argumentul său este valoarea pentru care promisiunea a fost rezolvată, în acest caz, data
.

Dacă o eroare a fost aruncată în timpul metodei, funcția catch
va fi apelată, dacă este prezentă.
Notă : Dacă trebuie să înțelegeți mai în profunzime cum funcționează Promises, vă recomand articolul „JavaScript Promises: An Introduction” al lui Jake Archibald, pe care l-a scris pe blogul de dezvoltare web al Google.
Acum putem folosi aceste noi metode și putem evita lanțurile de apel invers.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Având o modalitate nativă de a crea sarcini asincrone și o interfață clară pentru a urmări rezultatele posibile, a permis industriei să iasă din modelul de observator. Cele bazate pe promisiuni păreau să rezolve codul ilizibil și predispus la erori.
Deoarece o evidențiere mai bună a sintaxei sau mesaje de eroare mai clare ajută la codificare, un cod care este mai ușor de raționat devine mai previzibil pentru dezvoltatorul care îl citește, cu o imagine mai bună a căii de execuție, cu atât mai ușor de prins o posibilă capcană.
Adoptarea Promises
a fost atât de globală în comunitate încât Node.js lansează rapid versiuni încorporate ale metodelor sale I/O pentru a returna obiectele Promise, cum ar fi importul lor operațiuni de fișiere din fs.promises
.
A furnizat chiar și un promisify
pentru a încheia orice funcție care a urmat modelul de returnare a primei erori și a o transforma într-una bazată pe Promisiune.
Dar Promises ajută în toate cazurile?
Să ne reimaginăm sarcina noastră de preprocesare a stilului scrisă cu 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))
Există o reducere clară a redundanței în cod, în special în ceea ce privește gestionarea erorilor, deoarece acum ne bazăm pe catch
, dar Promises nu a reușit cumva să furnizeze o indentare clară a codului care se referă direct la concatenarea acțiunilor.
Acest lucru se realizează de fapt la prima instrucțiune then
după readFile
. Ceea ce se întâmplă după aceste rânduri este necesitatea de a crea un nou scop în care să putem face mai întâi directorul, pentru a scrie ulterior rezultatul într-un fișier. Acest lucru determină o întrerupere a ritmului de indentare, nefiind ușoară determinarea secvenței instrucțiunilor la prima vedere.
O modalitate de a rezolva acest lucru este să precoceți o metodă personalizată care se ocupă de acest lucru și care permite concatenarea corectă a metodei, dar am introduce încă o adâncime de complexitate unui cod care pare să aibă deja ceea ce are nevoie pentru a realiza sarcina. noi vrem.
Notă : Luați în considerare că acesta este un exemplu de program și deținem controlul asupra unora dintre metode și toate urmează o convenție a industriei, dar nu este întotdeauna cazul. Cu concatenări mai complexe sau cu introducerea unei biblioteci cu o formă diferită, stilul nostru de cod se poate rupe cu ușurință.
Cu bucurie, comunitatea JavaScript a învățat din nou din sintaxele altor limbi și a adăugat o notație care ajută foarte mult în aceste cazuri în care concatenarea sarcinilor asincrone nu este la fel de plăcută sau simplă de citit precum este codul sincron.
Async și așteptați
O Promise
este definită ca o valoare nerezolvată în momentul execuției, iar crearea unei instanțe a unei Promise
este un apel explicit al acestui artefact.
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))
În cadrul unei metode asincrone, putem folosi cuvântul await
rezervat pentru a determina rezoluția unei Promise
înainte de a continua execuția acesteia.
Să revedem sau să facem un fragment de cod folosind această sintaxă.
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()
Notă : Observați că trebuie să mutăm tot codul într-o metodă, deoarece nu putem folosi await
în afara domeniului de aplicare a unei funcții asincrone astăzi.
De fiecare dată când o metodă asincronă găsește o instrucțiune await
, se va opri până când valoarea sau promisiunea de procedură sunt rezolvate.
Există o consecință clară a utilizării notației async/wait, în ciuda execuției sale asincrone, codul arată ca și cum ar fi sincron , ceea ce noi dezvoltatorii suntem mai obișnuiți să vedem și să raționăm.
Cum rămâne cu gestionarea erorilor? Pentru aceasta, folosim declarații care au fost prezente de mult timp în limbă, try
și 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()
Suntem siguri că orice eroare aruncată în proces va fi gestionată de codul din interiorul instrucțiunii catch
. Avem un loc central care se ocupă de tratarea erorilor, dar acum avem un cod care este mai ușor de citit și urmat.
Având acțiuni consecutive care au returnat valoare, nu trebuie să fie stocate în variabile precum mkdir
care nu încalcă ritmul codului; De asemenea, nu este nevoie să creați un nou domeniu pentru a accesa valoarea result
într-un pas ulterior.
Este sigur să spunem că Promises au fost un artefact fundamental introdus în limbaj, necesar pentru a activa notația async/wait în JavaScript, pe care o puteți utiliza atât pe browserele moderne, cât și pe cele mai recente versiuni ale Node.js.
Notă : Recent, în JSConf, Ryan Dahl, creatorul și primul colaborator al Node, a regretat că nu a respectat Promises la dezvoltarea sa timpurie, mai ales pentru că scopul lui Node era să creeze servere bazate pe evenimente și management de fișiere pentru care modelul Observer a servit mai bine.
Concluzie
Introducerea Promises în lumea dezvoltării web a schimbat modul în care punem la coadă acțiunile în codul nostru și a schimbat modul în care raționăm despre execuția codului nostru și modul în care cream biblioteci și pachete.
Dar îndepărtarea de lanțurile de apel invers este mai greu de rezolvat, cred că a trebui să trecem o metodă pentru then
nu ne-a ajutat să ne îndepărtăm de trenul gândurilor după ani în care ne-am obișnuit cu Modelul Observator și cu abordările adoptate de marii furnizori. în comunitate precum Node.js.
După cum spune Nolan Lawson în excelentul său articol despre utilizările greșite în concatenările Promise, vechile obiceiuri de apelare mor greu ! Mai târziu, el explică cum să scapi de unele dintre aceste capcane.
Cred că promisiunile au fost necesare ca pas de mijloc pentru a permite o modalitate naturală de a genera sarcini asincrone, dar nu ne-au ajutat prea mult să avansăm pe modele de cod mai bune, uneori aveți nevoie de o sintaxă de limbaj mai adaptabilă și îmbunătățită.
Pe măsură ce încercăm să rezolvăm puzzle-uri mai complexe folosind JavaScript, vedem nevoia unui limbaj mai matur și experimentăm arhitecturi și modele pe care nu eram obișnuiți să le vedem pe web înainte.
„
Încă nu știm cum va arăta specificațiile ECMAScript peste ani, deoarece extindem întotdeauna guvernanța JavaScript în afara web și încercăm să rezolvăm puzzle-uri mai complicate.
Este greu de spus acum de ce vom avea nevoie exact de la limbaj pentru ca unele dintre aceste puzzle-uri să se transforme în programe mai simple, dar sunt mulțumit de modul în care web-ul și JavaScript însuși mută lucrurile, încercând să se adapteze provocărilor și noilor medii. Simt că acum JavaScript este un loc mai prietenos asincron decât atunci când am început să scriu cod într-un browser în urmă cu peste un deceniu.
Lectură suplimentară
- „Promisiuni JavaScript: o introducere”, Jake Archibald
- „Promise Anti-Patterns”, o documentație a bibliotecii Bluebird
- „Avem o problemă cu promisiunile”, Nolan Lawson