Lekcja 14: Asynchroniczny JavaScript - Funkcje zwrotne (Callbacks)

JavaScript jest językiem jednowątkowym, co oznacza, że może wykonywać tylko jedną operację naraz. Jednak wiele operacji, zwłaszcza tych związanych z siecią (pobieranie danych), operacjami na plikach (w środowisku Node.js) czy timerami (setTimeout, setInterval), zajmuje czas. Aby uniknąć blokowania głównego wątku i zamrożenia interfejsu użytkownika podczas oczekiwania na zakończenie tych operacji, JavaScript wykorzystuje mechanizmy asynchroniczne.

Jednym z najwcześniejszych i podstawowych mechanizmów obsługi asynchroniczności są funkcje zwrotne (callbacks).

Co to jest funkcja zwrotna (Callback)?

Funkcja zwrotna to funkcja przekazywana jako argument do innej funkcji, z zamiarem wywołania jej (czyli "oddzwonienia" - stąd nazwa) w późniejszym czasie, zazwyczaj po zakończeniu jakiejś operacji asynchronicznej lub wystąpieniu zdarzenia.

function wykonajOperacje(dane, callback) {
    console.log(`Rozpoczynam operację z danymi: ${dane}`);
    // Symulacja operacji asynchronicznej (np. zapytanie do serwera)
    setTimeout(() => {
        let wynik = dane.toUpperCase(); // Przykładowe przetworzenie danych
        console.log("Operacja zakończona.");
        // Wywołanie funkcji zwrotnej z wynikiem
        callback(wynik);
    }, 2000); // Operacja zajmie 2 sekundy
}

// Definicja funkcji, która będzie naszym callbackiem
function wyswietlWynik(rezultat) {
    console.log(`Otrzymany rezultat: ${rezultat}`);
}

console.log("Przed wywołaniem wykonajOperacje");
wykonajOperacje("przykladowe dane", wyswietlWynik);
console.log("Po wywołaniu wykonajOperacje (ale przed zakończeniem operacji)");

/* Oczekiwany output w konsoli:
Przed wywołaniem wykonajOperacje
Rozpoczynam operację z danymi: przykladowe dane
Po wywołaniu wykonajOperacje (ale przed zakończeniem operacji)
(po 2 sekundach)
Operacja zakończona.
Otrzymany rezultat: PRZYKLADOWE DANE
*/

W powyższym przykładzie wyswietlWynik jest funkcją zwrotną przekazaną do wykonajOperacje. Zostaje ona wywołana dopiero po upływie 2 sekund, gdy symulowana operacja asynchroniczna (setTimeout) się zakończy.

Callback Hell (Piramida Zagłady)

Problem z callbackami pojawia się, gdy musimy wykonać kilka operacji asynchronicznych jedna po drugiej, gdzie każda kolejna operacja zależy od wyniku poprzedniej. Prowadzi to do głębokiego zagnieżdżania funkcji zwrotnych, tworząc strukturę znaną jako "Callback Hell" lub "Pyramid of Doom". Taki kod staje się trudny do czytania, debugowania i utrzymania.

// Przykład Callback Hell
function krok1(wartosc, callback) {
    setTimeout(() => {
        console.log("Krok 1 zakończony");
        callback(wartosc + 1);
    }, 500);
}

function krok2(wartosc, callback) {
    setTimeout(() => {
        console.log("Krok 2 zakończony");
        callback(wartosc * 2);
    }, 500);
}

function krok3(wartosc, callback) {
    setTimeout(() => {
        console.log("Krok 3 zakończony");
        callback(wartosc - 3);
    }, 500);
}

// Zagnieżdżone wywołania
krok1(10, function(wynik1) {
    console.log("Wynik kroku 1:", wynik1); // 11
    krok2(wynik1, function(wynik2) {
        console.log("Wynik kroku 2:", wynik2); // 22
        krok3(wynik2, function(wynik3) {
            console.log("Wynik kroku 3:", wynik3); // 19
            console.log("Wszystkie kroki zakończone!");
            // Kolejne zagnieżdżenia...
        });
    });
});

Jak widać, kod szybko staje się nieczytelny z powodu wielu poziomów wcięć i zagnieżdżonych funkcji.

Obsługa błędów w Callbackach

Standardowym wzorcem obsługi błędów w funkcjach asynchronicznych używających callbacków jest tzw. "error-first callback". Funkcja zwrotna jako pierwszy argument przyjmuje potencjalny obiekt błędu. Jeśli operacja się nie powiedzie, ten argument będzie zawierał obiekt błędu, a pozostałe argumenty (wynikowe) będą zazwyczaj null lub undefined. Jeśli operacja się powiedzie, pierwszy argument (błąd) będzie null lub undefined.

function operacjaZbledem(czySukces, callback) {
    console.log("Rozpoczynam operację z możliwością błędu...");
    setTimeout(() => {
        if (czySukces) {
            // Sukces - pierwszy argument callbacka to null
            callback(null, "Operacja zakończona sukcesem!");
        } else {
            // Błąd - pierwszy argument callbacka to obiekt błędu
            callback(new Error("Coś poszło nie tak!"), null);
        }
    }, 1000);
}

// Wywołanie z oczekiwaniem sukcesu
operacjaZbledem(true, (blad, wynik) => {
    if (blad) {
        console.error("Wystąpił błąd:", blad.message);
    } else {
        console.log("Wynik operacji (sukces):", wynik);
    }
});

// Wywołanie z oczekiwaniem błędu
operacjaZbledem(false, (blad, wynik) => {
    if (blad) {
        console.error("Wystąpił błąd:", blad.message);
    } else {
        console.log("Wynik operacji (błąd):", wynik); // Ten blok się nie wykona
    }
});

Zawsze należy sprawdzać pierwszy argument callbacka (błąd) przed próbą użycia wyniku.

Podsumowanie

Funkcje zwrotne były historycznie pierwszym sposobem radzenia sobie z asynchronicznością w JavaScript. Są nadal używane w wielu miejscach (np. obsługa zdarzeń, starsze API Node.js), ale mają swoje wady, głównie problem "Callback Hell" i potencjalnie skomplikowaną obsługę błędów przy wielu zagnieżdżeniach.

W kolejnych lekcjach poznamy nowocześniejsze i bardziej eleganckie rozwiązania problemu asynchroniczności: Obietnice (Promises) oraz składnię `async/await`.

Zadanie praktyczne

Napisz funkcję pobierzDaneUzytkownika(id, callback), która symuluje pobieranie danych użytkownika z serwera. Funkcja powinna użyć setTimeout, aby zasymulować opóźnienie 1 sekundy. Po tym czasie, funkcja powinna wywołać przekazany callback, przekazując mu obiekt użytkownika, np. { id: id, imie: "Jan", email: "jan@example.com" }.

Następnie wywołaj funkcję pobierzDaneUzytkownika z przykładowym ID i funkcją zwrotną, która wyświetli otrzymane dane użytkownika w konsoli.

Pokaż rozwiązanie
function pobierzDaneUzytkownika(id, callback) {
    console.log(`Pobieranie danych dla użytkownika o ID: ${id}...`);
    setTimeout(() => {
        // Symulacja danych pobranych z serwera
        const daneUzytkownika = {
            id: id,
            imie: "Jan",
            email: "jan@example.com"
        };
        console.log("Dane pobrane.");
        // Wywołanie callbacka z danymi
        callback(daneUzytkownika);
    }, 1000); // Opóźnienie 1 sekunda
}

// Funkcja zwrotna do wyświetlenia danych
function wyswietlUzytkownika(uzytkownik) {
    console.log("--- Dane Użytkownika ---");
    console.log(`ID: ${uzytkownik.id}`);
    console.log(`Imię: ${uzytkownik.imie}`);
    console.log(`Email: ${uzytkownik.email}`);
    console.log("-----------------------");
}

// Wywołanie funkcji z callbackiem
pobierzDaneUzytkownika(123, wyswietlUzytkownika);

console.log("Zapytanie o dane użytkownika wysłane.");

Zadanie do samodzielnego wykonania

Zmodyfikuj funkcję pobierzDaneUzytkownika z zadania praktycznego, aby implementowała wzorzec "error-first callback". Dodaj warunek, że jeśli przekazane id jest mniejsze lub równe 0, funkcja powinna wywołać callback z obiektem błędu (new Error("Nieprawidłowe ID użytkownika")) jako pierwszym argumentem. W przeciwnym razie powinna działać jak poprzednio (błąd jako null, dane jako drugi argument). Przetestuj oba przypadki (poprawne i niepoprawne ID).

FAQ - Asynchroniczny JavaScript - Callbacks

Czy JavaScript zawsze był asynchroniczny?

Sam rdzeń języka JavaScript jest synchroniczny i jednowątkowy. Asynchroniczność jest realizowana przez środowisko wykonawcze (przeglądarkę lub Node.js) za pomocą mechanizmów takich jak pętla zdarzeń (event loop), Web APIs (np. setTimeout, fetch) i właśnie callbacków, Promises czy async/await.

Czy każda funkcja przyjmująca inną funkcję jako argument używa callbacków w sensie asynchronicznym?

Niekoniecznie. Funkcje wyższego rzędu, takie jak map, filter, forEach na tablicach, przyjmują funkcje jako argumenty, ale wykonują je synchronicznie. Termin "callback" w kontekście asynchroniczności odnosi się do funkcji wywoływanej po zakończeniu operacji, która nie blokuje głównego wątku.

Co to jest pętla zdarzeń (Event Loop)?

Pętla zdarzeń to mechanizm w środowisku JavaScript, który monitoruje stos wywołań (call stack) i kolejkę zadań (task queue). Gdy stos wywołań jest pusty, pętla zdarzeń pobiera zadanie (np. callback z setTimeout) z kolejki i umieszcza je na stosie do wykonania. To pozwala na nieblokujące działanie.

Jak uniknąć Callback Hell bez Promises czy async/await?

Można próbować poprawić czytelność przez nazywanie funkcji zwrotnych zamiast używania anonimowych, oraz przez modularzyzację kodu – dzielenie go na mniejsze, reużywalne funkcje. Jednak te techniki tylko częściowo łagodzą problem, a Promises i async/await oferują znacznie lepsze rozwiązania strukturalne.

Czy `setTimeout(fn, 0)` wykonuje funkcję natychmiast?

Nie. setTimeout(fn, 0) umieszcza funkcję fn w kolejce zadań, aby została wykonana "tak szybko, jak to możliwe", ale dopiero po zakończeniu bieżącego bloku kodu synchronicznego i opróżnieniu stosu wywołań. Jest to sposób na odroczenie wykonania funkcji.

Czy obsługa zdarzeń DOM (np. `addEventListener`) używa callbacków?

Tak, funkcja przekazywana jako drugi argument do `addEventListener` jest funkcją zwrotną. Jest ona wywoływana przez przeglądarkę asynchronicznie, gdy wystąpi określone zdarzenie (np. kliknięcie). To klasyczny przykład użycia callbacków w programowaniu sterowanym zdarzeniami.