Ako na asynchrónny kód v JavaScripte (3.) - async, await

Publikoval Michal Kočí dňa 24.9.2018 o 09:00 v kategóriach Node.js a JavaScript

V predchádzajúcom príspevku tejto mini série sme sa naučili pracovať s promismi. Dnes sa naučíme používať tzv. async funkcie použitím nových klúčových slov async a await. Pre správne pochopenie async funkcií potrebujete dobrú znalosť promisov, nakoľko sú na nich postavené, preto sme sa im venovali minule a dnes na to nadviažeme.

Preferovaný spôsob práce s asynchrónnosťou

Vráťme sa na chvíľu k prvému dielu, kde sme si povedali nepríjemný dôsledok asynchronnosti: nemožnosť napísania funkcie, ktorá spúšťa asynchrónny kód a zároveň vracia výsledok tejto asychnrónnej činnosti.

Prečo je to vlastne problém? Veď v zásade je nám jedno, či nám funkcia výsledok asynchrónnej činnosti vráti ako svoju návratovú hodnotu, príjme callback, ktorý zavolá keď bude hotovo alebo vráti promise, ku ktorému si callback pridáme my. Či nie je?

Teoreticky nám to jedno je. Alebo môže byť. Veď v každej z týchto troch možností musíme aj tak počkať na výsledok asynchrónnej činnosti a až potom môžeme pokračovať...

Ale zamyslime sa nad týmito tromi možnosťami. Ktorá je nám mentálne najpríjemnejšia, najmilšia, najintuitívnejšia a radi by sme ju preferovali?

Mentálny model

Tak ktorá je preferovaná?

Samozrejme prvá, t.j. návratová hodnota. A dôvod? Náš mentálny model. To ako sme zvyknutý mentálne s kódom pracovať. To ako ho čítame. Odhora nadol. Riadok po riadku. Príkaz za príkazom.

Pozrime sa na tieto tri príklady, aby sme sa v tomto utvrdili. Pozrieme sa nie na funkciu loadData, ale na jej použitie.

Callbacky

Začnime callbackmi:

console.log('start');

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

loadData(url, function (err, data) {
    if(err) {
        console.log('async failed');
        console.error(err);
    } else {
        console.log('async done');
        console.log(data);
    }
})

console.log('end');

Ak ste na tom podobne ako väčšina z nás, tak budete čítať kód odhora dole a mentálne vám nebude robiť dobre, že do konzoly sa najskôr vypíše start, potom end a až nakoniec async done. Tým, ako čítame kód, stále nás to vedie k očakávaniu, že end sa vypíše až na konci programu.

Promisy

U promisov to nie je o moc iné:

console.log('start');

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

loadData(url)
    .then(function (data) {
        console.log('async done');
        console.log(data);
    }, function (reason) {
        console.log('async failed');
        console.error(reason);
    });

console.log('end');

Situácia je v zásade veľmi podobná, i keď použitie funkcie then nám už napovedá, že kód bude zrejme zavolaný neskôr (then = potom). Ale stále, čitateľnosť je zhoršená, náš mozog proste očakáva sekvenčné poradie vykonávania kódu.

Návratová hodnota

Čo ak by ale funkcia loadData priamo dáta vracala? Ako návratovú hodnotu. Zatiaľ nám toto nebude fungovať, ale neskôr s menšími úpravami áno:

console.log('start');

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

try {
    let data = loadData(url)
    console.log('async done');
    console.log(data);
} catch(err) {
    console.log('async failed');
    console.error(err);
}

console.log('end');

Nie je to hneď lepšie? Sekvenčne nám to sedí a aj v konzole by išli informácie v poradí, v akom by sme ich chceli a očakávali - start, async done a end.

Async funkcia

Async alebo asychrónna funkcia je novinka v ES2017 (ES8) a jedná sa o funkciu pred ktorú pridáte klúčové slovo async. Môže ísť o funkciu ale aj o arrow funkciu, ako vidno na týchto dvoch príkladoch:

async function loadData(url) {
    /* ... */
}
const loadData = url => { /* ... */ }

Čím sa teda asynchrónna funkcia líši od synchrónnej? Čo vlastne kľúčové slovo async znamená, čo spôsobuje?

async označuje asynchrónnu funkciu a označuje, že táto funkcia vracia Promise. Vždy vracia promise. Na to je hrozne dôležité pamätať.

Podľa toho teda k jej výsledku musíte pristupovať - bude to promise a tak bez akýchkoľvek dodatočných znalostí viete, že k jej výsledku sa dostanete spôsobom známym z minulého dielu - pripojením sa cez then prípadne catch:

async function loadData(url) {
    /* ... */
}

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

loadData(url)
    .then(function (data) {
        console.log(data);
    }, function (reason) {
        console.error(reason);
    });

console.log('end');

Fajn, poviete si, tak aký to má pre mňa prínos? Na čo mi je to dobré, keď to stále vracia len promise? Keď sa na spôsobe použitia nič nezmenilo? Nuž, vydržte, sľubujem, že bude lepšie.

Výsledok promisu vráteného z async funkcie

V rámci async funkcie môžete cez return vrátiť ľubovoľnú hodnotu - objekt, číslo, reťazec, čokoľvek. Áno, tvrdil som, že async funkcia vracia Promise. A neklamal som. Vy cez return vrátite čo chcete, návratová hodnota je však vždy Promise.

V tomto momente už zrejme tušíte a niečo vám to pripomína. Možno vám to pripomína callback, ktorý zapisujete do then. Aj z neho môžete vrátiť čokoľvek. A aj then vždy vracia Promise. A u async funkcií to funguje podobne.

Takže, bez ďalšieho napínania, poďme na pravidlá, ktoré pre async funkciu platia.

1. pravidlo

Promise, ktorý vracia async funkcia bude naplnený (fulfilled), ak v nej použijete return a hodnota naplnenia bude to, čo cez return budete vracať

async function returnObject() {
    return { name: 'Michal' };
}

returnObject()
    .then(function (data) { console.log(data.name); })
    .catch(function (err) { console.error(err); });

V konzole sa nám objaví Michal, čo je pre nás dôkaz jednak toho, že async funkcia ozaj vracia Promise (inak by nám nefungovali then a catch) a tiež aj toho, že promise bude naplnený s objektom, ktorý vraciame.

2. pravidlo

Promise, ktorý vracia async funkcia bude odmietnutý (rejected), ak v nej vyvoláta vynímku štandardne cez throw a hodnota odmietnutia bude práve to, čo vyvoláte cez throw

async function throwError() {
    throw new Error('I always throw Error');
}

throwError()
    .then(function (data) { console.log(data.name); })
    .catch(function (err) { console.error(err); });

Opäť dôkaz namiesto sľubov (pekný dvojzmysel, pravda?) - V async funkcii throw-ujeme, takže promise, ktorý je z nej vrátený to prekonvertuje na rejectnutie s hodnotou toho, čo sme vyvolali. V našom prípade v konzole uvidíme I always throw Error.

3. pravidlo

Promise, ktorý vracia async funkcia bude zrkadliť (mirrorovať) hodnotu vráteného promisu, pokiaľ v nej použijete return a vracať budete Promise.

Vyššie uvedené príklady by sme podľa tohto pravidla mohli prepísať nasledovne a začneme príkladom, kedy vraciame cez return Promise ktorý bude (resp. je) naplnený:

async function returnObject() {
    return Promise.resolve({ name: 'Michal' });
}

returnObject()
    .then(function (data) { console.log(data.name); })
    .catch(function (err) { console.error(err); });

A tu je prípad, kedy bude nami vracaný Promise a aj Promise vrátený z async funkcie odmietnutý:

async function throwError() {
    return Promise.reject(new Error('I always throw Error'));
}

throwError()
    .then(function (data) { console.log(data.name); })
    .catch(function (err) { console.error(err); });

await

Zatiaľ sme sa venovali iba jednej polovici z dvojice async/await. Poďme sa teraz pobaviť o druhej - o await. Čo spôsobuje await? Na čo nám slúži?

await je klúčové slovo, ktoré slúži na pozastavenie vykonávania async funkcie, počkanie na settlement promisu a rozbalenie výsledku či vyvolanie vynímky.

Fajn, a teraz si túto definíciu rozoberme na drobné. Vychádzať budeme z toho, že máme funkciu loadData, ktorá nám vracia Promise. Áno, tú z minulého dielu:

function loadData(url) {
    return new Promise(function (resolve, reject) {
        let request = new XMLHttpRequest();

        request.addEventListener('load', function() {
            resolve(this.responseText);
        });
        request.addEventListener('error', function() {
            reject(new Error('Failed to load data'));
        });
        request.open('GET', url);
        request.send();
    });
}

Počkanie na settlement

Začnime druhou časťou - slúži na počkanie na settlement promisu. Z tohto by nám mala vyplnynúť prvá zaujímavá vlastnosť. Klúčové slovo await sa píše vľavo od výrazu, ktorý vracia Promise.

Či tým výrazom je premenná alebo napríklad volanie funkcie, to je jedno. Musí to byť výraz, ktorý vracia Promise.

Ak teda vieme, že máme funckiu loadData, ktorá nám vracia Promise, vieme, že môžeme použiť await a malo by nám to počkať na settlement (t.j. fulfillment alebo rejection):

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

console.log('start');
await loadData(url);
console.log('end');

Pokiaľ si tento príklad spustíme, uvidíme, že exekúcia počká na výsledok - že v konzole uvidíme hneď start, ale end uvidíme, až keď volanie endpointu dobehne volanie. Dobré, nie?

Rozbalenie výsledku

Počkať na výsledok je jedna vec, ale dostať sa k nemu je samozrejme druhá. A aj s týmto vám await pomôže.

Nielenže vám teda počká na settlement, ale ak je settlement úspešný a jedná sa tak o fulfillment (naplnenie), hodnota naplneného promisu je rozbalená a vrátená vám ako hodnota volania funkcie.

Ak sa teda chceme dostať k dátam z loadData, jednoducho si ich priradíme do premennej:

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

let data = await loadData(url);

Čo ale v prípade, ak je settlement neúspešný, t.j. promise je rejectnutý (odmietnutý)? Vtedy rozbalenie výsledku znamená vyvolanie vynímky. A vynímku môžete zachytiť štandardne cez try/catch blok:

Váš kód by teda mal vyzerať skôr takto, ak by ste chceli rátať aj s prípadom, že Promise bude odmietnutý:

console.log('start');

let url = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR';

try {
    let data = await loadData(url)
    console.log('async done');
    console.log(data);
} catch(err) {
    console.log('async failed');
    console.error(err);
}

console.log('end');

Keď sa na tento príklad pozriete pozorne a spomeniete si na vyššie uvedený príklad, keď sme sa bavili, aké by bolo super, mať asynchrónne volanie tváriace sa ako synchrónne, tak zistíte, že príklady sú to tie isté. Teda, skoro tie isté. V našom upravenom prípade je navyše jedno slovo - await.

Pozastavenie vykonávanie async funkcie

Tretím efektom použitia async je pozastavenie vykonávania async fukncie, ak sa v nej vyskytne await.

To sme v zásade už videli, ale skúsme sa nad tým ešte zamyslieť, lebo je to v zásade niečo revolučné. Doteraz sme boli zvyknutý, že synchrónny kód beží riadok za riadkom, príkaz za príkazom a kód beží, kým neskončí.

S asynchrónnym kódom sa nám to ale zmenilo. Asynchrónnu činnosť sme vedeli spustiť synchrónne, ale nevedeli sme počkať na jej výsledok. Náš kód bežal ďalej. Spracovanie asynchrónnej činnosti išlo na rad až v momente, kedy jednak dobehlo a druhak a najmä, až keď všetok náš synchrónny kód dobehol.

Návrat k prirodzenému mentálnemu modelu

async a await ale všetko menia. Zrazu môžete napísať funkciu, ktorú spúšťate synchrónne, v nej beží kód synchrónne až kým narazí na await. V tom momente sa spúšťanie funkcie pozastaví a reálne čaká na dobehnutie asynchrónnej činnosti.

A sic sa to stále môže zdať ako drobnosť, je to jedna z najlepších vecí aká nás mohla postretnúť. Náš mentálny model je s týmto nie len že absolútne kompatibilný, nebuďme troškári a povedzme to na rovinu - toto je chovanie, ktoré náš mentálny model očakáva. Je to pre nás úplne prirodzené.

Záver

Týmto by som si dovolil ukončiť tento mini seriál. Pokryli sme si základy a tak by ste mali byť v tomto momente úplne komfortný s použitím a porozumením callbackov, promisov a async/await. A to bol cieľ.

Samozrejme o callbackoch, promisoch aj async/await by sa toho dalo popísať mraky, ale myslím, že nateraz ste dobre vybavený, aby ste sa s nimi cítili pohodlne, keď ich budete dennodenne používať.

Pravdepodobne sa k asynchrónnosti v budúcnosti ešte vrátim, nápadov mám množstvo, len času na písanie nie je toľko, koľko by som si predstavoval. Tak ma nezabudnite sledovať tu na blogu, alebo na twitteri...

Samozrejme, rád sa s vami (aj o asynchrónnosti) pobavím na niektorom z mojich JavaScriptových školení.

A okrem toho, že sa na nich môžete poriadne naučiť JavaScript, môžete sa pritom naučiť aj nejaký framework či knižnicu - Node.js, Angular či React.

Mohlo by ťa tiež zaujímať

Páčil sa ti príspevok?

Zdieľaj príspevok alebo si ho odlož na neskôr

Sleduj ma

Ak nechceš premeškať príspevky ako je tento, sleduj ma na Twitteri, alebo ak máš RSS čítačku, môžeš sledovať môj RSS kanál.