Jeśli dopiero zaczynasz programować, prawdopodobnie myślisz o programach jako o zestawie kolejnych bloków logiki, z których każdy blok wykonuje określoną czynność i przekazuje swój wynik, aby mógł zostać uruchomiony następny blok itd. w większości masz rację, większość programów działa w sposób sekwencyjny, ten model pozwala nam budować programy, które są proste w pisaniu i utrzymaniu. Istnieją jednak szczególne przypadki użycia, w których ten model sekwencyjny nie zadziałałby lub nie byłby optymalny. Jako przykład rozważ aplikację czytnika książek. Ta aplikacja ma kilka zaawansowanych funkcji, takich jak wyszukiwanie wszystkich wystąpień słowa, nawigacja między zakładkami i tym podobne. Teraz wyobraź sobie, że użytkownik czyta obecnie długą książkę i postanawia wyszukać wszystkie wystąpienia popularnego słowa, takiego jak „The”. Znalezienie i zindeksowanie wszystkich wystąpień tego słowa zajmuje zazwyczaj aplikacji kilka sekund. W programie sekwencyjnym użytkownik nie może wchodzić w interakcję z aplikacją (zmiana strony, zaznaczenie tekstu) do czasu zakończenia operacji wyszukiwania. Mamy nadzieję, że widzisz, że nie jest to optymalne doświadczenie użytkownika!
Diagram ilustruje typowy przebieg wykonywania aplikacji czytnika książek. Jeśli użytkownik zainicjuje długotrwałą operację (w tym przypadku wyszukiwanie wszystkich wystąpień „the” w dużej księdze), aplikacja „zawiesza się” na cały czas trwania tej operacji. W takim przypadku użytkownik będzie klikał przycisk następnej zakładki bez rezultatu, aż do zakończenia operacji wyszukiwania, a wszystkie operacje zostaną zastosowane od razu, dając użytkownikowi końcowemu poczucie opóźnionej aplikacji.
Być może zauważyłeś, że ten przykład tak naprawdę nie odpowiada modelowi sekwencyjnemu, który przedstawiliśmy wcześniej. Dzieje się tak dlatego, że operacje tutaj są od siebie niezależne. Użytkownik nie musi znać liczby wystąpień „the”, aby przejść do kolejnej zakładki, zatem kolejność wykonywania operacji nie ma większego znaczenia. Nie musimy czekać na zakończenie wyszukiwania, zanim będziemy mogli przejść do kolejnej zakładki. Możliwe ulepszenie poprzedniego przebiegu wykonywania opiera się na tej logice: możemy uruchomić długą operację wyszukiwania w tle, kontynuować wszelkie operacje przychodzące, a po zakończeniu długiej operacji możemy po prostu powiadomić użytkownika. Przebieg wykonania wygląda następująco:
Dzięki temu przepływowi wykonywania znacznie poprawia się komfort użytkownika. Teraz użytkownik może zainicjować długotrwałą operację, normalnie korzystać z aplikacji i otrzymać powiadomienie po zakończeniu operacji. To jest podstawa programowania asynchronicznego.
JavaScript, między innymi językami, obsługuje ten styl programowania asynchronicznego, udostępniając rozbudowane interfejsy API umożliwiające osiągnięcie niemal dowolnego zachowania asynchronicznego, jakie tylko możesz wymyślić. Ostatecznie Javascript powinien być z natury językiem asynchronicznym. Jeśli odniesiemy się do poprzedniego przykładu, logika asynchroniczna leży u podstaw wszystkich aplikacji interakcji z użytkownikiem, a JavaScript został stworzony głównie do użytku w przeglądarce, gdzie większość programów reaguje na działania użytkownika.
Poniżej znajduje się krótki przewodnik po asynchronicznym JavaScript:
Oddzwonienia
W typowym programie zwykle znajdziesz wiele funkcji. Aby skorzystać z funkcji, wywołujemy ją z zestawem parametrów. Kod funkcji wykona się i zwróci wynik, nic niezwykłego. Programowanie asynchroniczne nieznacznie zmienia tę logikę. Wracając do przykładu aplikacji czytnika książek, nie możemy użyć zwykłej funkcji do zaimplementowania logiki operacji wyszukiwania, ponieważ operacja ta zajmuje nieznaną ilość czasu. Zwykła funkcja w zasadzie powróci przed wykonaniem operacji i nie jest to zachowanie, którego oczekujemy. Rozwiązaniem jest określenie innej funkcji, która zostanie wykonana po zakończeniu operacji wyszukiwania. To modeluje nasz przypadek użycia, ponieważ nasz program może normalnie kontynuować działanie, a po zakończeniu operacji wyszukiwania zostanie wykonana określona funkcja, aby powiadomić użytkownika o wynikach wyszukiwania. Tę funkcję nazywamy funkcją wywołania zwrotnego:
// Search occurrences function
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in result variable
//....
callback(null, word, result);
} catch (err) {
callback(err);
}
}
// Search occurrences callback function
function handleSearchOccurrencesResult(err, word, result) {
if (err) {
console.log(`Search operation for ${word} ended with an error`);
} else console.log(`Search results for ${word}: ${result}`);
return;
}
searchOccurrences("the", handleSearchOccurrencesResult);
Najpierw definiujemy funkcję operacji wyszukiwania searchOccurrences. Pobiera słowo do wyszukania i drugi parametr „callback”, który będzie funkcją do wykonania po zakończeniu operacji wyszukiwania. Funkcja operacji wyszukiwania została celowo utrzymana w formie abstrakcyjnej, wystarczy skupić się na jej dwóch możliwych wynikach: w pierwszym przypadku wszystko poszło pomyślnie i wynik wyszukiwania znajduje się w zmiennej wynikowej. W tym przypadku wystarczy wywołać funkcję wywołania zwrotnego z następującymi parametrami: pierwszy parametr ma wartość null, co oznacza, że nie wystąpił żaden błąd, drugi parametr to wyszukiwane słowo, a trzeci i być może najważniejszy z trzech parametrów, jest wynikiem operacji wyszukiwania.
Drugi przypadek ma miejsce, gdy wystąpi błąd. Jest to również przypadek, gdy wykonanie operacji wyszukiwania jest zakończone i musimy wywołać funkcję wywołania zwrotnego. Do przechwycenia dowolnego błędu używamy bloku try and catch i po prostu wywołujemy funkcję wywołania zwrotnego z obiektem błędu z bloku catch.
Następnie zdefiniowaliśmy funkcję wywołania zwrotnego handleSearchOccurrences, utrzymując jej logikę w miarę prostą. To tylko kwestia wydrukowania wiadomości na konsoli. Najpierw sprawdzamy parametr „err”, aby sprawdzić, czy nie wystąpił jakiś błąd w funkcji głównej. W takim przypadku po prostu informujemy użytkownika, że operacja wyszukiwania zakończyła się błędem. Jeżeli nie zgłoszono żadnych błędów, drukujemy komunikat z wynikiem wyszukiwania.
Na koniec wywołujemy funkcję searchOccurrences ze słowem „the”. Funkcja będzie teraz działać normalnie, bez blokowania programu głównego, a po zakończeniu wyszukiwania zostanie wykonane wywołanie zwrotne i otrzymamy komunikat o wyniku albo z wynikiem wyszukiwania, albo z komunikatem o błędzie.
Należy tutaj wspomnieć, że mamy dostęp tylko do zmiennej wynikowej znajdującej się wewnątrz funkcji głównej i funkcji wywołania zwrotnego. Jeśli spróbujemy czegoś takiego:
let result;
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in searchResult variable
//....
result = searchResult;
callback(null, word, result);
} catch (err) {
callback(err);
}
}
searchOccurrences("the", handleSearchOccurrencesResult);
console.log(result);
wynik wydruku byłby niezdefiniowany, ponieważ programy nie czekają na wykonanie funkcji searchOccurrences. Przechodzi do następnej instrukcji, która jest instrukcją print, zanim zmienna wynikowa zostanie przypisana wewnątrz funkcji głównej. W rezultacie wydrukujemy nieprzypisaną zmienną wynikową.
Zatem opierając się na tej logice, powinniśmy zachować cały kod używający zmiennej wynikowej wewnątrz funkcji wywołania zwrotnego. Teraz może to nie wydawać się problemem, ale szybko może przerodzić się w prawdziwy problem. Wyobraź sobie przypadek, w którym mamy łańcuch funkcji asynchronicznych, które muszą działać sekwencyjnie. W typowej logice wywołania zwrotnego zaimplementowałbyś coś takiego:
functionA(function (err, resA) {
///......
functionB(resA, function (err, resB) {
///......
functionC(resB, function (err, resC) {
///......
functionD(resC, function (err, resD) {
///......
});
});
});
});
Należy pamiętać, że każde wywołanie zwrotne ma parametr błędu i każdy błąd musi być obsługiwany osobno. To sprawia, że i tak już złożony kod powyższy jest jeszcze bardziej złożony i trudny w utrzymaniu. Mamy nadzieję, że Obietnice są tutaj, aby rozwiązać problem piekła wywołania zwrotnego, omówimy to w następnej kolejności.
Obietnice
Obietnice są zbudowane na podstawie wywołań zwrotnych i działają w podobny sposób. Zostały one wprowadzone jako część funkcji ES6, aby rozwiązać kilka rażących problemów z wywołaniami zwrotnymi, takimi jak piekło wywołań zwrotnych. Obietnice udostępniają własne funkcje, które działają po pomyślnym zakończeniu (rozwiązanie) i w przypadku wystąpienia błędów (odrzucenie). Poniżej przedstawiono przykład searchOccurrences zaimplementowany z obietnicami:
// Search occurrences function
function searchOccurrences(word) {
return new Promise((resolve, reject) => {
try {
// search operation logic, result is in result variable
//....
resolve(word, result);
} catch (err) {
reject(err);
}
});
}
searchOccurrences("the")
.then((word, result) => {
console.log(`Search results for ${word}: ${result}`);
})
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Przyjrzyjmy się zmianom, które zastosowaliśmy:
Funkcja searchOccurrences zwraca obietnicę. Wewnątrz obietnicy zachowujemy tę samą logikę: mamy dwie funkcje „Resolve” i „Reject”, które reprezentują nasze wywołania zwrotne, a nie pojedynczą funkcję wywołania zwrotnego, która obsługuje zarówno pomyślne wykonanie, jak i wykonanie z błędami. Obietnice oddzielają dwa wyniki i zapewniają przejrzystą składnię podczas wywoływania funkcji głównej. Funkcja rozwiązywania jest „podłączona” do funkcji głównej za pomocą słowa kluczowego „wtedy”. Tutaj po prostu określamy dwa parametry funkcji rozwiązywania i drukujemy wynik wyszukiwania. Podobnie rzecz się ma z funkcją odrzucania, można ją podpiąć za pomocą słowa kluczowego „catch”. Mamy nadzieję, że docenisz korzyści, jakie oferują obietnice w zakresie czytelności kodu i czystości. Jeśli nadal nad tym debatujesz, sprawdź, jak możemy rozwiązać problem piekła wywołania zwrotnego, łącząc funkcje asynchroniczne, aby uruchamiały się jedna po drugiej:
searchOccurrences("the")
.then(searchOccurrences("asynchronous"))
.then(searchOccurrences("javascript"))
.then(searchOccurrences("guide"))
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Asynchronizacja/Oczekiwanie
Async/Await to najnowszy dodatek do naszego asynchronicznego paska narzędzi w języku JavaScript. Wprowadzone w ES8 zapewniają nową warstwę abstrakcji poza funkcjami asynchronicznymi, po prostu „czekając” na wykonanie operacji asynchronicznej. Przebieg programu blokuje się w tej instrukcji do momentu zwrócenia wyniku operacji asynchronicznej, po czym program przejdzie do następnej instrukcji. Jeśli myślisz o synchronicznym przepływie wykonywania, masz rację. Zatoczyliśmy koło! Async/await próbuje wprowadzić prostotę programowania synchronicznego do świata asynchronicznego. Należy pamiętać, że jest to dostrzegalne jedynie w wykonaniu i kodzie programu. Wszystko pozostaje takie samo pod maską, Async/await nadal używa obietnic, a wywołania zwrotne są ich elementami składowymi.
Przeanalizujmy nasz przykład i zaimplementujmy go za pomocą Async/await:
async function searchOccurrences(word) {
try {
// search operation logic, result is in result variable
//....
return result;
} catch (err) {
console.log(`Search operation for ${word} ended with an error`);
}
}
const word = "the";
const result = await searchOccurrences(word, handleSearchOccurrencesResult);
console.log(`Search results for ${word}: ${result}`);
Nasz kod niewiele się zmienił, warto zwrócić uwagę na słowo kluczowe „async” przed deklaracją funkcji searchOccurrences. Oznacza to, że funkcja jest asynchroniczna. Zwróć także uwagę na słowo kluczowe „await” podczas wywoływania funkcji searchOccurrences. Spowoduje to, że program będzie czekał na wykonanie funkcji do momentu zwrócenia wyniku, zanim program będzie mógł przejść do następnej instrukcji, innymi słowy, zmienna wyniku zawsze będzie zawierać wartość zwróconą przez funkcję searchOccurrences, a nie obietnicę funkcja w tym sensie Async/Await nie ma stanu oczekującego jako Obietnice. Po zakończeniu wykonywania przechodzimy do instrukcji print i tym razem wynik faktycznie zawiera wynik operacji wyszukiwania. Zgodnie z oczekiwaniami nowy kod zachowuje się tak samo, jakby był synchroniczny.
Kolejną drobną rzeczą, o której należy pamiętać, jest to, że ponieważ nie mamy już funkcji wywołania zwrotnego, musimy obsłużyć błąd searchOccurrences w tej samej funkcji, ponieważ nie możemy po prostu przekazać błędu do funkcji wywołania zwrotnego i tam go obsłużyć. Tutaj po prostu drukujemy komunikat o błędzie w przypadku błędu, dla przykładu.
Podsumowanie
W tym artykule omówiliśmy różne podejścia stosowane do implementacji logiki asynchronicznej w JavaScript. Zaczęliśmy od zbadania konkretnego przykładu, dlaczego musielibyśmy przejść od zwykłego synchronicznego stylu programowania do modelu asynchronicznego. Następnie przeszliśmy do wywołań zwrotnych, które są głównymi elementami składowymi asynchronicznego JavaScript. Ograniczenia wywołań zwrotnych doprowadziły nas do różnych alternatyw, które dodaliśmy na przestrzeni lat w celu przezwyciężenia tych ograniczeń, głównie obietnic i asynchronizacji/await. Logikę asynchroniczną można znaleźć w dowolnym miejscu sieci, niezależnie od tego, czy wywołujesz zewnętrzne API, inicjujesz zapytanie do bazy danych, piszesz do lokalnego systemu plików, czy nawet czekasz na dane wejściowe użytkownika w formularzu logowania. Mamy nadzieję, że teraz czujesz się pewniej w radzeniu sobie z tymi problemami, pisząc czysty i łatwy w utrzymaniu asynchroniczny JavaScript!
Jeśli spodobał Ci się ten artykuł, zajrzyj na Blog CLA, gdzie omawiamy różne tematy dotyczące wejścia do branży technologicznej. Zajrzyj też na nasz kanał YouTube, aby zobaczyć nasze poprzednie bezpłatne warsztaty i śledź nas w mediach społecznościowych, aby nie przegapić nadchodzących!
- Zabezpiecz swoją karierę na przyszłość, podnosząc umiejętności w zakresie HTML, CSS i JavaScript dzięki Code Labs Academy Web Development Bootcamp.*