Що таке цикл подій та стек викликів у JavaScript

10 хв. читання
23 листопада 2020

Цей матеріал для тих, хто давно хотів зрозуміти, як саме працює JavaScript у браузері. Навіть досвідчені розробники можуть не знати цих принципів або забути їх, тож сподіваємось, що стаття буде для вас корисною.

То як JavaScript працює у браузері

Перш ніж ми заглибимось у кожну концепцію, поговоримо про них на загальному рівні. Не хвилюйтесь, якщо певні терміни вам не знайомі, вони будуть пояснюватись нижче.

Подивимось, як працює JavaScript у браузері.

Що таке цикл подій та стек викликів у JavaScript

Зверніть увагу, що не всі елементи на зображенні стосуються безпосередньо JavaScript. Web API, черга колбеків та цикл подій належать браузеру.

Для NodeJS схема виглядала б так само, але в цьому матеріалі ми поговоримо саме про JavaScript.

Стек викликів

Напевно, ви вже знаєте, що JavaScript однопотоковий. Що це означає?

Це означає, що JavaScript може виконувати лише одну операцію за раз і має лише один стек викликів.

Стек викликів — механізм, що допомагає інтерпретатору JavaScript відстежувати функції, які викликає скрипт.

Щоразу, коли функція викликається, вона з'являється у стеку викликів. Коли ж функція завершує виконання, інтерпретатор прибирає її звідти.

Функція припиняє діяти, якщо повернулось значення з return або ж коли всі її інструкції виконано.

Візуалізуємо стек викликів:

Що таке цикл подій та стек викликів у JavaScript
Функції, які виконуються, додаються до стека викликів зверху. Після виконання функції видаляються зі стека.

Коли функція починає викликати інші функції, стек викликів наповнюється записами. Стек обробляє ці виклики за принципом LIFO (last in, first out — останній зайшов, перший вийшов).

В попередньому прикладі стек викликів наповнюється в такому порядку:

  1. Файл завантажується і викликається функція main, яка відповідає за виконання всього файлу. Функція додається до стека викликів.
  2. main викликає функцію calculation, яка додається до стека викликів зверху.
  3. calculation викликає addThree, яка також додається до стека викликів.
  4. Так само addThree викликає addTwo, яка теж додається до стека, як і addOne.
  5. Оскільки функція addOne не викликає інших функцій, то після завершення вона видаляється зі стека.
  6. Функція addTwo отримує результат функції addOne, тоді завершується і також видаляється зі стека.
  7. Те ж саме і з addThree.
  8. calculation викликає функцію addTwo, яка додається до стека викликів.
  9. addTwo викликає addOne, яка додається до стека викликів.
  10. addOne припиняє своє виконання, та видаляється зі стека.
  11. З addTwo те саме.
  12. Функція calculation тепер може завершитись з результатами виконання функцій addTwo та addThree.
  13. У файлі більше немає викликів функцій та інших інструкцій, тож функція main завершує своє виконання та видаляється зі стека.

У нашому випадку main означає контекст виконання функцій, однак це не офіційна назва. В повідомленнях про помилку цей контекст називатиметься anonymous.

Uncaught RangeError: Maximum call stack size exceeded

Що таке цикл подій та стек викликів у JavaScript
Стек викликів можна побачити в деталях помилок. Одна з таких помилок — Uncaught RangeError: Maximum call stack size exceeded

Придивіться до трасування стека для помилки вище. Ми бачимо, в якому порядку викликались функції (тут функція b викликалась функцією a, котра викликалась функцією b тощо).

Якщо ви колись натрапляли на таку помилку, то стек викликів був переповнений, тому що якась функція викликала занадто багато інших функцій. Максимальний розмір стеку викликів сягає від 10 до 50 тисяч викликів. Якщо у вашій програмі стек переповнився, то, ймовірно, у цьому винен нескінченний цикл.

Браузер захищає вашу сторінку від підвисання, обмежуючи стек викликів.

Розглянемо код, який провокує переповнення стека. Це, перш за все, рекурсивні функції, котрі не мають умови для завершення дій.

function a() {
    b();
}

function b() {
    a();
}

a();

Стек викликів відстежує виконання функцій. Він діє за принципом LIFO (останній зайшов, перший вийшов), тобто першою завжди обробляється функція на верхівці стеку.

В JavaScript є лише один стек викликів, який може виконувати одну операцію за раз.

Купа

Купа в JavaScript — це структура даних, яка зберігає об'єкти, коли ми оголошуємо функції чи змінні.

Оскільки вона не впливає на роботу стеку викликів та роботу циклу подій, ми не будемо приділяти їй багато уваги. Купа — це більше про те, як JavaScript керує пам'яттю.

Якщо ви хотіли б краще розібратись в цій темі, автор рекомендує цей матеріал.

Web API

Ми знаємо, що в JavaScript можна виконати лише одну операцію за раз.

А як щодо браузера? Річ у тім, що в браузері ви можете робити деякі речі паралельно (завдяки його API).

Розглянемо детальніше принцип роботи API-запиту. Якби ми виконували код в інтерпретаторі JavaScript, то не змогли б виконувати інші операції, поки не прийде відповідь із сервера.

Тоді вебзастосунки стали б надзвичайно повільними. Тож браузери пропонують спеціальні API, які ми можемо використовувати у нашому JavaScript-коді. Виконання цього API обробляється безпосередньо платформою, тому не блокує стек викликів JavaScript.

Ще одна перевага цих API в тому, що вони написані на низькорівневих мовах програмування (на зразок С). Саме тому їхні можливості дуже відрізняються від стандартної JavaScript.

Саме API для вебу дає нам можливість робити AJAX-запити, маніпулювати DOM, працювати з геолокацією, локальним сховищем, сервіс-воркерами тощо.

До речі, ось тут ви можете дізнатись, які API чекають на нас у майбутньому.

Черга колбеків

Саме за допомогою Web API ми можемо робити операції паралельно поза інтерпретатором JavaScript. Але ми досі не розібрались, як обробити результат асинхронного запиту в синхронному JavaScript.

Тут у гру вступають колбеки. Саме за допомогою колбеків Web API дає можливість виконувати код після завершення асинхронних операцій.

Що таке колбек?

Колбек — це функція, яка передається як аргумент до іншої функції. Зазвичай колбек виконується після іншого коду.

Ви можете самостійно створити колбек, передавши функцію як аргумент до іншої функції. Інша назва колбеків — функції вищого порядку. Тут важливо розуміти, що колбеки не є асинхронними автоматично.

Розглянемо приклад:

const a = () => console.log('a');
const b = () => setTimeout(() => console.log('b'), 100);
const c = () => console.log('c');

a();
b();
c();

Функція setTimeout першим аргументом приймає колбек, вона виконує його після затримки в 100 мілісекунд.

Напевно, ви вже зрозуміли, яким буде результат цих функцій.

setTimeout виконується конкурентно, поки інтерпретатор JavaScript виконує інші операції.

Якщо стек викликів пустий, а визначена затримка завершена, виконається колбек-функція, яку ми передали в setTimeout.

Результат буде таким:

a
c
b

Але до чого тут черга колбеків?

Коли setTimeout завершує своє виконання, він не одразу викликає функцію-колбек. Чому так?

Пам'ятаєте, що JavaScript може робити лише одну операцію за раз?

Колбек, який ми передаємо в setTimeout, написаний на JavaScript, тобто саме інтерпретатор JavaScript повинен виконати цей код. Як ми вже з'ясували, коли функція виконується, вона додається до стека викликів. Аби виконати колбек, нам треба дочекатись, доки стек викликів стане пустим.

setTimeout провокує виклик Web API, який додає колбек до черги. Потім цикл подій керує переміщенням колбека з черги до стека викликів, коли він звільняється.

На відміну від стека викликів, черга колбеків працює за принципом FIFO (First In, First Out — перший зайшов, перший вийшов), тобто виклики обробляються в тому ж порядку, що й потрапляють до черги.

Цикл подій

Цикл подій у JavaScript бере перший виклик з черги колбеків та додає його до стека викликів, коли він звільняється.

JavaScript-код виконується в режимі змагання. Якщо стек викликів зайнятий виконанням іншого коду, цикл подій заблокується і він не зможе обробити функції з черги, поки стек не стане знов пустим.

Саме тому важливо не блокувати стек викликів задачами, які важкі для обчислення.

Якщо ваш код переповнює чергу колбеків, сайт припиняє відповідати на дії користувача, адже більше не може виконувати JavaScript-код.

Обробники подій на зразок onScroll додають дуже багато задач до черги колбеків. Саме тому треба використовувати техніки на кшталт debounce, аби виконувати колбек не частіше заданої кількості мілісекунд.

Переконайтеся самостійно

Додайте цей код до консолі свого браузера і почніть скролити сторінку. Ви побачите, як часто браузер викликає колбек для події onScroll.

setTimeout(fn, 0)

Тепер, коли ми розуміємо, як все працює під капотом, можемо виконувати деякі завдання і не блокувати основний потік протягом довгого періоду.

Якщо ви передасте свій асинхронний код як колбек до setTimeout, а як значення затримки передасте 0 мілісекунд, то браузер зможе виконувати операції на зразок оновлення DOM, перш ніж виконати ваш колбек.

Черга завдань та асинхронний код

Окрім черги колбеків існує ще одна черга, яка обробляє лише виконання промісів. Це черга завдань.

Проміси: швидкий огляд

Вперше проміси представили в EcmaScript 2015 (або ES6). До цього вони деякий час вже були доступні через Babel.

Проміс — один з інструментів обробки асинхронного коду, який прийшов на зміну колбекам. З промісами можна легко організовувати послідовне виконання асинхронних функцій. До того ж їхній синтаксис виглядає набагато чистішим, на відміну від «пекла колбеків».

setTimeout(() => {
  console.log('Print this and wait');
  setTimeout(() => {
    console.log('Do something else and wait');
    setTimeout(() => {
      // ...
    }, 100);
  }, 100);
}, 100)

Фрагмент коду вище — це антипатерн використання колбеків.

З промісами такий код можна зробити набагато чистішим.

// проміс-обгортка для setTimeout
const timeout = (time) => new Promise(resolve => setTimeout(resolve, time));
timeout(1000)
  .then(() => {
    console.log('Hi after 1 second');
    return timeout(1000);
  })
  .then(() => {
    console.log('Hi after 2 seconds');
  });

Ще чистіший код можна отримати, якщо використати синтаксис async/await.

const logDelayedMessages = async () => {
  await timeout(1000);
  console.log('Hi after 1 second');
  await timeout(1000);
  console.log('Hi after 2 seconds');
};

logDelayedMessages();

Більше про проміси в документації MDN.

Коли виконуються проміси?

Ми побачили, що проміси працюють не так, як колбеки. Тож не дивно, що в них є власна черга.

Черга завдань (або ж черга промісів) має пріоритет перед чергою колбеків.

Цикл подій спочатку обробляє завдання з черги промісів, а потім переходить до черги колбеків.

Розглянемо приклад:

console.log('a');
setTimeout(() => console.log('b'), 0);
new Promise((resolve, reject) => {
  resolve();
})
.then(() => {
  console.log('c');
});
console.log('d');

Якщо згадати, як працює черга колбеків, можна припустити, що результат виконання коду буде a d b c.

Але черга промісів має пріоритет над чергою колбеків. Тож c буде виведено перед b, хоч обидві функції були асинхронними:

a
d
c
b

Підсумок

Сподіваємось, тепер ви краще зрозуміли, що відбувається за лаштунками вашого JavaScript-коду.

Корисні ресурси, які допоможуть вам розібратись в циклі подій ще краще:

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація