Az Async/Await részletesen a JavaScript-ben példákon keresztül

A cikk szerzője példákon keresztül tárgyalja az Async/Await megoldást a JavaScript-ben. Összességében az Async/Await egy kényelmes módja az aszinkron kód írásának. E lehetőség előtt az aszinkron kódot callback-ek és promiszok használatával írták. Az eredeti cikk szerzője feltárja az Async/Await előnyeit különböző példákon keresztül.

Emlékeztetjük: minden «Habr» olvasónak 10 000 rubeles kedvezmény jár bármely Skillbox kurzus jelentkezésekor a «Habr» promóciós kód felhasználásával.

A Skillbox ajánlja: Oktatási online kurzus Java fejlesztő.

Callback

A callback egy olyan funkció, amelynek meghívása késlekedik meghatározatlan ideig. Korábban a callback-eket olyan kód szakaszokban használták, ahol az eredmény azonnal nem volt elérhető.

Íme egy példa aszinkron fájlolvasásra Node.js-en:

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

Problémák merülnek fel, amikor több aszinkron műveletet kell egyszerre végrehajtani. Képzeljünk el egy olyan forgatókönyvet: egy lekérdezést hajtunk végre a felhasználó Arfat adatbázisában, és le kell kérnünk a profile_img_url mezőjét, majd le kell töltenünk a képet a someserver.com-ról.
A letöltés után a képet átkonvertáljuk egy másik formátumba, például PNG-ből JPEG-be. Ha a konvertálás sikeres volt, a felhasználónak e-mailt küldünk. Az esemény információját ezután a transformations.log fájlba rögzítjük a dátummal együtt.

Érdemes figyelni a callback-ek egymásra rakódására és a végső kódrészletben található számos })-ra. Ezt nevezik Callback Hell-nek vagy Doom Pyramidájának.

E módszer hátrányai nyilvánvalóak:

  • Ez a kód nehezen olvasható.
  • Emellett nehezen kezelhetők a hibák, ami gyakran a kód minőségének romlásához vezet.

A probléma megoldására a JavaScript-be bevezették a promiszokat. Ezek lehetővé teszik a callback-ek mélybe ágyazott használatának helyettesítését a .then kulcsszóval.

A promiszok pozitívuma, hogy a kód sokkal jobban olvasható, ráadásul függőlegesen, nem pedig vízszintesen. Mindazonáltal a promiszoknak is vannak saját problémáik:

  • Sok .then-t kell hozzáadni.
  • A try/catch helyett a .catch-t használják az összes hiba kezelésére.
  • Több promisszal való munka egy cikluson belül nem mindig kényelmes, bizonyos esetekben bonyolítja a kódot.

Íme egy feladat, amely megmutatja az utolsó pont értékét.

Tegyük fel, hogy van egy for ciklus, amely 0-tól 10-ig terjedő számokat jelenít meg véletlenszerű időbeli intervallummal (0–n másodperc). Ígéretek használatával módosítani kell ezt a ciklust úgy, hogy a számok 0-tól 10-ig terjedő sorrendben jelenjenek meg. Tehát, ha a nulla megjelenítése 6 másodpercet vesz igénybe, az egyes megjelenítése pedig 2 másodpercet, akkor először a nulla jelenik meg, és csak utána kezdődik el az egyes megjelenítése.

Természetesen ennek a feladatnak a megoldására nem használjuk az Async/Await vagy a .sort módszert. A megoldás példája a végén található.

Async-funkciók

Az async-funkciók bevezetése az ES2017 (ES8) keretében megkönnyítette az ígéretekkel való munka feladatát. Megjegyzem, hogy az async-funkciók az ígéretek «felszíne» felett működnek. Ezek a funkciók nem minősülnek alapvetően eltérő elképzelésnek. Az async-funkciók mint alternatívája lettek kitalálva a hírdre, ami ígéreteket használ.

Az Async/Await lehetőséget ad az aszinkron kód szinkron stílusú kezelésére.

Így tehát az ígéretek ismerete megkönnyíti az Async/Await alapelveinek megértését.

Szintaxis

Általában ez két kulcsszóból áll: async és await. Az első szó a funkciót aszinkronná alakítja. Ezekben a funkciókban engedélyezett az await használata. Bármely más esetben a funkció használata hibát fog okozni.

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

Az async a függvény deklarálásának legelején kerül elhelyezésre, míg nyílfüggvény használata esetén a «=» és a zárójelek közé.

Ezek a funkciók objektumba helyezhetők metódusokként, vagy felhasználhatók osztály deklarációjában.

// 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! Érdemes szem előtt tartani, hogy az osztályok konstruktőrei és gettere/gatterek nem lehetnek aszinkronok.

Szemantika és végrehajtási szabályok

Az async-funkciók alapvetően hasonlítanak a standard JS-funkciókra, de van kivétel is.

Így az async-funkciók mindig ígéreteket adnak vissza:

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

Konkrétan, a fn visszaadja a hello karakterláncot. Mivel ez egy aszinkron funkció, az értéket ígéretbe csomagolja a konstruktora.

Itt van egy alternatív szerkezet Async nélkül:

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

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

Ebben az esetben az ígéret visszaadása «kézzel» történik. Az aszinkron funkció mindig egy új ígéretbe van csomagolva.

Abban az esetben, ha a visszatérési érték primitív, az async-függvény a visszatérési értéket egy ígéretbe csomagolva adja vissza. Abban az esetben, ha a visszatérési érték egy ígéret objektum, annak megoldása egy új ígéretben tér vissza.

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

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

De mi történik, ha egy hiba lép fel az aszinkron függvény belsejében?

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

foo().catch(console.log);

Ha nem kezelik, a foo() egy ígéretet ad vissza elutasítással. Ebben a helyzetben a Promise.resolve helyett a Promise.reject fog visszatérni, amely a hibát tartalmazza.

Az aszinkron függvények mindig ígéretet adnak vissza, függetlenül attól, hogy mit adnak vissza.

Az aszinkron függvények minden await-nél felfüggesztődnek.

Az await hatással van a kifejezésekre. Így, ha a kifejezés egy ígéret, az async-függvény felfüggesztődik az ígéret végrehajtásáig. Ha a kifejezés nem ígéret, akkor a Promise.resolve révén ígéretté alakul, majd befejeződik.

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

Íme a leírás arról, hogyan működik a fn-függvény.

  • A hívása után az első sor const a = await 9; az alábbiakra alakul: const a = await Promise.resolve(9);.
  • Az Await használata után a függvény végrehajtása felfüggesztődik, amíg a a nem kapja meg értékét (jelen esetben ez 9).
  • A delayAndGetRandom(1000) felfüggeszti a fn-függvény végrehajtását, amíg az be nem fejeződik (1 másodperc után). Ez lényegében a fn-függvény 1 másodpercre való megállítását jelenti.
  • A delayAndGetRandom(1000) a resolve-on keresztül visszaad egy véletlenszerű értéket, amely aztán a b változóhoz kerül.
  • A c változó esete hasonló a a változó esetéhez. Ezután minden megáll egy másodpercre, de most a delayAndGetRandom(1000) nem ad vissza semmit, mivel erre nincs szükség.
  • Végül az értékeket az a + b * c képlettel számítják ki. Az eredmény pedig a Promise.resolve révén ígéretbe csomagolva kerül vissza a függvényből.

Ezek a szünetek hasonlíthatnak az ES6 generátorokra, de ennek megvannak a saját okai.

A feladat megoldása

Most nézzük meg a feladat megoldását, amelyet fentebb említettünk.

A finishMyTask függvényben az Await használatával várakozunk olyan műveletek, mint a queryDatabase, sendEmail, logTaskInFile eredményeire. Ha ezt a megoldást összehasonlítjuk a promisszal működő verzióval, nyilvánvaló a hasonlóság. Ennek ellenére az Async/Await verzió jelentősen leegyszerűsíti az összes szintaktikai bonyodalmat. Ilyen esetben nem találunk sok callback-et és láncot, mint a .then/.catch.

Íme egy megoldás a számok kiírására, itt két lehetőség van.

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

Ez itt egy megoldás async függvények használatával.

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

Hibakezelés

A nem kezelt hibák rejected promisszá alakulnak. Azonban az async függvényekben használhatjuk a try/catch konstrukciót a hibák szinkron feldolgozására.

async function canRejectOrReturn() {
  // várj egy másodpercet
  await new Promise(res => setTimeout(res, 1000));

// ~50% valószínűséggel elutasít
  if (Math.random() > 0.5) {
    throw new Error('Sajnálom, a szám túl nagy.')
  }

return 'tökéletes szám';
}

a canRejectOrReturn() egy aszinkron függvény, amely vagy sikeresen zárul le ("tökéletes szám"), vagy hibával végződik ("Sajnálom, a szám túl nagy").

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'hiba elkapva';
  }
}

Mivel a fenti példában a canRejectOrReturn végrehajtására számítunk, a saját sikertelen befejezés elindítja a catch blokkot. Ennek eredményeként a foo függvény vagy undefined-del (ha a try blokkban semmit nem adunk vissza), vagy a hiba elkapva értékkel fejeződik be. Végül ennek a függvénynek nem lesz sikertelen befejezése, mert a try/catch foglalkozik a foo függvény feldolgozásával.

Íme egy másik példa:

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

Fontos megjegyezni, hogy a foo példában a canRejectOrReturn-t adjuk vissza. Ez esetben a Foo vagy a tökéletes számot adja vissza, vagy hibát visszaad ("Sajnálom, a szám túl nagy"). A catch blokk soha nem fog végrehajtódni.

A probléma az, hogy a foo egy ígéretet ad vissza, amelyet a canRejectOrReturn-tól kapott. Ezért a foo függvény megoldása a canRejectOrReturn megoldása lesz. Ebben az esetben a kód mindössze két sorból áll:

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

Mit fogunk kapni, ha együtt használjuk az await-ot és a return-t:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'hiba elkapva';
  }
}

A fenti kódban a foo sikeresen befejeződik tökéletes számként és hibakezelésnek is. Itt nem lesznek hibák. De a foo a canRejectOrReturn-nal fejeződik be, nem pedig undefined-dal. Győződjünk meg erről, távolítsuk el a return await canRejectOrReturn() sort:

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

Gyakori hibák és csapdák

Bizonyos esetekben az Async/Await használata hibákhoz vezethet.

Elfelejtett await

Ez elég gyakran előfordul — a kulcsszó await hiányzik a promisz előtt:

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

A kódban, amint látható, nincs sem await, sem return. Ezért a foo mindig undefined-dal fejeződik be egy másodperc késleltetés nélkül. A promisz azonban végrehajtódik. Ha hibát ad vagy elutasít, akkor ebben az esetben UnhandledPromiseRejectionWarning fog megjelenni.

Async-funkciók visszahívásokban

Az Async-funkciók meglehetősen gyakran használatosak a .map vagy .filter callbackjeiként. Például vegyük a fetchPublicReposCount(username) funkciót, amely visszaadja a GitHub-on található nyilvános tárolók számát. Tegyük fel, hogy három felhasználóra van szükségünk, itt van a kód erre a feladatra:

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

ArfatSalman, octocat, norvig fiókokra van szükségünk. Ebben az esetben a következőt hajtjuk végre:

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

Érdemes figyelni a Await-t a .map visszahívásban. Itt a counts — egy promiszokból álló tömb, a .map pedig egy névtelen visszahívás az adott felhasználók számára.

Túlzottan sorozatos használat await

Példaként vegyük ezt a kódot:

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

Itt a count változóban a repók számát tároljuk, amelyet aztán hozzáadunk a counts tömbhöz. A kód problémája, hogy amíg az első felhasználó adatai a szerverről meg nem érkeznek, minden további felhasználó várakozó állapotban marad. Így egy időben csak egy felhasználó kerül feldolgozásra.

Például, ha egy felhasználó feldolgozása körülbelül 300 ms időt vesz igénybe, akkor az összes felhasználónál ez már egy másodpercet jelent, az eltöltött idő lineárisan függ a felhasználók számától. Mivel azonban a nyilvános repók mennyiségének lekérése nem függ egymástól, a folyamatokat párhuzamosíthatjuk. Ehhez a .map és a Promise.all használata szükséges:

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

A Promise.all bemenetként egy ígéretek tömbjét kapja, amelyek egy promissal térnek vissza. Az utolsó ígéret a tömb összes ígérete befejezése után, vagy az első elutasításkor fejeződik be. Lehet, hogy nem mindegyik indul el egyszerre — ahhoz, hogy biztosítsuk az egyidejű indítást, használhatjuk a p-map-et.

Zárás

Az async függvények egyre fontosabbá válnak a fejlesztés során. Az async függvények rugalmas használatához érdemes kihasználni a Async iterátorokat. A JavaScript fejlesztőnek jól kell ismernie ezt.

A Skillbox ajánlja:

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster