Event loop в браузере: задачи, микрозадачи и отрисовка
Автор: Казачкин Даниил Михайлович · Обновлено
Цикл событий браузера согласует выполнение JavaScript, обработку событий и возможности обновления экрана. Понимание задач и микрозадач помогает объяснить порядок Promise и…
Цикл событий браузера согласует выполнение JavaScript, обработку событий и возможности обновления экрана. Понимание задач и микрозадач помогает объяснить порядок Promise и таймеров, а также найти код, из-за которого интерфейс перестаёт отвечать.
Среда и границы модели
Основной пример запускайте в консоли современного браузера на пустой локальной странице либо внутри обычного скрипта этой страницы. Он не меняет DOM и не обращается к сети. В Node.js похожий короткий пример может дать тот же вывод, но у Node другая организация цикла событий и дополнительные механизмы; браузерные выводы о кадрах нельзя переносить туда автоматически.
JavaScript выполняет текущий фрагмент кода до завершения соответствующего исполнения. Таймер не прерывает середину обычной функции только потому, что истекло указанное время. Браузер также занимается сетью и другими внутренними процессами, но callback в основном потоке должен получить свою возможность выполнения. Формулировка «JavaScript однопоточный» полезна только вместе с указанием конкретного агента и потока, о которых идёт речь.
Полный пример: очередь продолжений
console.log('A');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => {
console.log('promise');
queueMicrotask(() => console.log('nested'));
});
queueMicrotask(() => console.log('queued'));
console.log('B');Ожидаемый порядок: A, B, promise, queued, nested, timer. Сначала заканчивается синхронная часть. Реакция уже выполненного Promise была поставлена в очередь раньше явной микрозадачи, поэтому выполняется первой. Она добавляет ещё одну микрозадачу в конец. Проверка микрозадач продолжает работу до опустошения очереди, поэтому вложенная задача опережает таймер.
Этот пример не доказывает, что у браузера есть одна универсальная очередь всех событий. Стандарт описывает источники задач и выбор подходящей очереди, а также условия обновления рендеринга. Для простой последовательности одного скрипта достаточно локального порядка постановки микрозадач. Для гонки сетевого ответа, пользовательского ввода и таймера нельзя выдумывать общий фиксированный приоритет, которого контракт API не обещает.
Задача и микрозадача выполняют разную роль
Callback таймера относится к работе, которая будет выполнена как задача после выполнения необходимых условий. Реакции Promise и queueMicrotask используют микрозадачи. Проверки очереди микрозадач происходят в определённых стандартом точках, в том числе после завершения задачи при подходящем состоянии стека. Поэтому модель «одна задача, затем все её микрозадачи» удобна для начала, но не должна превращаться в единственное правило всего поведения браузера.
Микрозадача не является маленькой по длительности автоматически. В ней можно запустить тяжёлый цикл, который займёт много времени. Более того, бесконечная последовательность микрозадач может не дать очереди опустеть. Добавление Promise.resolve().then(...) вокруг каждой порции не гарантирует отзывчивости: следующая порция может снова выполниться до возможности обработать ввод и нарисовать кадр.
await приостанавливает конкретную функцию, но её продолжение также связано с Promise. Поэтому цикл с await Promise.resolve() может оставаться в непрерывной цепочке микрозадач. Это важное различие между «мой код не написан одной синхронной функцией» и «браузер получает время для остальных обязанностей». Оценивать нужно фактический граф исполнения.
Таймер — нижняя граница, а не точные часы
Нулевой таймер означает отсутствие запрошенной положительной задержки, а не немедленный вызов. Пока занят поток, callback ждёт. Вложенные таймеры и фоновые вкладки могут дополнительно ограничиваться браузером. Поэтому тест на ровно десять миллисекунд ненадёжен даже без ошибки приложения: среда может законно выполнить callback позднее.
Для измерения интервалов подходит performance.now, а для анимации — время, переданное requestAnimationFrame. Если двигать объект на фиксированное число пикселей за callback, скорость будет зависеть от частоты кадров. Визуальное движение нужно связывать с прошедшим временем, а не с предположением, что каждый компьютер рисует ровно шестьдесят кадров в секунду.
requestAnimationFrame назначает callback перед соответствующим обновлением отрисовки. Он не сообщает, что пиксели уже появились на экране, и обычно приостанавливается в скрытых вкладках. Между произвольным таймером и кадром нельзя обещать универсальный порядок для любых условий. Для задач интерфейса формулируйте требование конкретно: изменить данные, измерить геометрию или обновить визуальное состояние перед кадром.
Практика: обработка порциями с возможностью продолжения
Нужно просуммировать числа от одного до ста тысяч, обрабатывая ограниченное количество за один вызов. Пример ниже самостоятельный и намеренно использует задачи таймера между порциями. Он не гарантирует кадр после каждой порции, но позволяет циклу событий выбирать другую работу между ними. Для дорогого вычисления в реальном приложении стоит рассмотреть Worker.
function sumInChunks(limit, chunkSize = 5000) {
if (!Number.isSafeInteger(limit) || limit < 0 ||
!Number.isInteger(chunkSize) || chunkSize <= 0) {
return Promise.reject(new RangeError('Invalid bounds'));
}
return new Promise((resolve) => {
let next = 1;
let sum = 0;
function step() {
const end = Math.min(next + chunkSize, limit + 1);
while (next < end) {
sum += next;
next += 1;
}
if (next <= limit) setTimeout(step, 0);
else resolve(sum);
}
step();
});
}
sumInChunks(100000).then((sum) => console.log(sum));
console.log('scheduled');Сначала появится scheduled, затем 5000050000. Первая порция выполняется синхронно внутри конструктора, остальные планируются таймерами. В учебном диапазоне сумма точно представима; для произвольных огромных границ потребуется отдельная проверка числового контракта. Размер порции выбирают по измерениям времени на целевом устройстве, а не потому, что число пять тысяч универсально.
Для практического интерфейса добавьте отмену и проверку актуальности результата: пользователь может закрыть экран до завершения. Если операции регулярно занимают значительную часть кадра, перенос в Worker может быть полезнее всё более мелкого дробления. При этом Worker не получает прямого доступа к DOM, и обмен данными также имеет стоимость.
Частые вопросы
Почему индикатор загрузки не появился перед тяжёлым циклом?
Изменение DOM не означает немедленную отрисовку. Пока выполнение не оставило браузеру подходящую возможность обновления экрана, пользователь не увидит новое состояние. Измерьте длинную задачу и перераспределите работу; один Promise вокруг цикла обычно не решает проблему.
Почему результаты таймера различаются на ноутбуке и телефоне?
Ограничения фоновой работы, нагрузка и производительность устройства различаются. Проверяйте логические зависимости и допустимые диапазоны времени, а не точное совпадение отметок. Для кадров используйте временные метки соответствующего API.
Что смотреть в DevTools?
Запишите короткое взаимодействие в Performance и найдите длинные участки JavaScript, обработчики событий и работу рендеринга. Затем свяжите их с конкретными функциями. Порядок сообщений консоли полезен для маленькой модели, но не заменяет профиль реального медленного сценария.
Связанные исследования
- Почему Promise опережает таймер: проверяем модель выполнения JavaScript — Причинная модель очередности Promise и таймеров: границы ECMAScript и HTML, контролируемые трассы, готовность реакции и ограничения выводов о времени исполнения.