Analizăm Async/Await în JavaScript prin exemple

Autorul articolului analizează, prin exemple, Async/Await în JavaScript. În general, Async/Await este un mod convenabil de a scrie cod asincron. Până la apariția acestei funcționalități, codul similar era scris folosind callback-uri și promisiuni. Autorul articolului original explică avantajele Async/Await, analizând diverse exemple.

Vă reamintim: pentru toți cititorii „Habr” — reducere de 10.000 de ruble la înscrierea la orice curs Skillbox cu codul de promovare „Habr”.

Skillbox recomandă: Curs online educațional «Dezvoltator Java».

Callback

Callback-ul este o funcție a cărei invocare este amânată pe o perioadă nedeterminată. În trecut, callback-urile erau folosite în acele segmente de cod în care rezultatul nu putea fi obținut imediat.

Iată un exemplu de citire asincronă a unui fișier în Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Problemele apar în momentul în care trebuie să executăm simultan mai multe operațiuni asincrone. Să ne imaginăm un astfel de scenariu: se face o interogare în baza de date pentru utilizatorul Arfat, este nevoie să citim câmpul său profile_img_url și să încărcăm imaginea de pe serverul someserver.com.
După încărcare, convertim imaginea într-un alt format, de exemplu din PNG în JPEG. Dacă conversia a avut succes, utilizatorului i se trimite un e-mail. Mai departe, informațiile despre eveniment sunt înregistrate într-un fișier transformations.log cu menționarea datei.

Merită să ne concentrăm asupra suprapunerii callback-urilor și a numărului mare de }) din partea finală a codului. Acest fenomen se numește Callback Hell sau Pyramid of Doom.

Dezavantajele acestui mod sunt evidente:

  • Acest cod este greu de citit.
  • Este, de asemenea, dificil să se gestioneze erorile, ceea ce duce adesea la o calitate mai slabă a codului.

Pentru a rezolva această problemă, în JavaScript au fost adăugate promisiuni. Acestea permit înlocuirea imersiunii adânci a callback-urilor cu cuvântul .then.

Un aspect pozitiv al promisiunilor este că, cu ele, codul este mult mai ușor de citit, din sus în jos, nu din stânga în dreapta. Cu toate acestea, promisiunile prezintă și ele probleme:

  • Trebuie adăugat un număr mare de .then.
  • În loc de try/catch, se folosește .catch pentru gestionarea tuturor erorilor.
  • Lucrul cu mai multe promisiuni în cadrul unui singur ciclu nu este întotdeauna convenabil, în unele cazuri acestea complicând codul.

Iată o sarcină care va ilustra valoarea ultimului punct.

Să presupunem că există o buclă for care afișează o secvență de numere de la 0 la 10 cu un interval aleator (0–n secunde). Folosind promisiuni, trebuie să modificăm această buclă astfel încât numerele să fie afișate în secvență de la 0 la 10. Așadar, dacă afișarea lui 0 durează 6 secunde, iar a lui 1 - 2 secunde, mai întâi trebuie să fie afișat 0, iar apoi să înceapă numărătoarea pentru 1.

Și, desigur, pentru a rezolva această sarcină nu folosim Async/Await sau .sort. Exemplul de soluție va fi la final.

Funcții async

Adăugarea funcțiilor async în ES2017 (ES8) a simplificat sarcina de lucru cu promisiunile. Trebuie să menționez că funcțiile async funcționează „peste” promisiuni. Aceste funcții nu reprezintă concepte calitativ diferite. Funcțiile async au fost gândite ca o alternativă pentru codul care folosește promisiuni.

Async/Await oferă posibilitatea de a organiza lucrul cu codul asincron într-un stil sincron.

Astfel, cunoașterea promisiunilor facilitează înțelegerea principiilor Async/Await.

Sintaxa

În mod obișnuit, aceasta constă din două cuvinte cheie: async și await. Primul cuvânt transformă funcția într-una asincronă. În aceste funcții, utilizarea lui await este permisă. În orice alt caz, utilizarea acestei funcții va genera o eroare.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async este inserat chiar la începutul declarației funcției, iar în cazul utilizării funcției săgeată — între semnul „=” și paranteze.

Aceste funcții pot fi plasate într-un obiect ca metode sau pot fi folosite în declarațiile de clasă.

// 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! Este important de reținut că constructorii de clasă și getterii/setterii nu pot fi asincroni.

Semantica și regulile de execuție

Funcțiile async sunt, în principiu, asemănătoare cu funcțiile standard JS, dar există și excepții.

Așadar, funcțiile async returnează întotdeauna promisiuni:

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

În particular, fn returnează un șir de caractere hello. De vreme ce este o funcție asincronă, valoarea acestui șir este învelită într-o promisiune folosind constructorul.

Iată o construcție alternativă fără Async:

function fn() {
  return Promise.resolve('hello');
}

fn().then(console.log);
// hello

În acest caz, returnarea promisiunii se face „manual”. O funcție asincronă este întotdeauna învelită într-o nouă promisiune.

În cazul în care valoarea returnată este un primitiv, funcția async returnează valoarea, încadrând-o într-un promisiune. Dacă valoarea returnată este un obiect de promisiune, rezolvarea ei va fi returnată într-o nouă promisiune.

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

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

Dar ce se întâmplă dacă există o eroare în interiorul unei funcții asincrone?

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

foo().catch(console.log);

Dacă aceasta nu este gestionată, foo() va returna un promisiune cu reject. În această situație, în loc de Promise.resolve se va returna Promise.reject, conținând eroarea.

Funcțiile async returnează întotdeauna un promisiune, indiferent de ceea ce este returnat.

Funcțiile asincrone se suspendă la fiecare await.

Await afectează expresiile. Astfel, dacă expresia este un promisiune, funcția async se suspendă până la executarea promisiunii. Dacă expresia nu este un promisiune, aceasta este convertită într-un promisiune prin Promise.resolve și apoi se finalizează.

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

Iată descrierea modului în care funcționează funcția fn.

  • După apelul său, prima linie se convertește din const a = await 9; în const a = await Promise.resolve(9);.
  • După utilizarea Await, execuția funcției se suspendă până când a primește valoarea sa (în această situație, 9).
  • delayAndGetRandom(1000) suspendă execuția funcției fn până când aceasta se finalizează (după 1 secundă). Aceasta este, de fapt, o oprire a funcției fn timp de 1 secundă.
  • delayAndGetRandom(1000) returnează prin resolve o valoare aleatorie, care este apoi atribuită variabilei b.
  • Iar cazul variabilei c este similar cu cazul variabilei a. După aceasta, totul se oprește timp de o secundă, dar acum delayAndGetRandom(1000) nu returnează nimic, deoarece nu este necesar.
  • În final, valorile sunt calculate conform formulei a + b * c. Rezultatul este încadrat într-un promisiune cu ajutorul Promise.resolve și returnat funcției.

Aceste pauze pot semăna cu generatoarele din ES6, dar există motivele sale.

Rezolvăm problema

Acum haideți să analizăm soluția problemei menționate mai sus.

Funcția finishMyTask folosește Await pentru a aștepta rezultatele operațiunilor precum queryDatabase, sendEmail, logTaskInFile și altele. Comparând această soluție cu cea care folosea promisiuni, devine evidentă similaritatea. Totuși, versiunea cu Async/Await simplifică considerabil toate complexitățile sintactice. În acest caz, nu există un număr mare de callback-uri și lanțuri precum .then/.catch.

Iată o soluție pentru afișarea numerelor, aici sunt două variante.

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

Iată o soluție folosind funcții async.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Gestionarea erorilor

Erorile negestionate sunt învelite într-o promisiune respinsă. Cu toate acestea, în funcțiile async se poate folosi construcția try/catch pentru a efectua o gestionare sincronă a erorilor.

async function canRejectOrReturn() {
  // așteaptă un second
  await new Promise(res => setTimeout(res, 1000));
 
// Respinge cu ~50% probabilitate
  if (Math.random() > 0.5) {
    throw new Error('Ne pare rău, numărul este prea mare.');
  }
 
return 'număr perfect';
}

canRejectOrReturn() este o funcție asincronă care fie reușește ('număr perfect'), fie eșuează cu o eroare ('Ne pare rău, numărul este prea mare').

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'eroare prinsă';
  }
}

Deoarece în exemplul de mai sus se așteaptă execuția funcției canRejectOrReturn, o eșuare a acesteia va duce la executarea blocului catch. Ca rezultat, funcția foo se va încheia fie cu undefined (când nu se returnează nimic în blocul try), fie cu eroarea prinsă. În final, această funcție nu va avea o încheiere eșuată, deoarece try/catch se va ocupa de gestionarea funcției foo.

Iată un alt exemplu:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'eroare prinsă';
  }
}

Merită menționat că în exemplul din foo se returnează canRejectOrReturn. Foo se va încheia fie cu număr perfect, fie cu eroarea Error ('Ne pare rău, numărul este prea mare'). Blocul catch nu va fi niciodată executat.

Problema este că foo returnează o promisiune transmisă de canRejectOrReturn. Prin urmare, soluția funcției foo devine soluția pentru canRejectOrReturn. În acest caz, codul va consta din doar două linii:

try {
    const promise = canRejectOrReturn();
    return promise;
}

Și iată ce se va întâmpla dacă se utilizează împreună await și return:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'eroare prinsă';
  }
}

În codul de mai sus, funcția foo se va termina cu succes atât cu un număr perfect, cât și cu o eroare capturată. Nu vor exista eșecuri aici. Dar foo se va termina cu canRejectOrReturn, nu cu undefined. Să ne asigurăm de asta, eliminând linia return await canRejectOrReturn():

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Erori frecvente și capcane

În unele cazuri, utilizarea Async/Await poate duce la erori.

Await uitat

Aceasta se întâmplă destul de frecvent — cuvântul cheie await este omis înainte de promisiune:

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

În cod, după cum se poate observa, nu există nici await, nici return. Prin urmare, foo se va finaliza întotdeauna cu undefined fără o întârziere de 1 secundă. Dar promisiunea va fi executată. Dacă aceasta generează o eroare sau este respinsă, va fi apelat UnhandledPromiseRejectionWarning.

Funcții Async în callback-uri

Funcțiile Async sunt adesea utilizate în .map sau .filter ca callback-uri. Un exemplu poate fi funcția fetchPublicReposCount(username), care returnează numărul de repozitorii publici pe GitHub. Să presupunem că avem trei utilizatori ale căror statistici ne sunt necesare. Iată codul pentru această sarcină:

const url = 'https://api.github.com/users';
 
// Funcție utilitară pentru a obține numărul de repo-uri
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Avem nevoie de conturile ArfatSalman, octocat, norvig. În acest caz, executăm:

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

Merită să observăm Await în callback-ul .map. Aici counts este un array de promisiuni, iar .map — un callback anonim pentru fiecare utilizator indicat.

Utilizarea excesivă a await în mod secvențial

Ca exemplu, să luăm următorul cod:

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

Aici, în variabila count este stocat numărul de repozitorii, apoi acest număr este adăugat în array-ul counts. Problema codului este că până nu sosesc datele primului utilizator de pe server, toți utilizatorii următori vor fi în așteptare. Astfel, într-un moment dat este procesat doar un singur utilizator.

De exemplu, dacă prelucrarea unui utilizator durează aproximativ 300 ms, atunci pentru toți utilizatorii deja este o secundă; timpul necesar depinde liniar de numărul de utilizatori. Dar, deoarece obținerea numărului de repozitorii nu depinde una de cealaltă, procesele pot fi desfășurate în paralel. Pentru aceasta, este nevoie de utilizarea .map și Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all primește un array de promisiuni, care returnează o promisiune. Aceasta se finalizează după ce toate promisiunile din array s-au completat sau la prima respingere. Este posibil ca toate acestea să nu fie lansate simultan — pentru a asigura lansarea simultană, se poate folosi p-map.

Concluzie

Funcțiile Async devin din ce în ce mai importante pentru dezvoltare. Iar pentru o utilizare adaptivă a funcțiilor async, ar trebui să folosești Iteratori Async. Un dezvoltator JavaScript ar trebui să fie foarte bine familiarizat cu aceasta.

Skillbox recomandă:

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster