Привет, Хабр! В 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, после чего интервал будет остановлен.

Можно грузить внешние скрипты через элемент <script> с атрибутом src. В этом случае выполнение скрипта начнется только после того, как скрипт будет полностью загружен, и это произойдет асинхронно относительно остальной части страницы:

<script>
  console.log('Перед загрузкой скрипта');
</script>
<script src="path/to/external/script.js"></script>
<script>
  console.log('После загрузки скрипта');
</script>

Основное отличие между микро- и макрозадачами заключается в их приоритете и способе обработки 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»

Комментарии (10)


  1. LJ322
    26.03.2024 19:50
    +7

    Спойлер

    И какая это статья по счёту уже про Event Loop?


    1. space2pacman
      26.03.2024 19:50
      +1

      Вы следующий будете постить? Я за вами постою.


    1. Rive
      26.03.2024 19:50
      +1

      Ладно, про чёрную магию с queueMicrotask я ещё не слышал, так что для меня эта статья была полезна.


  1. dyadyaSerezha
    26.03.2024 19:50

    В самом начале лучше написать, что это JS, которая внутри браузера, а не на сервере.

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

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

    А закончить лучше так: джавускрипт надо было бы убить ещё в детстве, но тогда не было альтернатив... ну так склалось. ;)


    1. Nengchak
      26.03.2024 19:50

      Истинный JS только в браузере.


  1. savostin
    26.03.2024 19:50
    +1

    Жаль нельзя компании в список добавлять...


    1. Robastik
      26.03.2024 19:50

      Если авторы будут понимать свою ответственность за связи с инфоцыганами, желающих останется не так много)


  1. ViktorVovk
    26.03.2024 19:50

    Начало статьи начитается с “Event loop в JS”, что странно ведь в JS нет не какого EL. Что то похожее реализует стандарт Html5, ну и с тем что описано в спецификации в статье не совпадает. Например откуда взялся термин «макрозадача»? Ну и в целом эта концепция реализуется агентом, и реализация может отличаться как на пример в браузере или ноде. Я просто не понимаю откуда это берут авторы статей…


    1. mayorovp
      26.03.2024 19:50

      Как ещё назвать задачу, которая не является микрозадачей, так, чтобы подчеркнуть это свойство?


  1. FurySeer
    26.03.2024 19:50

    Так макрозадачи, или просто - задачи?