Czym jest asynchroniczny JavaScript i dlaczego go potrzebujesz
Asynchroniczny JavaScript to model programowania, który pozwala na wykonywanie długo trwających operacji w tle, bez blokowania głównego wątku. Pozwala to na bardziej efektywne wykorzystanie zasobów i płynniejszą obsługę wielu zadań jednocześnie. Typowe operacje asynchroniczne to na przykład zapytania sieciowe, operacje na bazach danych, czy operacje na plikach. W JS asynchroniczność jest możliwa dzięki mechanizmom takim jak callbacki, Promise i async/await.
Wyobraź sobie, że jesteś w ruchliwej kawiarni. Kiedy składasz zamówienie, barista nie stoi bezczynnie czekając, aż twoja kawa się zaparzy. Zamiast tego, przyjmuje więcej zamówień i przygotowuje inne napoje. Gdy twoja kawa jest gotowa, barista wywołuje twoje imię. Właśnie tak działa też asynchroniczny JavaScript. Tworząc kod asynchroniczny, pozwalasz swojej aplikacji rozpocząć zadanie (takie jak pobieranie danych z serwera) i kontynuować wykonywanie innych operacji podczas oczekiwania na zakończenie tego zadania. Gdy zadanie zostanie ukończone, twój kod obsłuży wynik i idzie dalej.
W JS takie podejście jest szczególnie ważne, bo jest to język jednowątkowy. Oznacza to, że język jako taki może wykonywać tylko jedną operację naraz, natomiast operacje asynchroniczne są możliwe dzięki środowisku wykonawczemu (runtime), czyli w praktyce dzięki przeglądarce lub Node.js. Przyjrzyjmy się temu bliżej w dalszej części artykułu.
Aby Twoja aplikacja w JavaScript mogła być szybka i responsywna, musisz korzystać z technik asynchronicznych. Budujesz aplikację frontendową? Nie może ona „zamrażać się” na czas ładowania danych. Tworzysz backend w Node.js? Musisz być w stanie obsłużyć wiele operacji wejścia/wyjścia jednocześnie. W obu przypadkach techniki asynchroniczne są narzędziem, które pozwoli Ci zrealizować te cele.
Spis treści
Programowanie synchroniczne i asynchroniczne
Operacje asynchroniczne są powszechne w dzisiejszym programowaniu – od pobierania danych z API i obsługi interakcji użytkownika po odczytywanie danych z plików lub bazy danych. W praktyce trudno byłoby napisać coś użytecznego w JS bez użycia jakichś technik asynchronicznych. Zatem aby naprawdę opanować programowanie w JavaScript, musisz zrozumieć różnicę między kodem synchronicznym i asynchronicznym. Przyjrzyjmy się bliżej tym dwóm koncepcjom.
Programowanie synchroniczne, czyli jedno zadanie na raz
W programowaniu synchronicznym zadania są wykonywane sekwencyjnie, jedno po drugim. Każda operacja musi zakończyć się, zanim rozpocznie się następna. Taki program działa jak linia montażowa, gdzie każde stanowisko musi zakończyć swoją pracę, zanim produkt pójdzie dalej.
console.log("Zadanie 1: start");
// Symulujemy czasochłonną operację:
for (let i = 0; i < 1000000000; i++) {}
console.log("Zadanie 1: koniec");
console.log("Zadanie 2: start"); // Rozpoczyna się dopiero po zakończeniu zadania 1
// Rezultat w konsoli:
// Zadanie 1: start
// Zadanie 1: koniec (po czasie, jaki zajęło wykonanie pętli opóźniającej)
// Zadanie 2: start
Podczas wykonywania tego kodu nic innego nie może się wydarzyć. Przeglądarka się zawiesza, przyciski przestają reagować, a animacje się zatrzymują. Na tym polega „blokująca” natura kodu synchronicznego.
Programowanie asynchroniczne, czyli wielozadaniowość
Na wstępie powiedzieliśmy, że asynchroniczny JavaScript to model programowania, który pozwala na wykonywanie długo trwających operacji w tle, bez blokowania głównego wątku. Jednak sam język JavaScript jest z natury synchroniczny i jednowątkowy. W takim razie, jak to możliwe, że operacje asynchroniczne jednak działają w JS, a nawet są jego kluczowym elementem?
Odpowiedź leży środowisku wykonawczym JavaScriptu, czyli w runtime. Chodzi o to, że silnik JavaScriptu (np. V8 lub SpiderMonkey) działa w środowisku wykonawczym. W praktyce takim środowiskiem jest zwykle przeglądarka lub Node.js, ale może nim też być Electron, czy nowoczesne narzędzia typu Deno.
Środowisko wykonawcze dostarcza JavaScriptowi API umożliwiające interakcje z otoczeniem – takie jak dostęp do plików, sieci, czy interfejsu użytkownika. Na przykład przeglądarka, w ramach tak zwanego Web API oferuje między innymi funkcję fetch
, pozwalającą na pobieranie danych z sieci, oraz setTimeout
, umożliwiającą celowe odroczenie wykonania kodu o określony czas. Większość z tych operacji jest asynchroniczna. Za ich obsługę odpowiada pętla zdarzeń (event loop), która monitoruje kolejki zadań (task queue, microtask queue) i decyduje, które z nich mają być wykonane w danym momencie.
Zatem z jednej strony środowisko wykonawcze (przeglądarka, Node.js) umożliwia operacje asynchroniczne udostępniając odpowiednie API. A z drugiej strony sam JavaScript zawiera rozwiązania takie jak Promise i async-await
, które pozwalają efektywnie pracować z asynchronicznymi API dostarczanymi przez środowisko. Oznacza to, że choć kod JavaScript wykonuje się linia po linii, możesz delegować czasochłonne zadania do API środowiska i używać asynchronicznych narzędzi JavaScript do obsługi wyników, gdy będą gotowe.
Jednym z najprostszych i najstarszych API asynchronicznych w JavaScript jest funkcja setTimeout()
. Pozwala ona na odroczenie wykonania pewnej funkcji o określony czas.
W praktyce wygląda to tak:
console.log("Zadanie 1: start");
setTimeout(() => {
console.log("Zadanie 1: koniec");
}, 1000);
console.log("Zadanie 2: start"); // Nie czeka na Zadanie 1
// Rezultat w konsoli:
// Zadanie 1: start
// Zadanie 2: start
// Zadanie 1: koniec (po 1 sekundzie)
W powyższym przykładzie mamy funkcję setTimeout()
, do której jako argument przekazaliśmy funkcję zwrotną (callback), a następnie podaliśmy czas opóźnienia w milisekundach. Jak już mówiliśmy, setTimeout()
jest obsługiwany przez Web API przeglądarki, która odkłada naszą funkcję callback do wykonania w przyszłości, oraz uruchamia timer. W międzyczasie JavaScript kontynuuje wykonywanie innych operacji. Gdy timer się skończy, funkcja callback zostanie wykonana. Naprawdę istotne jest to, że wykonanie zadania nr 2 rozpoczęło się od razu po rozpoczęciu czasochłonnego zadania nr 1, bez czekania na jego zakończenie. To właśnie jest istotą programowania asynchronicznego.
Dlaczego asynchroniczny JavaScript jest ważny
Zobaczmy dlaczego asynchroniczny JavaScript ma tak duże znaczenie w rzeczywistych aplikacjach. Wyobraź sobie, że budujesz panel, który potrzebuje danych z kilku API: profili użytkowników, dzienników aktywności i danych analitycznych. Bez kodu asynchronicznego każde wywołanie API zamroziłoby Twoją aplikację do czasu, gdy zakończy się pobieranie danych. W oczekiwaniu na dane, Twoi użytkownicy patrzyliby na zamrożony ekran:
// Bez asynchroniczności - blokujące wykonanie
const userProfile = fetchUserProfile(); // Czekamy...
const activityLog = fetchActivityLog(); // Nadal czekamy...
const analytics = fetchAnalytics(); // I jeszcze czekamy...
updateDashboard(userProfile, activityLog, analytics);
Dzięki programowaniu asynchronicznemu operacje te mogą działać jednocześnie:
// Z asynchronicznością - nieblokujące wykonanie
Promise.all([
fetchUserProfile(),
fetchActivityLog(),
fetchAnalytics()
// Wszystkie zapytania są wykonywane równolegle
]).then(([profile, log, analytics]) => {
// Dane są gotowe, aktualizujemy panel
updateDashboard(profile, log, analytics);
});
Dzięki kodowi asynchronicznemu Twoja aplikacja pozostaje responsywna. Użytkownicy nadal mogą wchodzić w interakcję z interfejsem – klikać przyciski, przewijać treści i oglądać animacje – podczas gdy dane ładują się w tle.
I nie chodzi tu tylko o aplikacje działające po stronie przeglądarki. W Node.js kod asynchroniczny umożliwia efektywną obsługę mnóstwa jednoczesnych operacji. Serwer jest w stanie w jednym czasie obsługiwać wielu użytkowników, przetwarzać zapytania do bazy danych, wykonywać operacje na plikach, komunikować się z innymi usługami, i tak dalej. Dzięki takiemu działaniu Node.js świetnie się sprawdza w aplikacjach takich jak na przykład czaty.
Ewolucja asynchronicznego JavaScriptu
Historia asynchronicznego JavaScript odzwierciedla ewolucję samej sieci. Gdy Brendan Eich stworzył JavaScript w 1995 roku, miał to być prosty język skryptowy do podstawowej interaktywności stron internetowych. Dlatego początkowo JavaScript był całkowicie synchroniczny, wykonując tylko jedno zadanie naraz. Sprawdzało się to dobrze w przypadku prostych zadań, takich jak walidacja formularzy czy podstawowa manipulacja DOM. Ale oczekiwania względem serwisów internetowych szybko zaczęły rosnąć, a wraz z nimi potrzeby nowych rozwiązań.
Pierwszym krokiem ku asynchroniczności była wspomniana wcześniej funkcja setTimeout
, wprowadzona już w Netscape Navigator 2.0. Jednak prawdziwy przełom nastąpił na początku lat 2000, kiedy Internet Explorer 5 wprowadził XMLHttpRequest (XHR). Ta innowacja umożliwiła przeglądarkom wykonywanie żądań HTTP bez przeładowywania całej strony – zaczęła się era AJAX (Asynchronous JavaScript and XML).
Tak wyglądał wczesny kod z użyciem XMLHttpRequest (XHR):
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
xhr.open("GET", "data.json", true);
xhr.send();
To było naprawdę rewolucyjne, przełomowe rozwiązanie. Rozwój nowego rodzaju aplikacji wręcz eksplodował. Ale programiści musieli też radzić sobie z nowymi wyzwaniami. Jednym z nich były różnice w implementacji XHR między przeglądarkami. Jednak jeszcze większym problemem była złożoność kodu, jaka wynikała z zagnieżdżania callbacków – zjawisko znane jako „callback hell”:
getData(function (a) {
getMoreData(a, function (b) {
getMoreData(b, function (c) {
getMoreData(c, function (d) {
// Głęboko zagnieżdżone wywołania:
// trudne do czytania i utrzymania
});
});
});
});
Ból walki z tymi problemami motywował społeczność deweloperów JavaScript do poszukiwania rozwiązań. Powstały liczne biblioteki, wśród nich najpopularniejsza jQuery, która uspójniła i ułatwiła zarówno pracę z DOM, jak i korzystanie z XHR. Ale JavaScript potrzebował wbudowanych rozwiązań do zarządzania asynchronicznością. Po wielu eksperymentach, wreszcie pojawił się Promise, który został ustandaryzowany w ES6 (2015):
fetch("data.json")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error(error));
Następnie ES2017 wprowadził async-await
, co jeszcze bardziej uprościło kod asynchroniczny:
async function getData() {
try {
const response = await fetch("data.json");
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
Ten krótki przegląd historii asynchronicznego JavaScriptu pokazuje, jaką drogę przeszły te rozwiązania. Od zagnieżdżonych callbacków, poprzez elegancki Promise, aż do intuicyjnego async-await
. Każde nowe podejście buduje na poprzednich. Ta ewolucja trwa nadal, przykłady to między innymi wzorzec Observable i propozycje ulepszeń obsługi błędów.
Typowe zastosowania asynchronicznego JavaScriptu
Kilkukrotnie wspominałem już, że asynchroniczny JavaScript jest niezbędny do budowy nowoczesnych aplikacji. Więc przyjrzyjmy się teraz paru typowym zastosowaniom asynchroniczności w praktyce.
Żądania do API
Wykonywanie żądań HTTP do API jest jednym z najbardziej powszechnych zastosowań asynchronicznego JavaScriptu. Korzystając z API fetch
lub bibliotek takich jak axios
, aplikacje mogą pobierać dane, jednocześnie zachowując responsywność interfejsu:
async function getUserData() {
try {
const response = await fetch("https://api.example.com/users");
const users = await response.json();
updateUserList(users);
} catch (error) {
handleError(error);
}
}
Obsługa interakcji z użytkownikiem
Asynchroniczny JavaScript jest też sposobem na obsługę interakcji z użytkownikiem bez blokowania aplikacji. Oto przykład obsługi wyszukiwania, które na bieżąco odpytuje API podczas gdy użytkownik wpisuje poszukiwaną frazę:
const searchInput = document.querySelector("#search");
let timeoutId;
searchInput.addEventListener("input", (event) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const results = await searchAPI(event.target.value);
displayResults(results);
}, 300);
});
Operacje na bazie danych
W aplikacjach backendowych operacje na bazach danych są z natury asynchroniczne. Takie podejście zapewnia, że aplikacja pozostaje responsywna podczas operacji na danych:
async function createUser(userData) {
const user = await db.collection("users").insertOne({
name: userData.name,
email: userData.email,
createdAt: new Date(),
});
return user;
}
Timing i interwały
Funkcje czasowe JavaScriptu umożliwiają asynchroniczne wykonanie kody w sposób z góry zaplanowany:
function showNotification(message) {
const notification = document.createElement("div");
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
Operacje na systemie plików
Aplikacje Node.js muszą efektywnie obsługiwać operacje na plikach. API fs.promises
pozwala to zrobić asynchronicznie w prosty i wygodny sposób:
import { promises as fs } from "fs";
async function processLogFile() {
const logContent = await fs.readFile("app.log", "utf8");
const processedData = analyzeLog(logContent);
await fs.writeFile("processed.log", processedData);
}
Przetwarzanie w tle
Jeżeli nasza aplikacja ma do wykonania intensywne obliczenia, możemy wydelegować je do osobnego wątku. Służy do tego API Web Worker. W poniższym przykładzie opakowaliśmy komunikację z workerem w Promise, dzięki czemu łatwo możemy obsłużyć wynik operacji:
async function processLargeDataset(data) {
return new Promise((resolve) => {
const worker = new Worker("processor.js");
worker.postMessage(data);
worker.onmessage = (event) => {
resolve(event.data);
};
});
}
Komunikacja w czasie rzeczywistym
Niektóre aplikacje wymagają ciągłej komunikacji z serwerem. Na przykład w celu aktualizacji danych na dashboardzie, albo odczytywania nowych wiadomości na czacie. Zwykle wykorzystuje się do tego API WebSocket, które pozwala na dwukierunkową komunikację pomiedzy klientem a serwerem:
const socket = new WebSocket("wss://chat.example.com");
socket.addEventListener("message", async (event) => {
const message = JSON.parse(event.data);
await updateChatUI(message);
await markMessageAsReceived(message.id);
});
Dynamiczne ładowanie zasobów
Czasem dla optymalizacji wydajności aplikacji chcemy załadować pewne zasoby dopiero wtedy, gdy są nam potrzebne. Może to dotyczyć skryptów, styli CSS, czy obrazów. Tutaj również możemy skorzystać z asynchroniczności:
async function loadEditorModule() {
if (userStartsEditing) {
const { default: Editor } = await import("./editor.js");
const editor = new Editor();
editor.mount("#editor-container");
}
}
Jak uczyć się asynchronicznego JavaScriptu?
Kod synchroniczny jest prostszy do zrozumienia, bo wykonywany jest sekwencyjnie, linia po linii. Takie sekwencyjne działanie jest intuicyjne i naturalne dla naszych umysłów. Myśląc o realizacji jakiegoś zadania, zazwyczaj mamy w głowie listę kroków: 1) najpierw to, 2) potem to, 3) a na końcu tamto. Dlatego kod synchroniczny naturalnie odzwierciedla nasz sposób myślenia. Myśląc o kodzie synchronicznym, w ogóle nie musisz brać pod uwagę wpływu czasu na wykonanie programu. Poszczególne kroki zostaną wykonane w takiej kolejności, w jakiej je napisałeś.
Zupełnie inaczej jest w przypadku kodu asynchronicznego. Tutaj kolejność wykonywania poszczególnych kroków jest nieprzewidywalna, bo zależy od czasu, jaki zajmie wykonanie danej operacji. W związku z tym, aby zrozumieć kod asynchroniczny, musisz zmienić swoje podejście. Zamiast myśleć o krokach, musisz myśleć o zdarzeniach i zależnościach między nimi. Zamiast planować, co zrobić po czym, musisz myśleć o tym, co zrobić, gdy dane zdarzenie nastąpi. Takie przestawienie sposobu myślenia jest sporym wyzwaniem, szczególnie na początku. Dlatego nauka asynchronicznego JavaScriptu wymaga systematycznego podejścia.
Zacznij od zrozumienia jednowątkowej natury JavaScriptu i dlaczego potrzebujemy programowania asynchronicznego. Następnie przejdź przez główne wzorce asynchroniczne: callbacki, Promises i async/await. Każdy z tych wzorców opiera się na poprzednim, oferując coraz bardziej eleganckie rozwiązania typowych problemów asynchronicznych. W dalszej kolejności przejdź do bardziej zaawansowanych tematów, jak synchronizacja wielu operacji, obsługa błędów, czy zarządzanie stanem.
Aby bardziej zgłębić ten temat, sprawdź nasz kompletny przewodnik po asynchronicznym JavaScript. Znajdziesz w nim szczegółowe wyjaśnienia wszystkich istotnych konceptów, praktyczne przykłady i zaawansowane wzorce.
Pytania i odpowiedzi
Czym jest asynchroniczny JavaScript?
Asynchroniczny JavaScript pozwala rozpocząć długotrwałe operacje i kontynuować wykonywanie innego kodu podczas oczekiwania na zakończenie tych operacji. Gdy operacja się zakończy, funkcja callback lub handler Promise przetworzy wynik.
To podejście sprawdza się dobrze w przypadku:
- Wykonywania żądań HTTP
- Czytania plików w Node.js
- Dostępu do baz danych
- Ustawiania timerów lub interwałów
- Obsługi interakcji użytkownika
Oto praktyczny przykład:
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => console.log(data));
console.log("Ta linia wykona się natychmiast, podczas gdy dane są pobierane");
Jaka jest różnica między synchronicznym i asynchronicznym JavaScriptem?
Synchroniczny JavaScript wykonuje kod sekwencyjnie, kolejna operacja zaczyna się dopiero po zakończeniu poprzedniej:
const result = someTimeConsumingOperation(); // Blokuje wykonanie reszty kodu
console.log('Ta linia wykona się dopiero po zakończeniu poprzedniej');
Asynchroniczny JavaScript pozwala na równoczesne wykonywanie wielu operacji:
someAsyncOperation().then(result => {
console.log('Obsługuje wynik asynchronicznej operacji gdy jest gotowy');
});
console.log('Ten kod wykona się natychmiast, bez czekania');
Najważniejsze różnice to:
- Kolejność wykonania: Kod synchroniczny działa sekwencyjnie, a kolejność zakończenia operacji asynchronicznych może się różnić.
- Zachowanie blokujące: Operacje synchroniczne blokują wykonywanie innych operacji, a operacje asynchroniczne nie blokują.
- Efektywność wykorzystania zasobów: Kod asynchroniczny bardziej efektywnie wykorzystuje zasoby systemowe: CPU i pamięć.
- Złożoność: Kod synchroniczny jest często prostszy do śledzenia, natomiast zrozumienie kodu asynchronicznego jest trudniejsze z powodu jego niedeterministycznego zachowania (które wynika z nieprzewidywalnego czasu wykonania operacji).
Czy JavaScript jest z natury synchroniczny czy asynchroniczny?
JavaScript jest z natury synchroniczny i jednowątkowy, ale obsługuje operacje asynchroniczne dzięki mechanizmom środowiska wykonawczego (przeglądarka lub Node.js). Środowisko wykonawcze dostarcza pętlę zdarzeń (event loop), która zarządza kolejnością wykonywania kodu. Pętla zdarzeń korzystaja z dwóch głównych kolejek: microtask queue, gdzie trafiają na przykład callbacki Promisów, oraz callback queue, gdzie trafiają callbacki na przykład z setTimeout
czy zdarzeń DOM. Dzięki tym mechanizmom JavaScript może efektywnie obsługiwać operacje asynchroniczne, mimo że działa w pojedynczym wątku.
Spójrz na ten kod:
console.log("Start");
setTimeout(() => console.log("Timer done!"), 1000);
console.log("End");
Operacja setTimeout
jest obsługiwana przez API przeglądarki, a nie przez sam JavaScript. JavaScript deleguje to zadanie do środowiska hosta, które posiada mechanizmy do jego realizacji (timer oraz wspomnianą pętlę zdarzeń).
Przez długi czas mechanizm obsługi asynchroniczności w JavaScript nie był żaden sposób zdefiniowany w specyfikacji języka. Polegał jedynie na praktycznych implementacjach twórców przeglądarek, Node.js i innych środowisk wykonawczych. Dopiero ECMAScript 2015 (ES6) sformalizował zachowanie asynchroniczne w języku opisując mechanizm działania Promise. Zatem od tego momentu JavaScript oficjalnie wspiera operacje asynchroniczne w oparciu o wyraźnie zdefiniowany mechanizm. Ale nie zmienia to faktu, że JavaScript jest językiem jednowątkowym, a asynchroniczność jest możliwa dzięki środowisku wykonawczemu.
Podsumowanie
Kod asynchroniczny jest podstawą nowoczesnego programowania w JavaScript. Dzięki niemu aplikacje mogą działać płynnie, wykonując operacje w tle, bez blokowania głównego wątku. Mechanizmy takie jak callbacki, Promise i async/await
pozwalają skutecznie zarządzać asynchronicznymi operacjami na poziomie języka, natomiast środowisko wykonawcze (przeglądarka, Node.js) dostarcza API umożliwiające działanie tych operacji.
Rozumienie asynchroniczności jest niezbędne zarówno dla programistów frontendowych, jak i backendowych, ponieważ umożliwia efektywne zarządzanie operacjami sieciowymi, interakcjami użytkownika czy operacjami na bazach danych.