Основы Event Loop в JavaScript

eb28212cc3b045052c00d86774f0abff.jpeg

Привет, Хабр!

В JS Event Loopпозволяет непрерывно проверять, есть ли в очереди задачи, и, когда стек вызовов пуст, передавать эти задачи на выполнение.

Таким образом долгие операции, например, запросы к серверу или таймеры, не мешают продолжению выполнения кода. Именно благодаря Event Loop приложение может оставаться отзывчивым, позволяя юзерам продолжать взаимодействие с интерфейсом, пока в фоне выполняются тяжелые задачи.

Возможно, у кого-то возникнет иллюзия параллелизма, хотя на самом деле в любой момент времени выполняется только одна задача.

В этой статье мы рассмотрим, как реализовать Event Loop в JavaScript.

Макрозадачи и микрозадачи

Микрозадачи

Микрозадачи — это задачи, которые должны быть выполнены немедленно после текущего выполненного скрипта и перед тем, как Event Loop продолжит обрабатывать макрозадачи.

Основной момент здесь — приоритет микрозадач. После завершения каждой макрозадачи, перед тем, как переходить к следующей макрозадаче (о них ниже), JS сначала обработает все микрозадачи, находящиеся в очереди.

Примеры:

Промисы — самый распространненый вариант микроазадч. Когда промис переходит в состояние »выполнено» (fulfilled) или »отклонено» (rejected), соответствующие обработчики .then() или .catch() добавляются в очередь микрозадач.

console.log('Начало');

Promise.resolve().then(() => {
    console.log('Обработка промиса');
});

console.log('Конец');

Несмотря на то, что промис решается немедленно, текст Обработка промиса будет выведен после Конец, потому что обработчик .then() ожидает в очереди микрозадач до завершения текущего скрипта.

async/await также основаны на микрозадачах. Когда функция объявлена как async, она автоматически возвращает промис. await заставляет JS ожидать решения промиса, добавляя оставшуюся часть функции как микрозадачу:

async function asyncFunction() {
    console.log('Внутри async функции');
    await Promise.resolve();
    console.log('После await');
}

console.log('Перед вызовом async функции');
asyncFunction();
console.log('После вызова async функции');

После await выводится после После вызова async функции.

HTML имеет функцию queueMicrotask, которая позволяет помещать функции в очередь микрозадач:

console.log('Перед queueMicrotask');

queueMicrotask(() => {
    console.log('Внутри микрозадачи');
});

console.log('После queueMicrotask');

Внутри микрозадачи будет выведено после После queueMicrotask.

Макрозадачи

Макрозадачи — это задачи, которые планируются на будущее выполнение. Они будут добавлены в конец очереди событий и будут обработаны только после того, как все микрозадачи будут выполнены.

Примеры:

setTimeout позволяет отложить выполнение функции на определенный период времени:

console.log('Начало');

setTimeout(() => {
    console.log('Выполнение через setTimeout');
}, 1000);

console.log('Конец');

Сообщение Выполнение через setTimeout будет выведено после Конец, даже если задержка составляет всего 1 миллисекунду, поскольку setTimeout всегда помещает вызов в очередь макрозадач, которая будет обработана после выполнения всех текущих микрозадач.

setInterval похож на setTimeout, но позволяет выполнять функцию регулярно с заданным интервалом времени:

console.log('Начало интервального выполнения');

let count = 0;
const intervalId = setInterval(() => {
    console.log('Интервал');
    count++;
    if (count === 5) {
        console.log('Остановка интервала');
        clearInterval(intervalId);
    }
}, 500);

console.log('Код после установки интервала');

Код будет регулярно выводить сообщение Интервал каждые 500 миллисекунд до тех пор, пока счетчик не достигнет 5, после чего интервал будет остановлен.

Можно грузить внешние скрипты через элемент

Основное отличие между микро- и макрозадачами заключается в их приоритете и способе обработки Event Loop’ом. Микрозадачи обрабатываются сразу после текущего скрипта и перед любыми макрозадачами, что обеспечивает быстрое выполнение обещаний и других критических операций. Макрозадачи, с другой стороны, обеспечивают способ планировать выполнение задач на будущее.

Web API

Web API — это набор асинхронных API, предоставляемых средой выполнения (например, браузером), который позволяет выполнять задачи: работа с DOM, отправка AJAX запросов, установка таймеров и многое другое. Эти API не являются частью JS, но они могут быть вызваны из JavaScript.

Когда в коде JS вызывается асинхронный Web API (например, fetch для AJAX запроса или setTimeout), запрос отправляется в соответствующий модуль Web API, а сам JavaScript продолжает выполняться далее без блокировки.

Web API берет на себя выполнение запроса. Например, если это AJAX запрос, Web API управляет всем процессом сетевого обмена данными. Для таймера Web API будет отслеживать время, необходимое для его срабатывания.

По завершении работы Web API (например, получен ответ на AJAX запрос или наступило время для setTimeout), callback-функция, связанная с этим асинхронным вызовом, помещается в очередь событий.

Event Loop регулярно проверяет очередь событий на наличие задач, готовых к выполнению. Если стек вызовов JavaScript пуст, Event Loop извлекает события (callback-функции) из очереди и помещает их в стек вызовов для выполнения.

Примеры:

Fetch API имет хороший и гибкий интерфейс выполнения AJAX-запросов. Это промис-ориентированный способ асинхронно запрашивать ресурсы:

console.log('Начало выполнения скрипта');

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Ошибка при выполнении запроса:', error));

console.log('Конец выполнения скрипта');

fetch() выполняет HTTP-запрос к указанному URL, а затем обрабатывает полученный ответ. Юзаем цепочку .then() для преобразования ответа в формат JSON и для обработки данных. Последний .catch() перехватывает возможные ошибки запроса. Стоит отметить, что вывод в консоль 'Конец выполнения скрипта' появится раньше, чем данные или ошибка от fetch().

Изменение DOM является частым делом при разработке на JS и также взаимодействует с Web API. Допустим, есть задача обновления содержимого элемента по завершению асинхронной операции:

console.log('Начало скрипта');

setTimeout(() => {
  document.getElementById('myElement').textContent = 'Обновленное содержимое';
  console.log('Содержимое элемента обновлено');
}, 2000);

console.log('Конец скрипта');

setTimeout() используется для имитации задержки — например, ожидания ответа от сервера. После задержки в 2 секунды содержимое элемента обновляется.

Web Workers позволяют выполнять сложные вычисления в фоновом потоке, не блокируя основной поток выполнения:

if (window.Worker) {
  const myWorker = new Worker('worker.js');

  myWorker.postMessage('Начать обработку');

  myWorker.onmessage = function(e) {
    console.log('Сообщение от worker:', e.data);
  };
} else {
  console.log('Web Workers не поддерживаются в вашем браузере.');
}

Создаем новый веб-воркер, который выполняется в worker.js. Отправляем сообщение воркеру для начала обработки и устанавливаем обработчик для получения результата его работы.

QueueMicrotask

queueMicrotask как функция дает возможность помещать задачи в очередь микрозадач.

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

Примеры:

Асинхронная обработка ошибок:

function asyncOperationWithErrorHandling() {
    try {
        // предположим, здесь может произойти ошибка
        throw new Error('Что-то пошло не так');
    } catch (error) {
        // планируем асинхронную обработку ошибки
        queueMicrotask(() => console.error('Асинхронно обработанная ошибка:', error));
    }
}

asyncOperationWithErrorHandling();

Ошибка перехватывается в блоке try...catch, а ее обработка асинхронно планируется с помощью queueMicrotask

Управление порядком выполнения асинхронного кода:

console.log('Начало скрипта');

queueMicrotask(() => console.log('Выполнение микрозадачи'));

Promise.resolve().then(() => console.log('Обработка промиса'));

console.log('Конец скрипта');

Задача, добавленная с помощью queueMicrotask, будет выполнена до того, как браузер получит возможность обработать другие макрозадачи, но после текущего скрипта и задач, запланированных с помощью промисов.

Гарантированное выполнение кода после всех промисов:

Promise.resolve().then(() => console.log('Промис 1 выполнен'));
Promise.resolve().then(() => console.log('Промис 2 выполнен'));

queueMicrotask(() => console.log('Гарантированное выполнение после всех промисов'));

Одной из распространенных ошибок является предположение, что асинхронные операции, к примеру setTimeout с задержкой в 0 мс, будут выполнены немедленно после текущего блока кода. На самом деле, такие операции будут помещены в очередь задач и выполнены только после того, как стек вызовов будет очищен, и Event Loop сможет их обработать.

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

В целом, правильное использование Event Loop серьезно помогает в асинхронной разработке на JS.

Статья подготовлена в преддверии старта онлайн-курса «JavaScript Developer. Professional»

© Habrahabr.ru