Код
#статьи

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

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

 vlada_maestro / shutterstock

Автор текста — Александр Кузьмин, ведущий программист и руководитель отдела клиентской разработки компании 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. Надеемся, вам было полезно!

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Научитесь: Профессия Python-разработчик Узнать больше
Понравилась статья?
Да

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

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