Код
#статьи

Как управлять event loop в JavaScript. Часть 1

Event loop (событийные циклы) — важная часть архитектуры JavaScript. Мы попросили эксперта объяснить, как в этом разобраться.

 vlada_maestro / shutterstock

Понимание работы event loop — неотъемлемый пункт карьерного роста middle-разработчика.

А сейчас о событийных циклах рассказывает Александр Кузьмин — ведущий программист с десятилетним опытом во frontend, руководитель отдела клиентской разработки компании IT-Park. Передаём слово эксперту.

Асинхронность в JavaScript

У каждого языка свой подход к параллельному вычислению данных. Например, в языках типа C++ оно передаётся в отдельный поток или даже процесс, который выполняется на другой машине.

Александр Кузьмин

ведущий программист, руководитель отдела клиентской разработки компании IT-Park

Если нужно сообщить потоку что-то вроде «посчитай вот это и положи результат в базу данных, а я когда-нибудь приду за ними», мы имеем дело с асинхронными операциями.

Это значит, что код, который их вызвал, не ждёт завершения выполнения, а продолжает исполняться дальше. Если же мы хотим дождаться результата, у многих современных языков есть операторы async и await для синхронизации исполняемого кода.

В JavaScript асинхронность — основной инструмент. Во времена до появления Node.JS он был практически единственным языком исполнения сценариев на клиенте в вебе (Internet Explorer поддерживал VB Script, но его никто не использовал). Сейчас невозможно представить интернет, где все запросы на сервер отправлялись бы с перезагрузкой страницы. Напротив, мы пришли к одностраничному вебу, в котором на стороне клиента происходит разрешение адресов страниц и отображение соответствующего контента.

Любые данные от сервера запрашиваются асинхронно: отправляется запрос (XMLHttpRequest или XHR), и код не ждёт его возвращения, продолжая выполняться. Когда же сервер отвечает, объект XHR получает уведомление об этом и запускает функцию обратного вызова — callback, который передали в него перед отправкой запроса.

Если придётся ждать, пока запрос придёт, JavaScript перестанет принимать любые события, а страница зависнет. Чтобы пользователь спокойно использовал веб-приложение, запрос выводят из текущего контекста выполнения. Операции, результата которых приходится ждать, прежде чем продолжать выполнение кода, называются блокирующими. О них — во второй части статьи.

Суть кроется в устройстве языка:

JavaScript ориентирован на общение с пользователем, чтобы тот мог запускать несколько событий одновременно.

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

Event loop в JavaScript — менеджер асинхронных вызовов

Чтобы этот хитрый процесс слаженно работал, в JavaScript реализован механизм для управления очерёдностью исполнения кода. Поскольку это однопоточный язык, возникла необходимость «вклиниваться» в текущий контекст исполнения. Этот механизм называется event loop — событийный цикл.

С английского loop переводится как «петля», что отлично отражает смысл: мы имеем дело с закольцованной очередью.

Event loop регулирует последовательность исполнения контекстов — стек. Он формируется, когда сработало событие или была вызвана функция. Реакция на событие помещается в очередь исполнения, в event loop, который последовательно, с каждым циклом выполняет попадающий в него код. При этом привязанная к событию функция вызывается следующей после текущего контекста исполнения.

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

Чтобы данные находились в консистентном состоянии, каждая функция должна быть выполнена до конца. Это обусловлено однопоточностью JavaScript и некоторыми другими особенностями, например характерными для функциональных языков программирования замыканиями. Поэтому единственный поток представлен в виде очереди контекстов исполнения, в которой и происходит «вклинивание» функций, прошедших через цикл событий.

Схема цикла событий. На каждом этапе проверяется одна из его очередей. Несмотря на названия, setImmediate выполняется внутри цикла каждую итерацию (tick), а nextTick вызывается в момент срабатывания — между этапами цикла.

Справка

В JavaScript существует понятие «контекст функции». Но есть и другой термин — «контекст исполнения». Это тело функции со всеми переменными и другими функциями, которое называют «область видимости», с английского — «scope». Важно не путать понятия, это принципиально разные вещи.

Как формируется контекст исполнения

JavaScript — интерпретируемый язык. Это значит, что любой код проходит через интерпретатор, который исполняет его построчно. Но и здесь есть нюансы.

Как только скрипт попадает в интерпретатор, формируются глобальный контекст и глобальная область видимости, в которой держится Variable Object, или VO — объект переменных.

Он формируется из переменных вида Function Declaration и атрибутов функции по следующему принципу. Интерпретатор считывает код и находит все объявления:

  • переменных по ключевому слову var (const или let в ES6 и выше);
  • функций, объявленных ключевым словом function, без присваивания.

Это складывается в VO текущего контекста исполнения. Затем берётся Variable Object внешней области видимости и к нему добавляется сформированный выше VO. Сверху он дополняется параметрами функции и их значениями на момент исполнения.

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

Рассмотрим скрипт:

var a = 10;
var c = 7;
function func(a, b, d) {
console.log(a, b, c, d);
c = a + d;
}
var b = 3;
func(10, a, b);
console.log(c);

VO этого скрипта формируется:

  1. Из переменной a, значение которой — undefined.
  2. Переменной c, значение которой — undefined.
  3. Переменной b, значение которой — undefined.
  4. Функции func с соответствующим телом.

Затем скрипт начнет исполняться по следующему сценарию:

  1. В переменную a запишется значение 10.
  2. В переменную c запишется значение 7.
  3. В переменную b запишется значение 3.
  4. Будет вызвана функция func.
  5. Создается контекст исполнения функции func.
  6. В VO контекста исполнения функции func будут записаны переменные из внешней области видимости: a, c и b, c присвоенными значениями.
  7. В VO контекста исполнения функции func будут созданы переменные из списка аргументов; поскольку переменные a и b уже существуют в VO, добавлена будет только переменная d со значением undefined.
  8. В переменную a VO контекста исполнения функции func будет записано значение 10.
  9. В переменную b VO контекста исполнения функции func будет записано значение переменной a внешней области видимости — 10.
  10. В переменную d VO контекста исполнения функции func будет записано значение переменной b внешней области видимости — 3.
  11. Контекст исполнения функции func будет запущен.
  12. В консоль выведется 1010 7 3.
  13. В переменную c, находящуюся во внешней области видимости, будет записано значение 13.
  14. Контекст выполнения функции func будет завершён; VO функции func будет удалён.
  15. В консоль выведется 13.

Теперь перепишем скрипт, добавив setTimeout с нулевым тайм-аутом у вызова функции:

var a = 10;
var c = 7;
function func(a, b, d) {
console.log(a, b, c, d);
c = a + d;
}
var b = 3;
setTimeout(function () { func(10, a, b); }, 0);
console.log(c);

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

  1. В переменную a запишется значение 10.
  2. В переменную c запишется значение 7.
  3. В переменную b запишется значение 3.
  4. Функция func попадает в пул ожидания.
  5. Создаётся контекст исполнения функции func.
  6. По истечении 0 миллисекунд контекст исполнения функции func будет помещён в event loop.
  7. В консоль выведется 7.
  8. В VO контекста исполнения функции func будут записаны переменные из внешней области видимости: a, c и b, c присвоенными значениями.
  9. В VO контекста исполнения функции func будут созданы переменные из списка аргументов; поскольку переменные a и b уже существуют в VO, добавлена будет только переменная d со значением undefined.
  10. В переменную a VO контекста исполнения функции func будет записано значение 10.
  11. В переменную b VO контекста исполнения функции func будет записано значение переменной a внешней области видимости — 10.
  12. В переменную d VO контекста исполнения функции func будет записано значение переменной b внешней области видимости — 3.
  13. Контекст исполнения функции func будет запущен.
  14. В консоль выведется 1010 7 3.
  15. В переменную c, находящуюся во внешней области видимости, будет записано значение 13.
  16. Контекст выполнения функции func будет завершён; VO функции func будет удалён.

Почему так происходит?

Всему виной setTimeout, очевидно. Он выводит контекст исполнения функции из синхронного потока, помещая его в event loop. То же самое происходит и с регистрацией событий. Мы можем подписаться на событие при помощи функции addEventListener. Передавая функцию обратного вызова — callback, добавляем её в список функций, которые должны быть вызваны при срабатывании этого события.

Допустим, мы хотим нажатием на кнопку перекрасить её в красный цвет. Код, который это выполняет, выглядит так:

var button = document.querySelector(‘button’);
button.addEventListener(‘click’, function (evt) {
button.style.background = ‘#f00’;
});

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

Если в процессе будет вызвано ещё одно событие, его коллбэки будут вставать в очередь по тому же принципу, возможно, перемежаясь с контекстами исполнения первого события.

Более сложный пример: есть две кнопки, первая перекрашивает фон страницы в красный цвет, а вторая — в жёлтый, но у второй перекрашивание фона завёрнуто в setTimeout с нулевой задержкой. И мы вручную вызываем событие нажатия сначала на жёлтую кнопку, а потом — на красную.

var redButton = document.getElementById(‘red’);
redButton.addEventListener(‘click’, function () {
document.body.style.background = ‘#f00’;
});
var yellowButton = document.getElementById(‘yellow’)
yellowButton.addEventListener(‘click’, function () {
setTimeout(function () {
         document.body.style.background = ‘#ff0’;
}, 0);
});
yellowButton.click();
redButton.click();

Живой пример:

Что происходит в такой ситуации, исходя из того, что мы рассмотрели выше?

  1. Вызывается событие click на жёлтой кнопке.
  2. Формируется контекст исполнения для коллбэка жёлтой кнопки.
  3. Вызывается контекст исполнения для жёлтой кнопки.
  4. Контекст исполнения коллбэка setTimeout помещается в event loop.
  5. Вызывается событие click на красной кнопке.
  6. Формируется контекст исполнения для коллбэка красной кнопки.
  7. Вызывается контекст исполнения для красной кнопки.
  8. Фону страницы дается значение »#f00».
  9. Вызывается событие repaint для DOM.
  10. Фон страницы становится красным.
  11. Вызывается контекст исполнения коллбэка setTimeout.
  12. Фону страницы назначается значение «#ff0».
  13. Вызывается событие repaint для DOM.
  14. Фон страницы становится жёлтым.

Обратите внимание, что исполнение коллбэков событий click на кнопках при вызове из кода происходит сразу же, не попадая в event loop: setTimeout с нулевой задержкой отложил перекраску фона в жёлтый, но функция сама была исполнена в момент вызова.

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

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

Онлайн-школа для детей Skillbox Kids
Учим детей программированию, созданию игр, сайтов и дизайну. Первое занятие бесплатно! Подробности — по клику.
Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована