Визуализация промисов и 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, при rejectrejected.

    [[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() выполняется? Задачи в цикле событий имеют следующий приоритет:

    1. Сначала выполняются функции, находящиеся в стеке вызовов. Значения, возвращаемые этими функциями, удаляются из стека.
    2. После освобождения стека в него одна за другой помещаются и выполняются микрозадачи (микрозадачи могут возвращать другие микрозадачи, создавая бесконечный цикл микрозадач).
    3. После освобождения стека и очереди микрозадач, цикл событий проверяет наличие макрозадач. Макрозадачи помещаются в стек, выполняются и удаляются.



    Рассмотрим пример:

    • 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.

    Также см. «Визуализация работы сервис-воркеров».

    Спасибо за потраченное время. Надеюсь оно было потрачено не зря.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 5

      0

      Ну спасибо! Поглядим подробнее на промисы. Вкупе с данной статьёй может сделать предмет понятным: https://habr.com/ru/post/479062/


      Мы видим Promise в списке микрозадач. Когда Promise выполняется и вызывается метод then(), catch() или finally(), функция обратного вызова с методом добавляется в очередь микрозадач. Это означает, что коллбэк с методом не выполняется немедленно, что делает JS код асинхронным.

      Т.е. Promise автоматом делает ЛЮБОЙ код асинхронным? Или без асинхронного вызова типа setTimeout() всё равно никакой асинхронности не будет?!


      Т.е. функция-исполнитель ниже всегда будет асинхронной:


      let promise = new Promise(function(resolve, reject) {
        // функция-исполнитель (executor)
      });

      Или в неё нужно добавить что-то типа setTimeout():


      let promise = new Promise(function(resolve, reject) {
        // эта функция выполнится автоматически, при вызове new Promise
      
        // через 1 секунду сигнализировать, что задача выполнена с результатом "done"
        setTimeout(() => resolve("done"), 1000);
      });

      чтобы она была асинхронной?

        +1
        Здравствуйте. Не смог пройти мимо.
        Т.е. Promise автоматом делает ЛЮБОЙ код асинхронным? Или без асинхронного вызова типа setTimeout() всё равно никакой асинхронности не будет?!

        1) Извините, что буду отвечать не совсем прямо на ваш вопрос. Но начнем немного с другого. Promise — сквозь призму ваших слов выглядит как функция, которая взяла — что-то сделала магическое — и завершилась, отдав какой то результат. Все, как в классике: есть функция — значит она должна что-то получить на вход и что-то отдать на выход.

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

        И ваш вопрос не следует воспринимать буквально напрямик: «делает ли промис мой код асинхронным...?» Скорее надо задавать вопрос так. А вот мой код, который я напишу там-то (в executor-e) или тут-то (в коллбеке then() ) или еще где-то (например, в Promise.resolve()) — как он будет выполняться в уже имеющейся системе действий, которые скрыты от ваших глаз под капотом движка JS?

        Если код будет выполняться построчно здесь и сейчас — значит код выполняется синхронно. Если же мы откладываем его на какой-то промежуток времени на будущее — значит асинхронно.

        2) И тут возникает хороший вопрос. А что же это за система действий, которая выполняется во времени, и которая скрыта от наших глаз? Зная эту систему, и как наш код в неё встраивается, мы легко ответим на множество сейчас неочевидных вопросов. Прямо как система хуков =)

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

        Немного отвлекся. Ну так вот, возвращаясь к промисам, отвечаю наконец-то прямо на ваш вопрос. Код внутри executor фукнции new Promise( (res, rej)=>{/* тело executor функции */} ) будет выполняться всегда здесь и сейчас, как только интерпретатор дошел до команды new Promise() — то есть всегда синхронно.

        И не важно, напишете вы внутри setTimeout или fetch или что-еще — сама executor функция выполнится до финиша, после чего вам вернется на руки промис-объект. Этот промис объект
        будет всегда, вот только его состояние определяется тем, был ли синхронно вызван коллбек-res-параметр executor функции внутри executor фукнции, или вызов коллбек-res-параметра отложили когда-то на потом через setTimeout тот же.

        Успели синхронно вызвать коллбек-res-параметр — получите промис в состоянии fulfilled после выполнения new Promise(). Перенесли на будущее вызов — получившийся промис будет в состоянии pending.

        И если вы думаете, что на этом подковерная цепочка действий внутренней логики промисов завершилась — то это заблуждение. Там потом такие «повороты сюжета» идут — просто… then одним словом.

        Если в этой статье для вас не слишком явно описаны первоначальные нюансы использовния промисов — попробуйте эту статью. habr.com/ru/post/478938 — что называется минутка саморекламы. Там ответ на ваш вопрос описан более подробно в подзаголовке «Алгоритм создания promise объекта по спецификации ECMAScript».

          0

          Благодарю! Будем разбираться.


          А вот мой код, который я напишу там-то (в executor-e) или тут-то (в коллбеке then() ) или еще где-то (например, в Promise.resolve()) — как он будет выполняться в уже имеющейся системе действий, которые скрыты от ваших глаз под капотом движка JS?

          Вот насколько же красиво сделали в C# — запустил


          await Task.Run( () => { // функция-исполнитель (executor) }


          И чётко знаешь, что функция выполняется асинхронно в отдельном потоке без всяких там setTimeout() и прочего.

        0
        В какую очередь попадут коллбеки из requestAnimationFrame?
          0
          Спасибо! Не знал про нюансы между Promise и Async/Await. Буду учитывать это при разработке.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое