Ako na asynchrónny kód v JavaScripte (1.) - Callbacky

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

Jednou z noviniek v ES2015 sú Promises (prísľuby), ktoré nám pomáhajú s asynchrónnym kódom. Ale než si o nich povieme, musíme pochopiť, čo sa promisy snažia riešiť. Musíme pochopiť, čo je to asynchrónny kód, aké problémy nám spôsobuje a ako sa tieto problémy dajú riešiť callbackmi.

Jednovláknový JavaScript

JavaScript je jednovlánkový. Čo je dobré aj zlé. Závisí to od uhla pohľadu.

Dobré na tom je to, že nemusíte riešiť viac vlákien, v každom okamihu beží váš kód iba na jednom. Teda, žiadna správa vlákien, žiadne problémy s konkurentne bežiacim kódom. Ak ste na JavaScript prešli napríklad z .netu, potom isto oceníte, že nemusíte používať lock a podobéné konštrukty.

To horšie na tom je, že to beží iba na jednom vlákne :) A teda, je jedno koľko máte procesorov či ich jadier. Proste to beží na jednom vlákne. I keď v Node.js sa toto dá čiastočne obísť, vychádzajme z predpokladu, že to proste beží na jednom. A teda to môže byť pomalé.

No a teraz si predstavte, že by všetok váš JavaScriptový kód bol čisto synchrónny. To znamená, že by bežal pekne jeden príkaz za druhým, než by sa vykonalo všetko čo sa má a potom by to skončilo. Znie to dobre?

No, možno to znie fajn do momentu, než zistíte, že synchrónny kód je pri trochu komplexnejších aplikáciách nereálny. Chcete príklady?

  • Čo ak chcete v JavaScripte napísať webový server, reagujúci na prichádzajúce HTTP requesty?
  • Čo ak chcete naopak z vašej aplikácie robiť HTTP requesty?
  • Čo ak potrebujete vo webovej aplikácii reagovať na podnety od užívateľa, ako sú napríklad udalosti myši či klávesnice?

To sú jednoducho prípady, kedy musíte mať možnosť pracovať asynchrónne. Lebo vopred neviete povedať, kedy udalosti nastanú, oni nastanú asynchrónne.

Potreba asynchrónnosti

Poďme sa na to pozrieť cez jednoduchý prípad. Chceme načítať dáta cez HTTP protokol. Napríklad zavolať API, ktoré nám vráti aktuálny kurz kryptomien v EUR. Čo je na tom asynchrónne?

Jednoduché volanie, ale nie celkom. Rozoberme si ho na drobné. Potrebujeme nadviazať spojenie so serverom, s ktorým chceme komunikovať. Potrebujeme naň poslať nejaké dáta. Serveru chvíľu trvá, než našu požiadavku spracuje. Potom nám odpovie a my potrebujeme tieto dáta prijať a spracovať. Nuž a až teraz máme informácie o kurzoch.

Ak sa na to pozrieme z pohľadu času, tak v nejakom čase, označme si ho T, chceme spustiť komunikáciu. Lenže, odpoveď nám príde až v čase T plus X milisekúnd v závislosti na tom, aké máme rýchle pripojenie na internet a ako rýchlo dokáže cielový server odpovedať.

Optimisticky som to pomenoval T plus X milisekúnd, kde za X si môžeme dosadiť nejaké číslo. Lenže to číslo môže byť obrovské. Dokonca tak obrovské, že sa zrazu namiesto o milisekundách bavíme o sekundách. A to už môže byť problém.

Takže, povedzme si, že celé to trvá 3000 milisekúnd, inak povedané 3 sekundy. Čo má JavaScript počas tohto času robiť? Ak by bol kód synchrónny, tak by sa nič iné diať nemohlo. Len by sme čakali na odpoveď. Jednoducho počas tohto času by sme to jedno jediné vlákno, ktoré máme k dispozícii, zablokovali.

Asynchrónnosť v JavaScripte

JavaScriptový runtime, či už v prehliadači alebo cez Node.js, má podporu pre asynchrónny kód. Veci, ktoré môžu byť asynchrónne také proste sú. Najčastejšie sa bavíme o tzv. I/O (t.j. Input/Output) operáciách. Napríklad HTTP komunikácia je jedna z nich. Udalosti klávesnice tiež. Aj udalosti myši.

Pokiaľ sa budeme baviť o prehliadačoch, asynchrónnosť sa rieši najčastejšie cez event listenery - funkcie priradené k nejakej udalosti. Pozrime sa na príklad HTTP komunikácie použitím štandardného objektu XMLHttpRequest:

console.log('start');

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

let request = new XMLHttpRequest();

request.addEventListener('load', function () {
    console.log(this.responseText);
});
request.addEventListener('error', function () {
    console.log('failed to load data');
});
request.open('GET', url);
request.send();

console.log('end');

Ak si v prehliadači spustíte tento kód, v konzole najskôr uvidíte text start, potom end a až potom odpoveď zo servera. Prečo?

Dôvodom je samozrejme asynchrónnosť - vaša funkcia, ktorú ste priradili na udalosť load sa naozaj spustí asynchrónne - až v momente, kedy došla odpoveď zo servera. Čo je až po tom, čo dobehol tento váš skript na koniec.

Ak sa pozriete na kód, runtime spustí všetky riadky kódu, jeden za druhým. Na riadku, kde priradujete funkciu k udalosti sa ale nezdrží, nečaká na odpoveď. Na ňom vy len hovoríte: až niekedy v budúcnosti príde odpoveď, to znamená dáta sa načítajú, zavolaj túto funkciu. Ale funkcia sa v tomto bode ešte nespúšťa. Dáta ešte nie sú načítané.

Samotné HTTP volanie proste prebieha asynchrónne, mimo JavaScriptové vlákno. No a neskôr, keď dáta prídu, runtime vie, že má zavolať (spustiť) funkciu, ktorú ste priradili k udalosti. A opať, spustí ju a pôjde riadok za riadkom, príkazy bude vykonávať synchrónne jeden za druhým až do momentu, kedy vykoná všetky príkazy. V našom prípade je tam len jeden, zápis do konzoly.

O tom ako to funguje interne tento článok nie je, ale pokiaľ nechcete počkať, až o tom raz napíšem, a že to plánujem, tak skúste zagoogliť a nájsť si niečo o event loop.

Ako na asynchrónnu funkciu

Asynchrónnosti teda rozumieme.

Poďme teda k nepríjemnému dôsledku asynchronnosti: nemožnosti napísania funkcie, ktorá spúšťa asynchrónny kód a zároveň vracia výsledok tejto asychnrónnej činnosti.

Pozrite sa znovu na náš kód a zamyslite sa nad tým, ako z neho spraviť funkciu, ktorá URL príjme ako argument, aby sme mali peknú, znovu použiteľnú funkciu. Viete z nej dáta vrátiť?

Toto by nás mohlo napadnúť napísať, ale už vopred upozorňujem, že to nebude fungovať tak, ako by sme chceli:

function loadData(url) {
    let request = new XMLHttpRequest();

    request.addEventListener('load', function () {
        return this.responseText;
    });
    request.addEventListener('error', function () {
        console.log('failed to load data');
    });
    request.open('GET', url);
    request.send();
}

Prečo to nebude fungovať? Máme tam síce return a tento return dokonca aj vracia odpoveď servera. Ale, tento return je v zanorenej funkcii. To znamená, tieto dáta sú vrátené zo zanorenej funkcie a nie z našej. A tie zo zanorenej nikto nesleduje.

Navyše, v momente ked tento return nastane, naša funkcia už dávno skončila. Funkcia loadData teda len spustí HTTP volanie, nemá ale šancu počkať na výsledok.

Co s tým?

Staré dobré callbacky

V momente, kedy z funkcie neviete vrátiť dáta, lebo aj vy sami ich budete mať až asynchrónne neskôr, neviete v nej spraviť return. Na pomoc vám prídu callbacky.

Callback je názov používaný pre funkciu, ktorú vaša funkcia prijíma ako argument a ktorú neskôr zavolá. Zavolá (call) spať (back).

A práve to zavolanie je dôležité. Namiesto toho, aby ste vrátili dáta, čo neviete, vravíte: OK, tak vieš čo? Neviem ti dať dáta priamo, ale keď mi dáš funkciu, tak ja ju zavolám, keď budem mať tie dáta k dispozícii. A tie dáta ti predám do tejto funkcie, do callbacku.

Ako by sme teda funkciu upravili?

function loadData(url, callback) {
    let request = new XMLHttpRequest();

    request.addEventListener('load', function () {
        callback(this.responseText);
    });
    request.addEventListener('error', function () {
        console.log('failed to load data');
    });
    request.open('GET', url);
    request.send();
}

Čiže jednoduchá zmena, okrem parametru url prijímame ešte callback, funkciu, ktorú zavoláme, keď máme my sami dáta. Ako by sme loadData zavolali? Takto:

console.log('start');

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

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

console.log('end');

V konzole zase uvidíme najskôr start, potom end a až neskôr samotnú odpoveď. Na tom sa nič nemení. Raz keď máte asynchrónnosť, na synchrónnosť ju nikdy nezmeníte.

Ale vyriešili sme aspoň problém, že sme nevedeli, ako poskytnúť dáta smerom von, tomu, kto nás volá. Teraz to vieme, callbackom.

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.

Ako na neúspechy

Šťastná cesta (happy path) by nám fungovala, čo ale v prípade, že nám asynchrónna akcia neskončí úspechom? Ako to signalizovať?

Bežne sa to robí dvomi spôsobmi.

Vo svete webových prehliadačov je bežné, že funkcia neprijíma jeden callback, ale dva. Jeden, ktorý bude zavolaný s dátami v prípade úspechu a druhý, ktorý bude zavolaný v prípade chyby s prípadným detailom o chybe.

Prepísať by sme mohli funkciu nasledovne:

function loadData(url, success, failure) {
    let request = new XMLHttpRequest();

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

Teraz teda prijíma dva callbacky, success a failure. Prvý sa volá v prípade úspechu, druhý v prípade chyby. Do druhého posielame inštanciu triedy Error, čo je dobrá praktika.

A takto by sme ju zavolali:

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

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

Error-first callback

V Node.js je zaužívaný iný pattern a hovorí sa mu Error-First Callback, čiže niečo ako callback s chybou na prvom mieste. A to pomenovanie je trefné.

Napriek tomu, že je bežné v Node.js svete, nie je na ňom nič špeciálne. Nič vám teda nebráni tento vzor použiť aj vo webovom prehliadači.

Pokiaľ funkciu prepíšeme podľa tohto vzoru, nebudeme prijímať dva callbacky, ale opäť iba jeden.

error-first konvencia hovorí, že callback voláme s chybou ako prvým parametrom. Ak teda chyba nastala. Ak chyba nenastala, máme zavolať callback s prvým parametrom nastaveným na null a až druhý parameter má obsahovať dáta.

Naša loadData by teda vyzerala nasledovne, ak by sme chceli použiť error-first:

function loadData(url, callback) {
    let request = new XMLHttpRequest();

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

V prípade úspechu posielame null ako prvý argument a samotné dáta ako druhý. V prípade chyby posielame chybu ako prvý argument a druhý neposielame, t.j. druhý bude undefined.

Samotný callback toto potom musí rešpektovať. Mal by sa najskôr pozrieť, či nenastala chyba (t.j. prvý argument nie je falsy). Ak nastala, má ju nejakým spôsobom ošéfovať. A ak nenastala, môže očakávať dáta v druhom argumente:

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.error(err);
    } else {
        console.log(data);
    }
})

console.log('end');

Nabudúce

Na dnes sme toho prebrali viac než dosť.

V pokračovaní tohto mini seriálu sa pozrieme na novú triedu Promise, novinku v ES2015 (ale nie na novinku v JavaScripte, promises už existovali ako externé knižnice) a ako nám uľahčia prácu s asynchrónnym kódom.

Zostaňte preto naladení...

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.

Komentáre

K tomuto článku nie su pridané žiadne komentáre.

Pridať komentár

Máš niečo zaujímavé povedať k článku? Pridaj to k článku ako komentár. Spam, reklamu alebo inak nerelevantné komentáre okamžite mažem.