Меню

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

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

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

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

В первой части статьи я нарочно использовал синтаксис стандарта ECMAScript 5. Здесь применю современный стандарт, поскольку он больше подходит предмету нашего разговора.

Блокирующие операции vs. event loop

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

Взгляните на код:

const arr = [];

for (let i = 0; i < 10000; i++) {

arr[i] = Math.pow(2, i);

}

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

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

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

А если вместо Math.pow() мы бы использовали функцию, которая делает запрос на сервер и возвращает полученное значение? Это выглядело бы так:

async function fillArray() {

const arr = [];

for (var i = 0; i < 10000; i++) {

        arr[i] = await getData(i);

}

}

Конструкция async-await появилась в стандарте ECMAScript 2015, и само слово await четко дает понять, что мы должны дождаться выполнения асинхронной функции getData(), не помещать ее в event loop, а выполнить прямо здесь и записать результат в i-й элемент массива.

Это значит, что при вызове асинхронной функции fillArray() она попадет в очередь контекстов и будет исполнена. Но, как мы уже знаем, следующий контекст будет ждать, пока текущий завершится. Все пользовательские события, таймеры и прочие помещаемые в очередь контексты будут ждать, пока не пройдут десять тысяч запросов к серверу.

Сравните этот код со следующим:

async function fillArray() {

const arr = [];

for (var i = 0; i < 10000; i++) {

       arr.push(await getData(i));

}

}

Что здесь происходит? Мы избавились от операции присвоения внутри цикла, и на каждую итерацию arr.push() будет помещен в event loop, как и getData() для него. По мере готовности данных будет вызываться push, при этом блокирования очереди не произойдет, так как контексты теперь оказываются в событийном цикле. Ниже представлен JSFiddle, в котором нам придется немного забежать вперед, чтобы продемонстрировать саму концепцию, о которой идет речь (откройте консоль, чтобы видеть результат).

Смотрите код на JSFiddle.

Давайте чуть изменим наш код:

function mapArray(incoming = [], func = (x) => x) {

const outcoming = [];

for (var i = 0; i < incoming.length; i++) {

        outcoming.push(func(incoming[i]));

}

return outcoming;

}

const d = mapArray([1,2,3], (x) => x + 1);

Этот код поэлементно преобразует входной массив, выполняя для каждого из его элементов функцию-преобразователь, не блокируя очередь контекстов. На самом деле такая функция уже есть в языке — это метод map() объекта Array. Она работает заметно быстрее, поскольку реализована на стороне интерпретатора, но алгоритмически делает ровно то же самое. И взаимодействие с event loop у нее такое же.

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

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

Теперь, вооружившись базовым пониманием этого подхода, мы сможем перейти к следующей теме.

Высокоуровневый подход к управлению event loop

Событийный цикл не зря так называется — он ожидает событие и после него помещает в очередь исполнения соответствующий контекст. Выше, в примере с JSFIddle, мы уже эмулировали отложенное получение данных при помощи нативного для стандарта ES2015 объекта Promise. Это один из способов управления контекстом исполнения, и ниже мы рассмотрим его подробнее.

JavaScript Promise — отложенное обещание

Созданием объекта Promise мы даем обещание: как только произойдет какое-либо событие, мы его разрешим и запустим обработку данных. Технически это выглядит так:

const prom = new Promise((resolve, reject) => {

/* ... */

if (anyCondition) {

        resolve(10);

} else {

        reject(12);

}

});

prom.then(

(result) => console.log(result),

(result) => console.log(result),

);

Метод then() принимает две функции в качестве аргументов: первая выполнится при вызове resolve(), а вторая — при вызове reject(). В общем случае resolve() может вызываться при срабатывании любого события.

Таким образом, если anyCondition === true, в консоль выведется 10. Если же нет — 12. Но мы можем написать то же самое и по-другому:

prom

.then((result) => console.log(result))

.catch((result) => console.log(result));

Все благодаря тому, что then и catch возвращают исходный объект Promise. К тому же это работает и так:

prom

.then((result) => result + 10)
.then((result) => result * 2)
.then((result) => console.log(result))
.catch((result) => console.log(result));

В результате выполнения этого кода в консоль выведется число 40. Поскольку попавший в функцию resolve() результат сохраняется внутри объекта Promise, который мы создали, и принимает значения после выполнения функций, переданных в качестве аргументов в then() по мере путешествия по цепочке. То есть во второй функции result будет результатом выполнения предыдущей. Можете взять пример ниже и поэкспериментировать с ним:

const prom = new Promise((resolve) => resolve(10));

prom

.then((res) => {

           console.log(res); // 10

           return res + 10;

})

.then((res) => {

            console.log(res); // 20

            return res + 10;

});

Но мы не просто так играем с этими цепочками. Они работают напрямую через event loop и в общем случае могут содержать внутри достаточно большие и тяжелые вычисления. Разбивая их на атомарные операции, мы оставляем пространство для выполнения обработчиков пользовательских событий. Чтобы вам было проще понять механизм работы Promise, вот ссылка на Fiddle, где реализован объект, в ключевых моментах повторяющий функциональность Promise.

Пример полностью смотрите здесь.

Он не зря зовется высокоуровневым — в основе асинхронности JavaScript лежит все тот же setTimeout, но это лишь самый простой подход к асинхронной обработке данных с использованием event loop. Существует целый ряд библиотек, реализующих более сложные механизмы: RxJS, Bacon.js и им подобные.

На основе приведенного выше аналога Promise можно построить свой вариант такого решения. Например, сделать отложенный вызов функции promised, что позволит отделить декларацию цепочки обработки от фактического вызова в результате Ajax-запроса. Можно добавить различные обработчики, в частности, привычный для библиотеки Rx flatMap, который возвращает другой объект Promise внутри той же цепочки.

Этот подход зовется функциональным реактивным программированием (FRP). Основное правило, которое он преследует: функции должны быть атомарными — выполнять ровно одну задачу и оставаться неделимыми. Чем меньше и короче функция, тем быстрее она выполняется, гарантируя чистоту и доступность очереди контекстов для пользовательских событий.

Заключение

В этой и первой статье мы рассмотрели понятие асинхронности в JavaScript и области видимости, разобрались в блокирующих операциях и управлении event loop с помощью Promise. Надеемся, вам было полезно!

Приглашаем на двухгодичную программу «Веб-разработчик PRO», которая включает в себя несколько курсов и затрагивает сферу обучения максимально широко.

Я – Веб-разработчик PRO
С нуля до профессионального веб-разработчика. Вы научитесь верстать сайты и создавать интерфейсы, соберете6 проектов в портфолио и получите востребованную профессию. Расходы за первые полгода курса берет на себя Skillbox. В это время вы посещаете все лекции и воркшопы, прокачиваете навыки, находите себе работу и начинаете получать деньги.
  • Живая обратная связь с преподавателями
  • Неограниченный доступ к материалам курса
  • Стажировка в компаниях-партнёрах
  • Дипломный проект от реального заказчика
  • Гарантия трудоустройства в компании-партнёры для выпускников, защитивших дипломные работы