Как управлять event loop в JavaScript. Часть 2
Продолжаем рассказывать об event loop, понимание принципов работы которого важно для карьерного роста middle-разработчика.
vlada_maestro / shutterstock
Автор текста — Александр Кузьмин, ведущий программист и руководитель отдела клиентской разработки компании IT-Park, имеющий за плечами десятилетний опыт во frontend. Передаем ему слово.
В прошлый раз я рассказал об устройстве event loop в JavaScript, принципах работы и подводных камнях, с которыми сталкивается разработчик. Сегодня рассмотрим основные практики, позволяющие использовать возможности событийного цикла на полную, помимо setTimeout. Но сначала расскажу о том, как не стоит делать.
В первой части статьи я нарочно использовал синтаксис стандарта ECMAScript 5. Здесь применю современный стандарт, поскольку он больше подходит предмету нашего разговора.
Блокирующие операции vs. event loop
Такое название было придумано не зря: блокирующими называют операции, которые не дают контексту выполнения завершиться в адекватное время, что влечет за собой блокирование очереди контекстов. Рассмотрим классический пример — циклы.
Взгляните на код:
Вне контекста клиента он выглядит вполне операбельным. Но давайте разберемся, что здесь происходит.
Во-первых, заключенный в фигурные скобки цикла код выполняется в том же контексте исполнения, в котором цикл объявлен. Это значит, что контекст не завершится, пока не будут обработаны все десять тысяч итераций цикла.
Во-вторых, несмотря на вызов функции внутри цикла, возвращаемый ею результат записывается в объявленный выше массив через операцию присвоения по индексу.
А если вместо Math.pow () мы бы использовали функцию, которая делает запрос на сервер и возвращает полученное значение? Это выглядело бы так:
Конструкция async-await появилась в стандарте ECMAScript 2015, и само слово await четко дает понять, что мы должны дождаться выполнения асинхронной функции getData (), не помещать ее в event loop, а выполнить прямо здесь и записать результат в i-й элемент массива.
Это значит, что при вызове асинхронной функции fillArray () она попадет в очередь контекстов и будет исполнена. Но, как мы уже знаем, следующий контекст будет ждать, пока текущий завершится. Все пользовательские события, таймеры и прочие помещаемые в очередь контексты будут ждать, пока не пройдут десять тысяч запросов к серверу.
Сравните этот код со следующим:
Что здесь происходит? Мы избавились от операции присвоения внутри цикла, и на каждую итерацию arr.push () будет помещен в event loop, как и getData () для него. По мере готовности данных будет вызываться push, при этом блокирования очереди не произойдет, так как контексты теперь оказываются в событийном цикле. Ниже представлен JSFiddle, в котором нам придется немного забежать вперед, чтобы продемонстрировать саму концепцию, о которой идет речь (откройте консоль, чтобы видеть результат).
Давайте чуть изменим наш код:
Этот код поэлементно преобразует входной массив, выполняя для каждого из его элементов функцию-преобразователь, не блокируя очередь контекстов. На самом деле такая функция уже есть в языке — это метод map () объекта Array. Она работает заметно быстрее, поскольку реализована на стороне интерпретатора, но алгоритмически делает ровно то же самое. И взаимодействие с event loop у нее такое же.
Куда важнее сама концепция: стараться писать код так, чтобы тяжелые вычисления помещались в event loop и уже по готовности оказывались на месте.
Так мы расчищаем очередь, позволяя выполняться пользовательским событиям и, как ни странно, анимациям. Да, блокирующие операции влияют на их выполнение — даже CSS-анимации используют тот же поток вычислений, что и JavaScript.
Теперь, вооружившись базовым пониманием этого подхода, мы сможем перейти к следующей теме.
Высокоуровневый подход к управлению event loop
Событийный цикл не зря так называется — он ожидает событие и после него помещает в очередь исполнения соответствующий контекст. Выше, в примере с JSFIddle, мы уже эмулировали отложенное получение данных при помощи нативного для стандарта ES2015 объекта Promise. Это один из способов управления контекстом исполнения, и ниже мы рассмотрим его подробнее.
JavaScript Promise — отложенное обещание
Созданием объекта Promise мы даем обещание: как только произойдет какое-либо событие, мы его разрешим и запустим обработку данных. Технически это выглядит так:
Метод then () принимает две функции в качестве аргументов: первая выполнится при вызове resolve (), а вторая — при вызове reject (). В общем случае resolve () может вызываться при срабатывании любого события.
Таким образом, если anyCondition === true, в консоль выведется 10. Если же нет — 12. Но мы можем написать то же самое и по-другому:
Все благодаря тому, что then и catch возвращают исходный объект Promise. К тому же это работает и так:
В результате выполнения этого кода в консоль выведется число 40. Поскольку попавший в функцию resolve () результат сохраняется внутри объекта Promise, который мы создали, и принимает значения после выполнения функций, переданных в качестве аргументов в then () по мере путешествия по цепочке. То есть во второй функции result будет результатом выполнения предыдущей. Можете взять пример ниже и поэкспериментировать с ним:
Но мы не просто так играем с этими цепочками. Они работают напрямую через event loop и в общем случае могут содержать внутри достаточно большие и тяжелые вычисления. Разбивая их на атомарные операции, мы оставляем пространство для выполнения обработчиков пользовательских событий. Чтобы вам было проще понять механизм работы Promise, вот ссылка на Fiddle, где реализован объект, в ключевых моментах повторяющий функциональность Promise.
Он не зря зовется высокоуровневым — в основе асинхронности JavaScript лежит все тот же setTimeout, но это лишь самый простой подход к асинхронной обработке данных с использованием event loop. Существует целый ряд библиотек, реализующих более сложные механизмы: RxJS, Bacon.js и им подобные.
На основе приведенного выше аналога Promise можно построить свой вариант такого решения. Например, сделать отложенный вызов функции promised, что позволит отделить декларацию цепочки обработки от фактического вызова в результате Ajax-запроса. Можно добавить различные обработчики, в частности, привычный для библиотеки Rx flatMap, который возвращает другой объект Promise внутри той же цепочки.
Этот подход зовется функциональным реактивным программированием (FRP). Основное правило, которое он преследует: функции должны быть атомарными — выполнять ровно одну задачу и оставаться неделимыми. Чем меньше и короче функция, тем быстрее она выполняется, гарантируя чистоту и доступность очереди контекстов для пользовательских событий.
Заключение
В этой и первой статье мы рассмотрели понятие асинхронности в JavaScript и области видимости, разобрались в блокирующих операциях и управлении event loop с помощью Promise. Надеемся, вам было полезно!