Oglejmo si Async/Await v JavaScriptu na primerih

Avtor članka preučuje primere Async/Await v JavaScriptu. Na splošno je Async/Await priročen način za pisanje asinhrone kode. Preden se je ta funkcija pojavila, je bila taka koda napisana s povratnimi klici in obljubami. Avtor izvirnega članka z analizo različnih primerov razkriva prednosti Async/Await.

Spomnimo: za vse bralce "Habr" - popust v višini 10 rubljev ob vpisu v kateri koli tečaj Skillbox s promocijsko kodo "Habr".

Skillbox priporoča: Izobraževalni spletni tečaj "Java razvijalec".

Povratni klic

Povratni klic je funkcija, katere klic je odložen za nedoločen čas. Prej so se povratni klici uporabljali na tistih področjih 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 morate izvesti več asinhronih operacij hkrati. Predstavljajmo si ta scenarij: v uporabniško bazo podatkov Arfat je vložena zahteva, prebrati morate njeno polje profile_img_url in prenesti sliko s strežnika someserver.com.
Po prenosu sliko pretvorimo v drug format, na primer iz PNG v JPEG. Če je bila pretvorba uspešna, se na uporabnikov elektronski naslov pošlje pismo. Nato se informacije o dogodku vnesejo v datoteko transformations.log z navedbo datuma.

Vredno je biti pozoren na prekrivanje povratnih klicev in veliko število }) v zadnjem delu kode. Imenuje se Callback Hell ali Pyramid of Doom.

Slabosti te metode so očitne:

  • To kodo je težko brati.
  • Prav tako je težko obravnavati napake, kar pogosto vodi v slabo kakovost kode.

Za rešitev te težave so bile v JavaScript dodane obljube. Omogočajo zamenjavo globokega gnezdenja povratnih klicev z besedo .then.

Pozitiven vidik obljub je, da naredijo kodo veliko bolje berljivo, od zgoraj navzdol in ne od leve proti desni. Vendar imajo obljube tudi svoje težave:

  • Dodati morate veliko .then.
  • Namesto try/catch se za obravnavanje vseh napak uporablja .catch.
  • Delo z več obljubami v eni zanki ni vedno priročno; v nekaterih primerih zapletejo kodo.

Tukaj je problem, ki bo pokazal pomen zadnje točke.

Recimo, da imamo zanko for, ki natisne zaporedje števil od 0 do 10 v naključnih intervalih (0–n sekund). Z obljubami morate to zanko spremeniti tako, da se številke natisnejo v zaporedju od 0 do 10. Torej, če traja 6 sekund za tiskanje ničle in 2 sekundi za tiskanje ena, je treba najprej natisniti ničlo in nato začelo se bo odštevanje za tiskanje enega.

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

Asinhrone funkcije

Dodatek async funkcij v ES2017 (ES8) je poenostavil delo z obljubami. Opažam, da async funkcije delujejo "na vrhu" obljub. Te funkcije ne predstavljajo kvalitativno različnih konceptov. Async funkcije so mišljene kot alternativa kodi, ki uporablja obljube.

Async/Await omogoča organizacijo dela z asinhrono kodo v sinhronem slogu.

Tako je s poznavanjem obljub lažje razumeti načela Async/Await.

sintaksa

Običajno je sestavljen iz dveh ključnih besed: async in await. Prva beseda spremeni funkcijo v asinhrono. Takšne funkcije omogočajo uporabo čakanja. V vseh drugih primerih 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 je vstavljen na samem začetku deklaracije funkcije, v primeru puščične funkcije pa med znak "=" in oklepaje.

Te funkcije je mogoče postaviti v objekt kot metode ali 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! Vredno si je zapomniti, da konstruktorji razredov in pridobivalniki/nastavljalci ne morejo biti asinhroni.

Semantika in izvedbena pravila

Async funkcije so v osnovi podobne standardnim funkcijam JS, vendar obstajajo izjeme.

Tako asinhrone funkcije vedno vrnejo obljube:

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

Natančneje, fn vrne niz hello. No, ker je to asinhrona funkcija, je vrednost niza ovita v obljubo z uporabo konstruktorja.

Tukaj je alternativna zasnova brez Async:

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

V tem primeru se obljuba vrne "ročno". Asinhrona funkcija je vedno ovita v novo obljubo.

Če je vrnjena vrednost primitivna, funkcija async vrne vrednost tako, da jo ovije v obljubo. Če je vrnjena vrednost objekt obljube, je njegova razrešitev vrnjena v novi obljubi.

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

Toda kaj se zgodi, če pride do napake znotraj asinhrone funkcije?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

Če ni obdelan, bo foo() vrnil obljubo z zavrnitvijo. V tem primeru bo namesto Promise.resolve vrnjen Promise.reject, ki vsebuje napako.

Asinhrone funkcije vedno izpišejo obljubo, ne glede na to, kaj je vrnjeno.

Asinhrone funkcije se ustavijo ob vsakem čakanju.

Čakanje vpliva na izraze. Torej, če je izraz obljuba, je funkcija async začasno ustavljena, dokler obljuba ni izpolnjena. Če izraz ni obljuba, se pretvori v obljubo prek Promise.resolve in nato dokonč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 delovanja funkcije fn.

  • Po klicu se prva vrstica pretvori iz const a = await 9; in const a = await Promise.resolve(9);.
  • Po uporabi Await se izvajanje funkcije prekine, dokler a ne dobi svoje vrednosti (v trenutni situaciji je 9).
  • delayAndGetRandom(1000) začasno ustavi izvajanje funkcije fn, dokler se ne dokonča (po 1 sekundi). To dejansko ustavi funkcijo fn za 1 sekundo.
  • delayAndGetRandom(1000) prek razrešitve vrne naključno vrednost, ki je nato dodeljena spremenljivki b.
  • No, primer s spremenljivko c je podoben primeru s spremenljivko a. Po tem se vse za sekundo ustavi, zdaj pa delayAndGetRandom(1000) ne vrne ničesar, ker ni potrebno.
  • Posledično se vrednosti izračunajo po formuli a + b * c. Rezultat je zavit v obljubo z uporabo Promise.resolve in vrnjena s funkcijo.

Ti premori morda spominjajo na generatorje v ES6, vendar je nekaj na tem tvoji razlogi.

Reševanje problema

No, zdaj pa poglejmo rešitev zgoraj omenjenega problema.

Funkcija finishMyTask uporablja Await za čakanje na rezultate operacij, kot so queryDatabase, sendEmail, logTaskInFile in druge. Če to rešitev primerjate s tisto, kjer so bile uporabljene obljube, bodo podobnosti očitne. Vendar pa različica Async/Await močno poenostavi vse sintaktične zapletenosti. V tem primeru ni velikega števila povratnih klicev in verig, kot je .then/.catch.

Tukaj je rešitev z izpisom številk, obstajata dve možnosti.

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

Napaka pri ravnanju

Neobravnavane napake so zavite v zavrnjeno obljubo. Vendar pa lahko asinhrone funkcije uporabljajo poskus/ulov za sinhrono obravnavanje napak.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() je asinhrona funkcija, ki uspe (»popolna številka«) ali ne uspe z napako (»Oprostite, številka je prevelika«).

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

Ker zgornji primer pričakuje izvedbo canRejectOrReturn, bo lastna napaka povzročila izvedbo bloka catch. Posledično se bo funkcija foo končala bodisi z undefined (ko v poskusnem bloku ni vrnjeno nič) bodisi z ulovljeno napako. Posledično ta funkcija ne bo odpovedala, ker bo poskus/catch obravnaval samo funkcijo foo.

Tu je še en primer:

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

Vredno je biti pozoren na dejstvo, da je v primeru canRejectOrReturn vrnjen iz foo. Foo se v tem primeru konča s popolnim številom ali vrne napako (»Oprostite, število je preveliko«). Blok catch ne bo nikoli izveden.

Težava je v tem, da foo vrne obljubo, posredovano iz canRejectOrReturn. Tako rešitev za foo postane rešitev za canRejectOrReturn. V tem primeru bo koda sestavljena samo iz dveh vrstic:

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

Evo, kaj se zgodi, če hkrati uporabite await in return:

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

V zgornji kodi bo foo uspešno zapustil tako popolno število kot tudi napako. Tu ne bo nobenih zavrnitev. Toda foo se bo vrnil s canRejectOrReturn, ne z undefined. Prepričajmo se o tem tako, da odstranimo vrstico return await canRejectOrReturn():

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

Pogoste napake in pasti

V nekaterih primerih lahko uporaba Async/Await povzroči napake.

Pozabljeno čakanje

To se zgodi precej pogosto - ključna beseda await je pozabljena pred obljubo:

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

Kot lahko vidite, v kodi ni čakanja ali vrnitve. Zato foo vedno zapusti z nedefiniranim brez 1 sekundne zamude. A obljuba bo izpolnjena. Če sproži napako ali zavrnitev, bo poklican UnhandledPromiseRejectionWarning.

Asinhrone funkcije v povratnih klicih

Async funkcije se pogosto uporabljajo v .map ali .filter kot povratni klici. Primer je funkcija fetchPublicReposCount(username), ki vrne število odprtih repozitorijev na GitHubu. Recimo, da obstajajo trije uporabniki, katerih meritve 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 naredimo:

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

Vredno je biti pozoren na Await v povratnem klicu .map. Tukaj šteje niz obljub, .map pa anonimni povratni klic za vsakega določenega uporabnika.

Preveč dosledna uporaba čakanja

Vzemimo to kodo kot primer:

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

Tu je številka repo postavljena v spremenljivko count, nato pa je ta številka dodana matriki counts. Težava s kodo je v tem, da bodo vsi nadaljnji uporabniki, dokler podatki prvega uporabnika ne prispejo s strežnika, v stanju pripravljenosti. Tako je naenkrat obdelan le en uporabnik.

Če npr. obdelava enega uporabnika traja približno 300 ms, potem je to za vse uporabnike že sekunda, porabljeni čas pa je linearno odvisen od števila uporabnikov. Ker pa pridobivanje števila repo ni odvisno drug od drugega, lahko procese vzporedimo. To zahteva delo z .map in 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 kot vhod prejme niz obljub in vrne obljubo. Slednji je po izpolnitvi vseh obljub v matriki ali ob prvi zavrnitvi izpolnjen. Lahko se zgodi, da se vsi ne zaženejo hkrati - za zagotovitev istočasnega zagona lahko uporabite p-map.

Zaključek

Asinhrone funkcije postajajo vse bolj pomembne za razvoj. No, za prilagodljivo uporabo asinhronih funkcij bi morali uporabiti Asinhroni iteratorji. Razvijalec JavaScript bi moral to dobro poznati.

Skillbox priporoča:

Vir: www.habr.com

Dodaj komentar