Event Loop

Rozważmy prosty przykład:


setTimeout(() => {
    console.log("A");
}, 0);

console.log("B");
console.log("C");

Jaka będzie kolejność wykonania powyższego kodu?

Okazuje się, że mimo zerowego czasu dla setTimeout będzie to B, C oraz A.

Javascript jest jednowątkowy. Oznacza to, że w danym momencie może wykonywać tylko jeden kod, a dopóki go nie wykona, nie jest w stanie wykonać nic innego. Działanie takie w realnym świecie raczej by się nie sprawdziło, ponieważ zazwyczaj na stronie dzieje się równocześnie wiele rzeczy, a i użytkownik nie siedzi bezczynnie.

Rozwiązano to za pomocą pętli i kolejek. Javascript działa tak jak większość programów i gier, które odpalasz na komputerze. W tle działa nieskończona pętla, która dla Javascript nosi nazwę Event Loop.

Pamięć na której operuje Javascript składa się z kilku części:

Wygląd Javascript od strony pamięci
Podział pamięci w Javascript

Mamy więc część Heap, w której przechowywane są obiekty, na które wskazują nasze zmienne.

Drugą częścią jest Call Stack. Gdy Javascript wykonuje dany kod i natrafi w nim na wywołanie jakiejś funkcji, każdorazowo odkłada ją na stos wywołań (call stack). Odłożone na stos funkcje są następnie zdejmowane i wykonywane. Działa tutaj zasada LIFO - Last In First Out - czyli jako pierwsza zdejmowana jest ostatnio odłożona funkcja.

Powyższe działanie wynika z prostego faktu - częstokroć by zakończyć jakąś funkcję, trzeba zakończyć inną funkcję.


    function three() {
        return "txt";
    }

    function two() {
        const result = three();
        return result;
    }

    function one() {
        const result = two();
        return result;
    }
    
Call Stack w Debugerze
Wygląd Call Stack dla powyższego kodu

Synchroniczny i asynchroniczny kod

Wiele funkcjonalności, którymi posługujemy się na co dzień dostarczana jest nam przez przeglądarkę za pomocą różnych API.

Część z tych funkcjonalności jest synchroniczna, ale też trafimy tutaj rzeczy asynchroniczne.

Synchroniczny kod to taki, który wykonuje się linia po linii. Charakterystyczne tutaj jest to, że równocześnie możemy wykonywać tylko jeden fragment kodu. Jak go odpalimy, to do czasu jego wykonania nic nie jesteśmy w stanie zrobić.

Asynchroniczny kod natomiast może być wykonywany równolegle do reszty aplikacji nie przeszkadzając jej w działaniu, ale też rezultat działania nie jest natychmiastowy.

W naszym przypadku to właśnie przeglądarka udostępnia nam asynchroniczne funkcje setTimeout, setInterval oraz requestAnimationFrame, ale także funkcje dla zdarzeń, które wykonują się w odpowiednim momencie (np. po kliknięciu czy wczytaniu grafiki) czy też funkcjonalności do pracy z Ajaxem.

Po odpaleniu takich asynchronicznych funkcji przekazywane są z do przeglądarki, która przejmuje oczekiwanie na ich wykonanie. Przeglądarka czeka na odpowiedni moment (wczytanie danych, kliknięcie, zakończenie się czasu dla setTimeout), a gdy ten nastąpi funkcję zwrotną (tę, którą podaliśmy w parametrze) przekazuje z powrotem do Javascript.

Początkowo przekazana funkcja trafia do odpowiedniej kolejki wiadomości. W tle wciąż działa pętla zdarzeń, każdorazowo sprawdzając, czy w danym momencie Call Stack nie jest pusty. W momencie jego opróżnienia funkcje czekające w kolejce są do niego przenoszone, a następnie wykonywane.

Najważniejsze dla nas jako programistów jest to, że gdy Javascript zacznie wykonywać aktualne zadania z Call Stack, nie będzie w stanie wykonać kolejnych. Równocześnie po ich zakończeniu odpali te znajdujące się w kolejce. Chociażby z tego faktu wynika to, że przekazywany do setTimeout czas jest czasem orientacyjnym, ponieważ kod wykonywany w Call Stack może opóźnić wykonanie odłożonego zadania.

Sprawdźmy to na poniższym teście. Po kliknięciu w poniższy przycisk odpalę setTimeouty oraz trwającą 3 sekundy pętlę. Otwórz konsolę, kliknij na poniższy przycisk rozpoczynający test i od razu poklikaj kilka razy na stronie.


//setTimeout i click trafią do kolejki zadań
setTimeout(() => {
    console.log("Jestem tekstem z setTimeout A");
}, 0);

setTimeout(() => {
    console.log("Jestem tekstem z setTimeout B");
}, 0);

//rejestruję klikanie na dokumencie
document.addEventListener("click", e => {
    console.log("klik");
});

//pętla trwająca 3 sekundy
//kod synchroniczny - jako pierwszy trafi do Call Stack
let start = new Date().getTime();
let end = start;
while(end < start + 3000) {
    end = new Date().getTime();
}

console.log("Jestem tekstem ze zwykłego console.log");

Dodatkowe testy polecam przeprowadzić na tej stronie.

Makro i mikro zadania

W Javascripcie istnieje kilka kolejek na zadania asynchroniczne. Zarówno setTimeout, setInterval jak i funkcje odpalane przez zdarzenia tworzą tak zwane Makro zadania (Macrotasks).

W Javascripcie istnieją też Mikro zadania (Microtasks), które zazwyczaj tworzone są w momencie rozwiązania obietnic, czyli gdy następuje wywołanie funkcji then/catch/finally (w tym gdy kończy się funkcja rozpoczynająca się od async). Te "małe zadania" mają swój własny schowek, w którym czekają na wykonanie.

Gdy Call Stack jest już pusty, zaczynają być na niego odkładane Makro zadania. Przed każdym takim odłożeniem i wykonaniem zadania Javascript dodatkowo sprawdza w schowku Mikro zadań, czy istnieją takowe do wykonania. Jeżeli takie są, przenoszone są do Call Stack i wywoływane tuż przed wykonaniem danego Makro zadania.

event loop kolejność

Najłatwiej cały ten mechanizm zapamiętać na bazie powyższej grafiki. Event loop za każdym razem stara się opróżnić dany schowek - idąc od dołu do góry. Do kolejnego schowka przejdzie tylko w momencie, gdy poniższy jest w danym momencie pusty.

Przykład tego działania obrazuje poniższy kod:


console.log("1"); //odpali się jako pierwsze bo to synchroniczny kod

setTimeout(() => { //odpali się jako czwarte bo to Makro task
    console.log("4");
}, 0);

Promise.resolve().then(res => { //odpali się jako trzecie bo to Mikro task
    console.log("3");
});

console.log("2"); //odpali się jako drugie bo to synchroniczny kod

Kolejka renderowania

Aby płynnie działać, przeglądarka stara się renderować naszą stronę w okolicach 60 klatek na sekundę. Poza powyżej wymienionymi kolejkami zadań i mikro zadań, istnieje też kolejka renderowania, która służy do zbierania wszystkich rzeczy, które mają być wykonane przed kolejnym wyrenderowaniem strony.

Jej działanie jest bardzo podobne do kolejki mikro zadań. Różnią się sposobem wykonania. W przypadku mikro zadań, w pojedynczej pętli będą one wykonywane do momentu, aż ich schowek będzie pusty. W przypadku schowka renderowania, zadania z niego będą wykonywane tylko te, które były w danym momencie odłożone. Jeżeli w czasie wykonywania tych zadań trafi do niego nowe zadanie, zostanie ono wykonane w kolejnym tiku pętli.

Bardzo dobrze ten mechanizm pokazuje poniższy film:

Do odkładania na tą kolejkę służy funkcja requestAnimationFrame().


const div = document.querySelector("div");

function render() {
    const left = parseInt(div.style.left) + 1;
    div.style.left = left + "px"
    requestAnimationFrame(render);
}

render();

W sytuacjach gdzie zależy nam na płynności animacji zalecane jest użycie requestAnimationFrame zamiast setTimeout. Wynika to z faktu, że setTimeout może być wykonane w złym momencie renderowania klatki, co może powodować pewne mikro rozjazdy animacji.

W poniższym teście odpaliłem dwie animacje. Jedna za pomocą requestAnimationFrame a druga setTimeout z szybkością 60 klatek na sekundę. Spróbuj poruszać kursorem bo bocznym menu by wywołać kilka zdarzeń, a powinieneś zauważyć, że requestAnimationFrame sprawuje się zwyczajnie płynniej (w Firefoxie różnica jest o wiele bardziej zauważalna niż w Chrome).

requestAF
setTimeout

Rozbijanie dużych zadań na małe

Powyższą wiedzę możemy wykorzystać w kilku sytuacjach.

Podobna do powyższej sytuacja. Wyobraź sobie, że musisz wygenerować na stronie kilka tysięcy miniaturek. Jeżeli zrobisz to za pomocą klasycznej pętli, zadanie takie będzie się wykonywać bardzo długo, a użytkownik będzie je widział jako "zacięcie się przeglądarki" (a także może zobaczyć okienko przeglądarki mówiące o tym, że skrypt trwa zbyt długo).


//bardzo długa lista z adresami miniaturek typu cover1.jpg, cover2.jpg
let src = Array.from({length: 1000000}, (el, i) =>  `cover${i}.jpg`);

function generateCovers() {
    for (let i=0; i<src.length; i++) { //tutaj się zablokujemy
        const img = document.createElement("img");
        img.classList.add("cover-book");
        img.src =  src[i] + ".jpg";
        document.body.append(img);
    }
}

generateCovers();

Żeby uniknąć tego problemu, tak złożone zadanie możemy podzielić na części wykorzystując do tego setTimeout:


let j = 0;
//bardzo długa lista z adresami miniaturek typu cover1.jpg, cover2.jpg
let src = Array.from({length: 1000000}, (el, i) =>  `cover${i}.jpg`);

function generateCovers() {
    for (let i=0; i<50; i++) {
        const img = document.createElement("img");
        img.classList.add("cover-book");
        img.src = src[j] + ".jpg";
        document.body.append(img);
        j++;
        if (j >= src.length) return;
    }

    if (j < src.length) {
        setTimeout(generateCovers);
    }
}

generateCovers();

Ponowne nadanie animacji

Kolejny przykład użycia związany jest z działaniem na stylach danego elementu. Dość często będziemy chcieli ponownie nadać jakąś animację. Ponownie - znaczy jeszcze raz powinniśmy mu nadać klasę, która nadaje mu animację.

Pamiętaj - normalnie kod wykonuje się w jednym momencie. Gdy usuniesz elementowi klasę, a następnie od razu ją dodasz, kod ten będzie równoznaczny z tym, jakbyśmy w ogóle tej klasy nie usunął:


const element = document.querySelector(".element.animuj"); //pobieram element
//chcę ponownie go animować poprzez ponowne nadanie klasy .animuj

//niestety poniższe się nie sprawdzi, bo kod wykonał się w tym samym momencie
//co jest równoznaczne z tym jakbym nic nie zrobił
element.classList.remove("animuj");
element.classList.add("animuj");

Rozwiązaniem będzie ponownie użycie setTimeout. Dla elementu A użyłem setTimeout. Dla elementu B spróbowałem zrobić dane zadanie klasycznie. Po ponownym kliknięciu na obydwa elementy powinieneś zobaczyć, że element B już się nie będzie animował.


const a = document.querySelector(".elementA");
a.addEventListener("click", e => {
    a.classList.remove("animate");
    setTimeout(() => { //requestAnimationFrame też zadziała
        a.classList.add("animate");
    }, 0)
})

const b = document.querySelector(".elementB");
b.addEventListener("click", e => {
    b.classList.remove("animate");
    b.classList.add("animate");
})
element A
element B

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.

Menu