Razrađujemo Async/Await u JavaScriptu na primjerima

Avtor članka razlaga skozi primere Async/Await v JavaScriptu. Na splošno je Async/Await priročen način pisanja asinhronega kode. Preden je bila ta možnost na voljo, je bila podobna koda napisana z uporabo povratnih klicev in obljub. Avtor izvirnega članka razkriva prednosti Async/Await, ko razpravlja o različnih primerih.

Opominjamo: za vse bralce »Habra« — popust 10 000 rubljev ob vpisu na kateri koli tečaj Skillbox s promo kodo »Habr«.

Skillbox priporoča: Образовательный онлайн-курс „Java razvijalec“.

Povratni klic

Povratni klic je funkcija, katere klic je odložen za nedoločen čas. Prej so se povratni klici uporabljali v tistih delih kode, kjer rezultata ni bilo mogoče dobiti takoj.

Tukaj je primer asinhronega branja datoteke v Node.js:

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

Težave nastanejo, ko je potrebno izvesti več asinhronih operacij hkrati. Predstavljajmo si naslednji scenarij: izvaja se poizvedba v bazo podatkov uporabnika Arfat, treba je prebrati njegovo polje profile_img_url in naložiti sliko s strežnika someserver.com.
Po nalaganju pretvorimo sliko v drug format, na primer iz PNG v JPEG. Če je pretvorba uspešna, se uporabniku pošlje e-pošta. Nato se informacija o dogodku zabeleži v datoteko transformations.log z navedbo datuma.

Pomembno je opozoriti na gnezdenje povratnih klicev in veliko število }) na koncu kode. To se imenuje Callback Hell ali Pyramid of Doom.

Pomanjkljivosti tega načina so očitne:

  • To kodo je težko brati.
  • Prav tako je težko obravnavati napake, kar pogosto pripelje do poslabšanja kakovosti kode.

Da bi rešili ta problem, so bili v JavaScript dodani obljube. Te omogočajo zamenjavo globokega gnezdenja povratnih klicev s besedo .then.

Pozitivna stran obljub je, da se s temi kodami bere bistveno bolje, od zgoraj navzdol, namesto od leve proti desni. Kljub temu imajo obljube tudi svoje težave:

  • Treba je dodati veliko število .then.
  • Namesto try/catch se uporablja .catch za obravnavo vseh napak.
  • Delo z več obljubami v okviru enega cikla ni vedno priročno, v nekaterih primerih zaplete kodo.

Tukaj je naloga, ki bo pokazala vrednost zadnjega punkt.

Predpostavimo, da obstaja for zanka, ki izpisuje zaporedje števil od 0 do 10 s slučajnim intervalom (0–n sekund). Uporabljajoč obljube, je treba zanko spremeniti tako, da se številke izpisujejo v zaporedju od 0 do 10. Tako, če izpis ničle traja 6 sekund, enice pa 2 sekundi, mora najprej biti izpisana ničla, šele nato se začne odštevanje izpisa enice.

In seveda, za rešitev te naloge ne uporabljamo Async/Await ali .sort. Primer rešitve je na koncu.

Async-funkcije

Dodajanje async-funkcij v ES2017 (ES8) je poenostavilo delo z obljubami. Opozarjam, da async-funkcije delujejo »nad« obljubami. Te funkcije ne predstavljajo kvalitativno drugačnih konceptov. Async-funkcije so bile zasnovane kot alternativa kodi, ki uporablja obljube.

Async/Await omogoča organizacijo dela z asinhrono kodo na sinhron način.

Tako znanje obljub olajša razumevanje načel Async/Await.

Syntax

V običajni situaciji se sestoji iz dveh ključnih besed: async in await. Prva beseda pretvori funkcijo v asinhrono. V takšnih funkcijah je dovoljeno uporabljati await. V vsakem drugem primeru bo uporaba te funkcije povzročila napako.

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

Async se vstavi na samem začetku deklaracije funkcije, v primeru uporabe puščične funkcije pa med znakom „=” in oklepaji.

Te funkcije je mogoče vključiti v objekt kot metode ali pa jih uporabiti v deklaraciji razreda.

// 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! Treba je upoštevati, da konstruktorji razreda ter getterji/setterji ne morejo biti asinhroni.

Semantika in pravila izvajanja

Async-funkcije so v načelu podobne standardnim JS-funkcijam, a obstajajo tudi izjeme.

Tako async-funkcije vedno vračajo obljube:

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

Specifično fn vrača niz hello. Ker gre za asinhrono funkcijo, se vrednost niza ovije v obljubo s pomočjo konstruktorja.

Tukaj je alternativna konstrukcija brez Async:

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

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

V tem primeru se vračanje obljube izvaja »ročno«. Asinhrona funkcija se vedno ovije v novo obljubo.

V primeru, ko je vrnjena vrednost primitiv, async-funkcija vrne vrednost, jo ovije v obljubo. V primeru, ko je vrnjena vrednost objekt obljube, se njeno rešitev vrne v novi obljubi.

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

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

Toda kaj se zgodi, če v async-funkciji pride do napake?

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

foo().catch(console.log);

Če ne bo obravnavana, bo foo() vrnila obljubo z zavrnjenjem. V tej situaciji namesto Promise.resolve vrne Promise.reject, ki vsebuje napako.

Async-funkcije vedno dajejo obljubo na izhodu, ne glede na to, kaj se vrača.

Asinhrone funkcije se ustavijo pri vsakem await.

Await vpliva na izraze. Če je izraz obljuba, se async-funkcija ustavi, dokler obljuba ne konča. Če izraz ni obljuba, se konvertira v obljubo preko Promise.resolve in se nato konča.

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

Tukaj je opis, kako deluje fn-funkcija.

  • Po njenem klicu se prva vrstica konvertira iz const a = await 9; v const a = await Promise.resolve(9);.
  • Po uporabi Await se izvajanje funkcije ustavi, dokler a ne dobi svoje vrednosti (v trenutni situaciji to je 9).
  • delayAndGetRandom(1000) ustavi izvajanje fn-funkcije, dokler se ne konča sama (po 1 sekundi). To dejansko pomeni ustavitev fn-funkcije za 1 sekundo.
  • delayAndGetRandom(1000) prek resolve vrne naključno vrednost, ki se nato dodeli spremenljivki b.
  • Kar zadeva spremenljivko c, je podobno primeru spremenljivke a. Po tem se vse ustavi za sekundo, vendar zdaj delayAndGetRandom(1000) ne vrne ničesar, ker to ni potrebno.
  • Na koncu se vrednosti izračunajo po formuli a + b * c. Rezultat se ovije v obljubo z Promise.resolve in ga vrne funkcija.

Te pavze so lahko podobne generatorjem v ES6, vendar obstajajo svoje razlogi.

Rešujemo nalogo

Zdaj pa si oglejmo rešitev naloge, ki je bila navedena zgoraj.

V funkciji finishMyTask se uporablja Await za čakanje na rezultate takšnih operacij, kot so queryDatabase, sendEmail, logTaskInFile in drugih. Če to rešitev primerjamo s tisto, kjer so se uporabljali obljubi, postane očitno podobnost. Kljub temu različica z Async/Await močno poenostavi vse sintaktične zapletenosti. V tem primeru ni velikega števila povratnih klicev in verig, kot so .then/.catch.

Tukaj je rešitev z izpisom števil, tukaj sta dva primera.

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

In tukaj je rešitev z uporabo async funkcij.

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

Obdelava napak

Neobdelane napake se ovijejo v zavrnjeni obljubi. Kljub temu v async funkcijah lahko uporabimo konstrukcijo try/catch, da izvedemo sinhrono obdelavo napak.

async function canRejectOrReturn() {
  // počakaj eno sekundo
  await new Promise(res => setTimeout(res, 1000));

  // Zavrzite z ~50% verjetnostjo
  if (Math.random() > 0.5) {
    throw new Error('Žal, številka je prevelika.')
  }

  return 'popolna številka';
}

canRejectOrReturn() je asinhrona funkcija, ki bodisi uspešno izvede ("popolna številka"), bodisi neuspešno zaključi z napako ("Žal, številka je prevelika").

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'napaka ujeta';
  }
}

Ker se v zgornjem primeru pričakuje izvajanje canRejectOrReturn, bo lastni neuspešni zaključek povzročil izvajanje bloka catch. Posledično se bo funkcija foo zaključila bodisi z undefined (ko v bloku try nič ne vrne), bodisi z napako ujeta. Tako ne bo imela neuspešnega zaključka, saj se bo obravnava napak v funkciji foo ukvarjala sama.

Tukaj je še en primer:

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

Pomembno je opozoriti, da v primeru iz foo vrne canRejectOrReturn. Foo se v tem primeru zaključi bodisi z popolno številko, bodisi se vrne napaka Error ("Žal, številka je prevelika"). Blok catch se nikoli ne bo izvajal.

Težava je, da foo vrne obljubo, ki jo poda canRejectOrReturn. Zato postane rešitev funkcije foo rešitev za canRejectOrReturn. V tem primeru bo koda obsegala le dve vrstici:

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

In kaj se zgodi, če uporabimo await in return skupaj:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'napaka ujeta';
  }
}

V kode zgoraj bo foo uspešno končan tako s perfect number kot tudi z napako. Odpovedi ne bo. Toda foo se bo končal z canRejectOrReturn, ne z undefined. Prepričajmo se v tem, tako da odstranimo vrstico return await canRejectOrReturn():

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

Pogoste napake in pasti

V nekaterih primerih lahko uporaba Async/Await privede do napak.

Pozabljen await

To se pogosto zgodi — pred promisem se pozabi ključno besedo await:

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

V kodi, kot je razvidno, ni niti await niti return. Zato se foo vedno zaključi z undefined brez zamude 1 sekunde. Toda promis se bo izvajal. Če pa vrne napako ali je zavrnjen, bo v tem primeru sprožen UnhandledPromiseRejectionWarning.

Async-funkcije v povratnih klicih

Async-funkcije se pogosto uporabljajo v .map ali .filter kot povratni klici. Kot primer lahko navedemo funkcijo fetchPublicReposCount(username), ki vrača število odprtih repo-ov na GitHubu. Predpostavimo, da imamo tri uporabnike, katerih statistike potrebujemo. Tukaj je koda za to nalogo:

const url = 'https://api.github.com/users';

// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Potrebujemo račune ArfatSalman, octocat, norvig. V tem primeru izvedemo:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];

const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Pozornost je treba nameniti Await v povratnem klicu .map. Tukaj so counts — tabela promi, .map pa je anonimni povratni klic za vsak navedeni uporabnik.

Prekomerna zaporedna uporaba await

Kot primer vzemimo to kodo:

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

Tukaj se v spremenljivko count shrani število repo-ov, nato se to število doda v tabelo counts. Težava kode je v tem, da bodo vsi naslednji uporabniki čakali, dokler podatki prvega uporabnika ne prispejo s strežnika. Tako se v enem trenutku obdeluje le en uporabnik.

Na primer, ako je potrebno oko 300 ms da se obrade jedan korisnik, za sve korisnike to je već sekunda; vreme obrade linearno zavisi od broja korisnika. Ali pošto dobijanje broja repo-a ne zavisi jedan od drugog, procese možemo paralelizovati. Za to je potrebna obrada sa .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 kao ulaz dobija niz promise-a sa vraćanjem promise-a. Poslednji se završava nakon što se svi promise-i u nizu završe ili pri prvom odbacivanju. Može se desiti da se svi oni ne pokrenu istovremeno — da bi se osiguralo simultano pokretanje, može se koristiti p-map.

Zaključek

Async funkcije postaju sve važnije za razvoj. A za adaptivnu upotrebu async funkcija, valja koristiti Async Iterators. JavaScript programer treba dobro da razume ovo.

Skillbox priporoča:

Izvor: habr.com

Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki 🔥 Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki | ProHoster