Визуализация промисов и Async/Await
Доброго времени суток, друзья!
Представляю вашему вниманию перевод статьи «JavaScript Visualized: Promises & Async/Await» автора Lydia Hallie.
Приходилось ли вам сталкиваться с JavaScript кодом, который… работает не так, как ожидается? Когда функции выполняются в произвольном, непредсказуемом порядке, или выполняются с задержкой. Одна из главных задач промисов — упорядочение выполнения функций.
Мое ненасытное любопытство и бессонные ночи окупились сполна — благодаря им я создала несколько анимаций. Пришло время поговорить о промисах: как они работают, почему их следует использовать и как это делается.
Введение
При написании JS кода нам часто приходится иметь дело с задачами, которые зависят от других задач. Допустим, мы хотим получить изображение, сжать его, применить к нему фильтр и сохранить его.
Первым делом нам нужно получить изображение. Для этого воспользуемся функцией getImage
. После загрузки изображения мы передаем его функции resizeImage
. После сжатия изображения мы применяем к нем фильтр с помощью функции applyFilter
. После сжатия и применения фильтра мы сохраняем изображение и сообщаем пользователю об успехе.
В результате получаем следующее:
Хм… Что-нибудь заметили? Несмотря на то, что все работает, выглядит это не лучшим образом. Мы получаем множество вложенных функций обратного вызова, зависящих от предыдущих коллбэков. Это называется адом коллбэков и это существенно усложняет чтение и поддержку кода.
К счастью, сегодня у нас есть промисы.
Синтаксис промисов
Промисы были представлены в ES6. Во многих руководствах вы можете прочитать следующее:
Промис (обещание) — это значение, которое выполняется или отклоняется в будущем.
Да уж… Так себе объяснение. В свое время оно заставило меня считать промисы чем-то странным, неопределенным, какой-то магией. Чем же они являются на самом деле?
Мы можем создать промис с помощью конструктора Promise
, принимающего функцию обратного вызова в качестве аргумента. Круто, давайте попробуем:
Погодите, что здесь возвращается?
Promise
— это объект, который содержит статус ([[PromiseStatus]]
) и значение ([[PromiseValue]]
). В приведенном примере значением [[PromiseStatus]]
является pending
, а значением промиса — undefined
.
Не волнуйтесь, вам не придется взаимодействовать с этим объектом, вы даже не можете получить доступ к свойствам [[PromiseStatus]]
и [[PromiseValue]]
. Тем не менее, эти свойства очень важны при работе с промисами.
PromiseStatus
или статус промиса может принимать одно из трех значений:
fulfilled
: промис былresolved
(выполнен). Все прошло хорошо, без ошибокrejected
: промис былrejected
(отклонен). Возникла какая-то ошибкаpending
: промис пока не выполнен и не отклонен, онpending
(ожидает, находится в режиме ожидания)
Звучит здорово, но когда промис приобретает указанные статусы? И почему статус имеет значение?
В приведенном примере мы передаем конструктору Promise
простую функцию обратного вызова () => {}
. На самом деле эта функция принимает два аргумента. Значение первого аргумента, обычно называемого resolve
или res
, это метод, вызываемый при выполнении промиса. Значение второго аргумента, обычно называемого reject
или rej
, это метод, вызываемый при отклонении промиса, когда что-то пошло не так.
Посмотрим, что выводится в консоль при вызове методов resolve
и reject
:
Круто! Теперь мы знаем, как избавиться от статуса pending
и значения undefined
. Статусом промиса при вызове метода resolve
является fulfilled
, при reject
— rejected
.
[[PromiseValue]]
или значением промиса является значение, которое мы передаем методам resolve
или reject
в качестве аргумента.
Забавный факт: Jake Archibald после прочтения данной статьи указал на баг в Chrome, который вместо fulfilled
возвращал resolved
.
Ok, теперь мы знаем, как работать с объектом Promise
. Но для чего он используется?
Во введении я привела пример, в котором мы получаем изображение, сжимаем его, применяем к нему фильтр и сохраняем его. Тогда все закончилось адом коллбэков.
К счастью, промисы помогают с этим справиться. Перепишем код так, чтобы каждая функция возвращала промис.
Если изображение загрузилось, выполняем промис. В противном случае, если произошла ошибка, отклоняем промис:
Посмотрим, что происходит при запуске этого кода в терминале:
Клево! Промис возвращается с разобранными («распарсенными») данными, как мы и ожидали.
Но… что дальше? Нас не интересует объект промиса, нас интересуют его данные. Для получения значения промиса существует 3 встроенных метода:
.then()
: вызывается после выполнения промиса.catch()
: вызывается после отклонения промиса.finally()
: вызывается всегда, как после выполнения, так и после отклонения промиса
Метод .then
принимает значение, переданное методу resolve
:
Метод .catch
принимает значение, переданное методу reject
:
Наконец, мы получили искомое значение. Мы можем делать с этим значением все что угодно.
Когда мы уверены в выполнении или отклонении промиса, можно писать Promise.resolve
или Promise.reject
с соответствующим значением.
Именно такой синтаксис будет использоваться в следующих примерах.
Результатом .then
является значение промиса (т.е. данный метод также возвращает промис). Это означает, что мы можем использовать столько .then
, сколько потребуется: результат предыдущего .then
передается в качестве аргумента следующему .then
.
В getImage
мы можем использовать несколько .then
для передачи обработанного изображения следующей функции.
Такой синтаксис выглядит гораздо лучше лестницы вложенных функций обратного вызова.
Микрозадачи и (макро)задачи
Хорошо, теперь мы знаем, как создавать промисы и как извлекать из них значения. Добавим немного кода в наш скрипт и запустим его снова:
Сначала в консоль выводится Start!
. Это нормально, поскольку в первой строке кода у нас console.log('Start!')
. Вторым значением, выводимым в консоль, является End!
, а не значение выполнившегося промиса. Значение промиса выводится последним. Почему так произошло?
Здесь мы наблюдаем мощь промисов. Несмотря на то, что JS является однопоточным, мы можем сделать код асинхронным с помощью Promise
.
Где еще мы могли наблюдать асинхронное поведение? Некоторые встроенные в браузер методы, такие как setTimeout
, могут имитировать асинхронность.
Точно. В цикле событий (Event Loop) существует два типа очередей: очередь (макро)задач или просто задач ((macro)task queue, task queue) и очередь микрозадач или просто микрозадачи (microtask queue, microtasks).
Что относится к каждой из них? Если вкратце, то:
- Макрозадачи: setTimeout, setInterval, setImmediate
- Микрозадачи: process.nextTick, Promise callback, queueMicrotask
Мы видим Promise
в списке микрозадач. Когда Promise
выполняется и вызывается метод then()
, catch()
или finally()
, функция обратного вызова с методом добавляется в очередь микрозадач. Это означает, что коллбэк с методом не выполняется немедленно, что делает JS код асинхронным.
Когда же метод then()
, catch()
или finally()
выполняется? Задачи в цикле событий имеют следующий приоритет:
- Сначала выполняются функции, находящиеся в стеке вызовов. Значения, возвращаемые этими функциями, удаляются из стека.
- После освобождения стека в него одна за другой помещаются и выполняются микрозадачи (микрозадачи могут возвращать другие микрозадачи, создавая бесконечный цикл микрозадач).
- После освобождения стека и очереди микрозадач, цикл событий проверяет наличие макрозадач. Макрозадачи помещаются в стек, выполняются и удаляются.
Рассмотрим пример:
Task1
: функция, добавляемая в стек немедленно, например, посредством вызова в коде.Task2
,Task3
,Task4
: микрозадачи, например,then
промиса или задача, добавленная с помощьюqueueMicrotask
.Task5
,Task6
: макрозадачи, например,setTimeout
илиsetImmediate
Сначала Task1
возвращает значение и удаляется из стека. Затем движок проверяет наличие микрозадач в соответствующей очереди. После добавления и последующего удаления из стека микрозадач, движок проверяет наличие макрозадач, которые также добавляются в стек и удаляются из него после возврата значений.
Довольно слов. Давайте писать код.
В этом коде мы имеем макрозадачу setTimeout
и микрозадачу .then
. Запустим код и посмотрим, что выведется в консоль.
На заметку: в приведенном примере я использую такие методы как console.log
, setTimeout
и Promise.resolve
. Все эти методы являются внутренними, поэтому не отображаются в трассировке стека (stack trace) — не удивляйтесь, когда не обнаружите их в средствах устранения неполадок браузера.
В первой строке у нас console.log
. Он добавляется в стек и в консоль выводится Start!
. После этого данный метод удаляется из стека и движок продолжает разбор кода.
Движок достигает setTimeout
, который добавляется в стек. Данный метод является встроенным методом браузера: его функция обратного вызова (() => console.log('In timeout')
) добавляется в Web API и находится там до срабатывания таймера. Несмотря на то, что счетчик таймера равняется 0, коллбэк все равно помещается сначала в WebAPI, а затем в очередь макрозадач: setTimeout
— это макрозадача.
Далее движок достигает метода Promise.resolve()
. Данный метод добавляется в стек, после чего выполняется со значением Promise
. Его коллбэк then
помещается в очередь микрозадач.
Наконец, движок достигает второго метода console.log()
. Он сразу помещается в стек, в консоль выводится End!
, метод удаляется из стека, а работа движка продолжается.
Движок «видит», что стек пуст. Осуществляется проверка очереди микрозадач. Там находится then
. Он помещается в стек, в консоль выводится значение промиса: в данном случае — строка Promise!
.
Движок видит, что стек пуст. Он «заглядывает» в очередь микрозадач. Она тоже пуста.
Пришло время проверить очередь макрозадач: там находится setTimeout
. Он помещается в стек и возвращает метод console.log()
. В консоль выводится строка 'In timeout!'
. setTimeout
удаляется из стека.
Готово. Теперь все встало на свои места, не правда ли?
Async/Await
В ES7 был представлен новый способ работы с асинхронным кодом в JS. С помощью ключевых слов async
и await
мы может создать асинхронную функцию, неявно возвращающую промис. Но… как нам это сделать?
Ранее мы рассмотрели, как явно создать объект Promise
: с помощью new Promise(() => {})
, Promise.resolve
или Promise.reject
.
Вместо этого мы можем создать асинхронную функцию, неявно возвращающую указанный объект. Это означает, что нам больше не нужно вручную создавать Promise
.
То, что асинхронная функция неявно возвращает промис — это, конечно, здорово, но мощь данной функции в полной мере проявляется при использовании ключевого слова await
. await
заставляет асинхронную функцию ждать выполнение промиса (его значения). Чтобы получить значение выполненного промиса, мы должны присвоить переменной ожидаемое (awaited) значение промиса.
Получается, что мы может отложить выполнение асинхронной функции? Отлично, но… что это значит?
Посмотрим, что происходит при запуске следующего кода:
Сначала движок видит console.log
. Данный метод помещается в стек, в консоль выводится Before function!
.
Затем вызывается асинхронная функция myFunc()
, выполняется ее код. В первой строке этого кода мы вызываем второй console.log
со строкой 'In function!'
. Данный метод добавляется в стек, его значение выводится в консоль, и он удаляется из стека.
Код функции выполняется дальше. На второй строке у нас имеется ключевое слово await
.
Первое, что здесь происходит, это выполнение ожидаемого значения: в данном случае функции one
. Она помещается в стек и возвращает промис. После того, как промис выполнился, а функция one
вернула значение, движок видит await
.
После этого выполнение асинхронной функции откладывается. Выполнение тела функции приостанавливается, оставшийся код выполняется как микрозадача.
После того, как выполнение асинхронной функции было отложено, движок возвращается к выполнению кода в глобальном контексте.
После выполнения всего кода в глобальном контексте, цикл событий проверяет наличие микрозадач и обнаруживает myFunc()
. myFunc()
помещается в стек и выполняется.
Переменная res
получает значение выполненного промиса, который вернула функция one
. Мы вызываем console.log
со значением переменной res
: строкой One!
в данном случае. One!
выводится в консоль.
Готово. Заметили разницу между асинхронной функцией и методом then
промиса? Ключевое слово await
откладывает выполнение асинхронной функции. Если бы мы использовали then
, то тело промиса продолжило бы выполняться.
Получилось довольно многословно. Не переживайте, если чувствуете себя неуверенно при работе с промисами. Для того, чтобы к ним привыкнуть требуется какое-то время. Это характерно для всех приемов работы с асинхронным кодом в JS.
Также см. «Визуализация работы сервис-воркеров».
Спасибо за потраченное время. Надеюсь оно было потрачено не зря.