Generated with Avocode. Generated with Avocode. Generated with Avocode. Group 15 close hat Generated with Avocode. Generated with Avocode. Generated with Avocode. Generated with Avocode. Generated with Avocode. Generated with Avocode. path40

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

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

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

Асинхронность в 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 с нулевой задержкой отложил перекраску фона в желтый, но функция, его вызвавшая, сама была исполнена в момент вызова. Это происходит из-за того, что события из кода не требуется выполнять асинхронно. Действительно, в такой ситуации мы находимся в предсказуемом окружении, тогда как пользовательские события могут случаться в любой момент.

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

Если вы хотите знать все о JavaScript, ждем вас на курсе «Профессия frontend-разработчик». Вы научитесь верстать сайты и создавать интерфейсы, а на выходе получите два готовых проекта в портфолио.

Курс «Профессия frontend-разработчик»
С нуля до разработчика с зарплатой от60 000 рублей за6 месяцев. Научитесь верстать сайты и создавать интерфейсы, соберите два проекта в портфолио и получите современную профессию.
  • Живая обратная связь с преподавателями
  • Неограниченный доступ к материалам курса
  • Стажировка в компаниях-партнёрах
  • Дипломный проект от реального заказчика
  • Гарантия трудоустройства в компании-партнёры для выпускников, защитивших дипломные работы
Хочешь получать крутые статьи по программированию?
Подпишись на рассылку Skillbox