Ako na asynchrónny kód v JavaScripte (2.) - Promise

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

V minulom diele sme sa pozreli na asynchrónnosť a na riešenie problémov s ňou spojených callbackmi. Dnes sa pozrieme na sľubované promisy - novú triedu, ktorá nám pomôže a spríjemní prácu s asynchrónnym kódom v JavaScripte.

Čo je Promise

Pokiaľ by sme sa na Promise pozreli len z JavaScriptového pohľadu, potom sa jednoducho povedané jedná o novú triedu v JavaScripte.

Keď sa na promise pozrieme z konceptuálneho hľadiska, tak ide o prísľub (promise), že niekedy v budúcnosti poskytneme výsledok, prípadne informáciu o chybe.

A na promise sa vieme pozrieť ešte inak - ako na možnosť niečo vrátiť z funkcie, ktorá vykonáva asynchrónnu činnosť. Čo je veľký pokrok, lebo minule sme si naznačili, že pri asynchrónnych funkciách z nich nevieme nič vrátiť. Čo sa vďaka promisom zmenilo.

Koncept Promise nie je novinkou a dokonca aj v JavaScripte boli implementácie, ktoré fungovali aj pred ES2015, napríklad Bluebird, q a iné. A fungujú aj dnes, takže pokiaľ vám štandardná funkcionalita, o ktorej sa budeme baviť, nestačí, môžete sa po týchto knižniciach stále pozrieť.

Na čo slúži Promise

Promise slúži ako akýsi placeholder pre výsledok asynchrónnej operácie. Teda, ak by sme sa bavili o príklade, ktorý sme rozoberali v minulom diele, funkcia loadData by prestala prijímať callback, namiesto toho by vracala promise.

Ten, kto následne funkciu loadData zavolá, bude sledovať vrátený promise a zareaguje v momente, kedy bude jasné, že promise bol vysporiadaný (je k dispozícii výsledok alebo chyba).

Než sa pozrieme na promisy dedailnejšie, naznačme si, ako sa začne meniť funkcia loadData:

function loadData(url) {
    // vytvorit promise a vratit ho
    return;
}

Stav promisu

Promise je objekt. Objekt, ktorý nadobúda stavy, takže si za ním môžeme predstaviť veľmi jednoduchý stavový diagram.

Aké stavy nadobúda? Keď sa promise vytvorí, začína v stave Pending. Ako je už z názvu patrné, jedná sa o stav, kedy ešte nie je jasný výsledok.

Zo stavu Pending sa vie dostať iba do stavu Fulfilled (naplnený, máme výsledok) alebo Rejected (odmietnutý, nastala chyba a promise sa nepodarilo naplniť). Tieto dva stavy sa niekedy označujú spoločným názvom Settled (vysporiadaný).

Čo sa stavov týka, tak ešte platí, že promise môže byť vyriešený práve raz a že* jeho stav ani hodnota sa nemôžu zmeniť*.

Dva druhy API

Ak sa pozrieme na Promise ako na API, t.j. na rozhranie, ktoré nám poskytujú, môžeme toto API logicky podeliť na dve časti - na časť súkromnú a verejnú.

Súkromné API - Vytvorenie promisu

Súkromná slúži tomu, kto promise vytvoril. A slúži mu na to, aby v momente, keď má výsledok alebo chybu, mohol zmeniť stav promisu.

Promise sa v ES2015 vytvára zavolaním jeho konštruktora, new Promise(), pričom konštruktor prijíma jeden argument - tzv. spúšťač (executor).

Executor je funkcia, ktorá bude ihneď zavolaná. Ihneď, to znamená synchrónne. Executor je zároveň funkcia, ktorá dostane dva argumenty - resolve a reject, čo sú funkcie, ktorými viete zmeniť stav promisu.

Funkcia resolve prijíma jeden parameter, nazývaný value, a to hodnotu, s ktorou resolvujeme. K tejto hodnote bude mať prístup konzument promisu. Obdobne, reject prijíma jeden parameter, nazývaný reason, a to chybu či dôvod, prečo asynchrónna udalosť zlyhala. V prípade rejectnutia je to obvykle inštancia triedy Error, ale nie je to nutná podmienka.

Pozrime sa na to, ako vytvoriť jednoduchý Promise, ktorý sa resolvne za 1 sekundu a pridajme si pár logov do konzoly, aby sme videli, ako sa nám to v čase chová:

console.log('start');

let promise = new Promise(function (resolve, reject) {
    console.log('executor');
    setTimeout(function () {
        console.log('resolving');
        resolve('first promise');
        console.log('resolved');
    }, 1000);
});

console.log('end');

V konzole by ste mali vidieť ihneď texty start, executor a end. To je dôkaz, že executor sa volá ihneď, synchrónne. A za približne sekundu sa do konzoly zapíšu aj texty resolving a resolved.

Rozoberme si kód na drobné.

Voláme konštruktor triedy Promise a ako prvý parameter jej dávame funkciu, ktorá prijíma dva parametre.

To je executor, ktorý je zodpovedný za to, aby pripravil všetko potrebné a hlavne aby spustil asynchrónnu činnosť. V našom prípade nastavuje timeout, v loadData potom bude vytvárať a spúšťať request.

Zároveň okrem nastavenia a spustenia asynchrónnej činnosti musíte nájsť tie miesta, ked máte k dispozícii výsledok a zavolať v danom momente funkciu resolve. A tie miesta, kde môže nastať chyba, tam by ste mali zavolať reject.

Ako by teda vyzerala funkcia loadData, ktorá vytvorí a vráti Promise? Takto:

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

My si ju na chvíľu vytvoríme aj s logovaním do konzoly, aby sme neskôr mohli sledovať, čo sa nám kedy deje:

function loadData(url) {
    return new Promise(function (resolve, reject) {
        console.log('executor');
        let request = new XMLHttpRequest();

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

Jej telo je jediný príkaz - return. Vraciame promise, ktorý vytvárame. Všetku jej činnosť sme presunuli do executora. V momente, kedy máme dáta (udalosť load) resolvujeme s dátami. Ak náhodou nastane chyba, tak v danom momente (udalosť error) rejectujeme s informáciou o chybe.

Verejné API - Práca s promisom

Takže, keď už vieme promise vytvoriť, najvyšší čas sa naučiť, ako ho konzumovať. Ako zistiť, že je naplnený a dostať sa k výsledku. Na toto slúži tzv. verejné API.

Promise je objekt a má na sebe dve metódy - then a catch.

Funkcia then prijíma funkciu, ktorú zavolá, keď sa zmení stav promisu na naplnený (fulfilled). Funkcii then môžete poslať dva parametre, druhy by potom bola funkcia, ktorá bude zavolaná, ak by bol promise odmietnutý (rejected).

Funkcia catch potom prijíma funkciu, ktorá je zavolaná, keď je promise rejectnutý - chová sa teda rovnako ako druhý parameter u funkcie then.

Teraz už vieme, ako s promisom pracovať, poďme si teda ukázať, ako by sme použili našu funkciu loadData:

console.log('start');

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

Vieme, že volanie loadData nám vracia promise, tak k nemu ihneď pripájame dva callbacky, oba cez funkciu then. Prvý sa zavolá ak výsledok získame, druhý ak nastane chyba.

Čo uvidíme v konzole a prečo? Ak ste použili loadData s logovaním do konzoly, mali by ste najskôr vidieť texty start, executor a end. To by vás prekvapiť nemalo - kód sa spustí, spustí sa v zápätí executor (synchrónne, spomínate si?) a následne dobehne kód a zapíše sa info o konci.

Niekedy neskôr, keď sa nám vráti výsledok zo servera sa nám do konzoly zapíše resolving, resolve a až potom dáta. Prečo? Lebo aj keď resolving je pred volaním resolve v executorovi a resolved je za, táto funkcia musí najskôr dobehnúť.

Resolvovanie ani rejectovanie jednoducho neprebieha synchrónne - callback je zavolaný asynchrónne. Preto dáta sú zapísané do konzoly ako posledné.

Chcete sa poriadne naučiť JavaScript a ešte sa pritom naučiť nejaký framework či knižnicu (napríklad Angular či React)?

Prídite na niektoré z mojich JavaScriptových školení a naučte sa to rýchlo a jednoducho.

Chainovanie s then a catch

Ako funkcia then, tak aj funkcia catch vracajú promise. A keďže vracajú promise, tak ten má na sebe opäť funkcie then a catch. To nám umožňuje reťazenie (chainovanie).

Ak použijeme chainovanie, tak môžeme volanie loadData prepísať nasledovne:

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

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

Všimnite si, že callback pre rejectnutie teraz už neposielame ako druhý parameter to then, ale posielame ho do catch, ktorá je zapojená za then (rovná sa catch voláme nad promisom, ktorý sa nám vracia z volania then)

Ako je to ale možné? Čo za promise funkcie then a catch vracajú? Prvé, čo by nás mohlo napadnúť je, že vracajú ten istý promise, nad ktorý sú sami volané. Ten to ale nie je. Je to úplne nový promise.

Tento nový promise sleduje návratovú hodnotu callbacku, ktorý bol použitý v then/catch a podľa návratovej hodnoty bude fulfillnutý (zavolá sa najbližší then) alebo rejectnutý (zavolá sa najbližší catch).

Callback vráti normálnu hodnotu

Ak callback vracia normálnu hodnotu, nový promise bude naplnený s touto hodnotou. Ukážme si to na príklade, kde nám promise z loadData vracia tak ako doteraz reťazec s odpoveďou v JSON formáte. A my ju chceme naparsovať a ďalej s ňou pracovať ako s objektom:

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

loadData(url)
    .then(function (data) {
        return JSON.parse(data);
    })
    .then(function (obj) {
        console.log(obj.BTC);
    })
    .catch(function (reason) {
        console.error(reason);
    });

Callback priradený do prvej then funkcie prijíma dáta z odpovede, t.j. reťazec, vola JSON.parse čo naparsuje JSON a vracia objekt. Tento objekt je vrátený z callbacku, jedná sa teda o normálnu hodnotu.

Callback priradený do druhého then prijíma objekt a vypisuje info o hodnote bitcoinu. Podstatné ale je, že tento druhý then nie je zavolaný nad promisom z volania loadData ale nad novým promisom, ktorý vrátila prvá then. A keďže sa jedná o normálnu hodnotu, tento nový promise je resolvnutý.

Callback vyvolá vynímku

Ak callback priradeny do then/catch vyvolá vynímku, promise ktorý táto then/catch funckia vracia bude odmietnutý (rejectnutý). To znamená, že pokračovať sa nebude v nasledujúcom then ale v najbližšej catch funkcii.

Ukázať si to môžeme na podobnom príklade ako naposledy s tým, že pridáme jeden then navyše - ten bude kontrolovať kurz BTC/EUR a ak bude bitcoin lacnejší ako 6000 EUR, vyvolá vynímku:

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

loadData(url)
    .then(function (data) {
        return JSON.parse(data);
    })
    .then(function (obj) {
        if (obj.BTC.EUR < 6000) {
            throw new Error('BTC too cheap');
        }
        return obj;
    })
    .then(function (obj) {
        console.log(obj.BTC);
    })
    .catch(function (reason) {
        console.error(reason);
    });

Prvý then teda zbehne úspešne. Druhý then je pripojený na promise z prvého a vyvolá vynímku (bitcoin je aktuálne pod 6000€). Tretí then je pripojený na promise vrátený z druhého then, ale callback zavolaný nebude, lebo kvôli vyvolanej vynímke je tento promise odmietnutý. Zavolaný bude catch.

Callback vráti promise

Už sme videli, že then/catch môžu vrátiť normálnu hodnotu a môžu vyvolať vynímku. Môžu ale vrátiť promise, niekedy sa vraví, že vracajú thenable (then-able, preložené ako "schopné then", tzn. objekt čo má na sebe then funkciu).

Vtedy promise vrátený z then/catch tzv. followuje promise vrátený z callbacku. Ak bude tento naplnený, bude aj on naplnený. Ak bude promise z callbacku rejectnutý, bude aj promise z then/catch odmietnutý.

Predstavme si, že okrem funckie loadData mame funkciu convertToCzk, ktorá prijíma čiastku v EUR a vráti promise, ktorý ked je naplnený tak jeho hodnota je čiastka v CZK. Potom by sme mohli robiť prepočet v reťazení.

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

loadData(url)
    .then(function (data) {
        return JSON.parse(data);
    })
    .then(function (obj) {
        return convertToCzk(obj.BTC.EUR);
    })
    .then(function (amountInCzk) {
        console.log(amountInCzk);
    })
    .catch(function (reason) {
        console.error(reason);
    });

V tomto prípade druhý then je pripojený na promise z prvého a keďže callback druhého then vracia thenable, teda promise, bude promise vrátený tohto then sledovať ten vrátený. Ten bude v nejakom momente neskôr, t.j. asynchrónne, naplnený (fulfilled) a pokračovať sa tým pádom bude v treťom then.

Statické funkcie Promise

Okrem súkromného API slúžiaceho na vytvorenie promisu a jeho settlement a verejného API na prihlásenie sa k settlementu máme k dispozícii dve zaujímavé funkcie na objekte Promise, teda nie na inštanciách.

Prvou a zrejme zaujímavejšou z tejto dvojice je Promise.all, ktorá prijíma pole promisov a vracia jeden promise. Vrátený promise je odmietnutý ak niektorý z promisov z poľa je odmietnutý.

Ak sú ale všetky naplnené, potom aj vrátený promise je naplnený a jeho hodnotou je pole hodnôt z jednotlivých promisov a to v tom poradí, v ktorom boli samotné promisy predané do funkcie all.

Najbežnejšie použitie Promise.all je, keď chcete naraz spustiť viacero asynchrónnych činností a pokračovať v momente, kedy všetky dobehnú.

Pozrime sa na príklad, kedy chceme naraz spustiť načítanie kurzov. Chceme vedieť kurz BTC v EUR a USD a zároveň kurz LTC v EUR a CZK. Kód by mohol vyzerať nasledovne:

let btcUrl = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC&tsyms=EUR,USD';
let ltcUrl = 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=LTC&tsyms=EUR,CZK';

Promise.all([loadData(btcUrl), loadData(ltcUrl)])
    .then(function (results) {
        return results.map(function(data) {
            return JSON.parse(data);
        }) ;
    })
    .then(function (objects) {
        console.log(objects[0], objects[1]);
    })
    .catch(function (reason) {
        console.error(reason);
    });

Všimnite si, že prvý then prijíma pole, toto mapuje a vracia. Tým pádom druhý then tiež prijíma pole.

Treba mať na pamäti to, že takto sa naozaj spustia obe asynchrónne činnosti naraz. To je dobré v momente, kedy jedna nezávisí na druhej. Keď si spomeniete na predchádzajúci prípad, t.j. prepočet kurzu, tak volanie druhej asynchrónnej činnosti (prepočet) závisí na výsledku prvej (zistenie kurzu) a tak tam Promise.all nebolo možné použiť.

Obdobne, ak by nás napadlo, že miesto Promise.all načítanie kurzu LTC spustíme v then po načítaní kurzu BTC tak nám to nebude bežať naraz, ale za sebou.

Čo v tomto prípade nechceme, zbytočne by sme čakali na výsledok prvej asynchrónnej činnosti len preto, aby sme spustili činnosť druhú.

Druhou funkciou je Promise.race a ako názov napovedá, ideme závodiť. Tiež prijíma pole promisov a vracia jeden promise. Narozdiel od Promise.all ale bude tento vrátený promise vysporiadaný (settled) ihneď akonáhle prvý z promisov bude vysporiadaná (settled, čiže fulfilled alebo rejected) a výsledok bude kopírovať výsledok prvého.

Použitie tejto funkcie je bežné v momente, kedy chcete spustiť viac asynchrónnych operácií a zareagovať na tú najrýchlejšiu. Typicky napríklad máte viac endpointov vracajúcich tie isté dáta a odpoveď chcete čo najrýchlejšie aj za cenu, že requesty pôjdu jednoducho dva.

V tomto prípade je btcUrl1 identická s btcUrl2, ale predstavte si, že nie je, že sú to dve rôzne URL:

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

Promise.race([loadData(btcUrl1), loadData(btcUrl2)])
    .then(function (data) {
        return JSON.parse(data);
    })
    .then(function (obj) {
        return convertToCzk(obj.BTC.EUR);
    })
    .then(function (amountInCzk) {
        console.log(amountInCzk);
    })

Nabudúce

Ak ste poctivo došli až sem, mali by ste mať asynchrónne činnosti v malíčku a mali by ste ich v pohode vedieť spracovať či už cez callbacky, alebo cez promisy.

To je dobre, lebo nabudúce sa pozrieme na ďalšiu novinku a to async/await. Dvojicu, ktorá nám ešte viac zjednoduší prácu.

Tak si to nenechajte ujsť...

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.