Webrium
Подписаться

Цикл событий в JavaScript простыми словами

Как избежать ошибок при работе с асинхронным кодом и оптимизировать выполнение задач в JavaScript

Вложенные очереди задач: стек вызовов внутри цикла событий, микроочереди и макроочереди с выделенной текущей задачейЦикл событий

Цикл событий в JavaScript: от очередей к отзывчивому интерфейсу

Цикл событий - это фундаментальный механизм, который определяет, как JavaScript выполняет код в браузере. Он управляет порядком выполнения синхронных и асинхронных операций, обеспечивает обновление интерфейса и обработку пользовательских действий. Без понимания его работы невозможно создавать отзывчивые веб-приложения и избегать распространённых ошибок при работе с асинхронным кодом.

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

Анатомия цикла событий: стек, очереди и микроочереди

Цикл событий состоит из нескольких ключевых компонентов:

  1. Стек вызовов (Call Stack) - это место, где выполняется синхронный JavaScript-код. Когда функция вызывает другую функцию, новая задача добавляется в стек. Когда функция завершается, она удаляется из стека. Если стек переполняется (например, при бесконечной рекурсии), возникает ошибка “Maximum call stack size exceeded”.

  2. Очередь макрозадач (Macrotask Queue) - сюда попадают задачи, которые нужно выполнить позже. Это:

    • Таймеры (setTimeout, setInterval)
    • События DOM (click, scroll, input)
    • Сетевые запросы (fetch, XMLHttpRequest)
    • Другие асинхронные операции
  3. Очередь микрозадач (Microtask Queue) - очередь для задач с более высоким приоритетом. Сюда попадают:

    • Промисы (then, catch, finally)
    • Задачи, созданные через queueMicrotask
    • Мутации DOM (через MutationObserver)

Порядок выполнения задач следующий:

  1. Выполняется текущая макрозадача из стека вызовов
  2. Когда стек пустеет, выполняются все микрозадачи из очереди микрозадач
  3. Браузер может обновить интерфейс, если пришло время нового кадра
  4. Цикл берёт следующую макрозадачу из очереди макрозадач

Почему микрозадачи выполняются раньше макрозадач: типичный тупик

Рассмотрим классический пример, который часто вызывает недопонимание:

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

setTimeout(() => console.log('Таймер'), 0);

Promise.resolve()
  .then(() => console.log('Промис 1'))
  .then(() => console.log('Промис 2'));

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

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

Почему это не работает:

  1. setTimeout с нулевой задержкой не означает “выполнить немедленно” - он означает “добавить задачу в очередь макрозадач”
  2. Микрозадачи, поставленные в текущей задаче, выполняются раньше любой следующей макрозадачи
  3. Цикл событий всегда сначала выполняет все микрозадачи, прежде чем перейти к следующей макрозадаче

Правильный вывод:

Начало
Конец
Промис 1
Промис 2
Таймер

Второй тупик: попытка использовать таймеры для управления потоком выполнения

Распространённая ошибка - попытка использовать setTimeout для управления порядком выполнения асинхронных операций. Например:

function fetchData() {
  fetch('/api/data')
    .then(response => response.json())
    .then(data => {
      console.log('Данные получены');
      setTimeout(() => {
        console.log('Обработка завершена');
      }, 0);
    });
}

Разработчики часто думают, что setTimeout здесь необходим, чтобы гарантировать выполнение кода после завершения промиса. На самом деле это избыточно и создаёт ненужную задержку.

Почему это плохое решение:

  1. Создаётся лишняя макрозадача, которая задерживает выполнение кода
  2. Усложняется понимание потока выполнения
  3. Может привести к race conditions при вложенных вызовах
  4. Нарушает естественный порядок выполнения микрозадач

Правильное решение - просто продолжить цепочку промисов:

function fetchData() {
  fetch('/api/data')
    .then(response => response.json())
    .then(data => {
      console.log('Данные получены');
      return data;
    })
    .then(data => {
      console.log('Обработка завершена');
    });
}

Как задачи влияют на отрисовку интерфейса

Все микрозадачи выполняются до точки отрисовки, поэтому длинная цепочка промисов не отдаёт поток браузеру. Между двумя макрозадачами отрисовка может и не произойти. Если изменения нужно гарантированно показать до тяжёлой работы, используют requestAnimationFrame и уже в нём ставят setTimeout, либо scheduler.yield().

Рассмотрим пример долгой задачи:

function processLargeArray() {
  const largeArray = Array(1000000).fill(0);
  let sum = 0;

  for (let i = 0; i < largeArray.length; i++) {
    sum += Math.sqrt(largeArray[i]);
  }

  console.log('Обработка завершена');
}

processLargeArray();

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

Третий тупик: попытка использовать requestIdleCallback для долгих задач

Многие разработчики, столкнувшись с проблемой долгих задач, первым делом пытаются использовать requestIdleCallback. Например:

function processInIdle() {
  requestIdleCallback(() => {
    processLargeArray();
  });
}

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

Почему это не работает как ожидается:

  1. Непредсказуемое время выполнения - задача может начаться в любой момент, когда браузер сочтёт, что у него есть свободное время
  2. Колбэк выполняется целиком. Если внутри тяжёлая синхронная работа, интерфейс замирает так же. Проблема подхода в том, что он лишь откладывает долгую задачу и не дробит её, а без опции timeout колбэк может долго не вызываться
  3. Отсутствие гарантий - браузер может вообще не предоставить время для выполнения задачи, если пользователь активно взаимодействует со страницей
  4. Сложность отладки - прерывистое выполнение усложняет отладку и может приводить к неожиданным состояниям

Правильный подход - разбивать долгие задачи на части и уступать управление браузеру:

function processLargeArrayChunked() {
  const largeArray = Array(1000000).fill(0);
  let sum = 0;
  let index = 0;
  const BUDGET = 16; // бюджет одного кадра

  function processChunk() {
    const startTime = performance.now();

    while (index < largeArray.length && performance.now() - startTime < BUDGET) {
      sum += Math.sqrt(largeArray[index]);
      index++;
    }

    if (index < largeArray.length) {
      setTimeout(processChunk, 0);
    } else {
      console.log('Обработка завершена');
    }
  }

  processChunk();
}

Решение задач с собеседований: от простого к сложному

На собеседованиях часто спрашивают о порядке вывода в подобных примерах:

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

setTimeout(() => console.log('Таймер 1'), 0);

Promise.resolve()
  .then(() => console.log('Промис 1'))
  .then(() => console.log('Промис 2'));

setTimeout(() => console.log('Таймер 2'), 0);

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

Вывод будет таким:

Начало
Конец
Промис 1
Промис 2
Таймер 1
Таймер 2

Четвёртый тупик: попытка заучить порядок вывода

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

Почему это не работает:

  1. Любое изменение кода меняет порядок - добавление вложенного промиса, await или другой асинхронной конструкции полностью меняет картину
  2. Невозможно запомнить все варианты - существует бесконечное количество комбинаций асинхронных операций
  3. Не даёт понимания сути - заучивание примеров не объясняет, как на самом деле работает цикл событий
  4. Приводит к ошибкам в реальном коде - полагаясь на заученные примеры, легко допустить ошибку в реальном проекте

Рассмотрим более сложный пример с вложенными промисами и await:

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

setTimeout(() => console.log('Таймер'), 0);

Promise.resolve()
  .then(() => {
    console.log('Промис 1');
    return Promise.resolve().then(() => console.log('Вложенный промис'));
  })
  .then(() => console.log('Промис 2'));

async function asyncExample() {
  console.log('Асинхронная функция');
  await Promise.resolve();
  console.log('После await в функции');
}

asyncExample();

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

Реальный вывод:

Начало
Асинхронная функция
Конец
Промис 1
После await в функции
Вложенный промис
Промис 2
Таймер

Пошаговое объяснение:

  1. Выполняется синхронный код (Начало, Асинхронная функция, Конец)
  2. После завершения синхронного кода выполняются микрозадачи:
    • Первый промис из цепочки (Промис 1)
    • Продолжение после await встало в очередь ещё во время синхронного кода, поэтому оно выполняется следующим (После await в функции)
    • Вложенный промис ставится в очередь, когда выполняется колбэк “Промис 1”, поэтому он выполняется после продолжения async-функции (Вложенный промис)
    • Второй промис из цепочки (Промис 2) выполняется последним среди промисов, так как возврат промиса из then добавляет лишние такты микрозадач
  3. Браузер может обновить интерфейс
  4. Выполняется следующая макрозадача (Таймер)

Оптимизация долгих задач

Долгие задачи блокируют интерфейс, так как браузер не может обновить кадр, пока задача не завершится. Для решения этой проблемы задачи дробят на пакеты по бюджету времени и уступают поток, а для вычислений используют Web Worker. Подробности оптимизации отзывчивости интерфейса разобраны в статье о метриках front-web-vitals.

Позиция редакции

Модель двух очередей (макрозадачи и микрозадачи) и точки обновления интерфейса достаточна для большинства прикладных задач на фронтенде. Она позволяет объяснить основные сценарии работы с асинхронным кодом и оптимизировать отзывчивость интерфейса.

Эта модель хорошо работает в следующих случаях:

  1. При разработке пользовательских интерфейсов
  2. При работе с сетевыми запросами
  3. При обработке пользовательских событий
  4. При реализации анимаций и переходов

Однако у этой модели есть ограничения, при которых она становится неверной или недостаточной:

  1. Серверный JavaScript (Node.js) имеет более сложный цикл событий с дополнительными фазами (таймеры, I/O, проверка, закрытие). Здесь модель двух очередей не отражает всех нюансов работы event loop.

  2. Низкоуровневая работа с event loop - если вы пишете библиотеки или фреймворки, которые напрямую взаимодействуют с циклом событий, потребуется более глубокое понимание.

  3. Специфические браузерные реализации могут иметь особенности, не покрываемые базовой моделью. Например, некоторые браузеры могут оптимизировать выполнение микрозадач или иметь дополнительные очереди для специфических операций.

  4. Новые API могут вводить дополнительные типы задач с собственными правилами приоритезации. Например, задачи планировщика (scheduler.postTask) или задачи отрисовки (requestAnimationFrame).

Таким образом, базовая модель хорошо подходит для объяснения принципов работы цикла событий и решения большинства практических задач на фронтенде. Однако при работе с низкоуровневым кодом или в специфических средах может потребоваться более глубокое понимание деталей реализации конкретной JavaScript-машины.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Независимые ожидания, запущенные вместе, занимают время самого медленногоожидания вместе
Углубление

Асинхронность: что она ускоряет, а что нет

Асинхронность в JavaScript: как избежать простоя процессора и когда её применение оправдано — без мифов и переписывания кода.

Три ключевые метрики Core Web Vitals: две в норме, одна выходит за пределы допустимогоЗадержка реакции
Практика

Core Web Vitals: что действительно двигает оценку

Узнайте, какие метрики Core Web Vitals критичны для пользователей, и как устранить задержки на практике — от диагностики до конкретных шагов по оптимизации.

Состояние бывает четырёх видов, и библиотека нужна только одному из нихвиды состояниянужна библиотека
Усиление

Состояние в React: когда нужна библиотека, а когда нет

Когда в React-приложении состояние выходит из-под контроля, библиотека — не всегда решение: разберитесь, какой вид состояния у вас на самом деле.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть