Как стать автором
Обновить

Познаем промисы на основе Ecmascript спецификации. Знакомство

Время на прочтение 14 мин
Количество просмотров 7.4K
promise introduction

Здравствуйте. Изучая JavaScript (да и в принципе любую другую технологию), всегда возникают разнообразные вопросы, главный из которых: «А почему это работает таким образом и не иначе?» И очень важно в этот момент не просто найти ответ на вопрос, но и полученное объяснение встроить в единую систему уже полученных знаний. Иначе осиротевшую информацию придется зазубрить или забыть.


Совместное изучение чего-либо очень помогает искать ответы. Когда ученик / напарник задает вопрос про то, как понимать фразу — «… в следующий по цепочке промис „проваливается“ результат предыдущего… » — невольно задумываешься… Вот ведь какое странное дело. А ведь лучше уже не скажешь, неужели и это не понятно? Смотришь в чистые, немного наивные, глаза товарища и понимаешь — необходимо сказать как-то еще. Желательно так, чтобы даже запоминать не пришлось. Чтобы просто новая информация органично вписалась в уже существующие мысли человека.


Не буду описывать, что мы пробовали, читали, смотрели. В итоге заинтересовались спецификацией ECMAScript. Как её читать и понимать — это отдельный разговор (может даже отдельный пост). Но то, как там описаны промисы и их поведение, впервые дало нам целостностное и логически связное понимание данной темы. Чем и хочется поделиться с вами.


Данная статья ориентирована на новичков. Здесь будут обсуждаться промисы с точки зрения спецификации ECMAScript. Знаю, звучит странно, но как есть.


Объект promise: его философия, техническое представление, возможные состояния


Уже не раз было мною замечено, что качественное обучение программированию должно состоять из 2 частей. Это — философское осмысление идеи, а уже затем её техническая реализация. То есть обычная человеческая логика, которой ученик руководствуется при принятии каких-либо решений, сильно облегчает понимание технической реализации этого решения. Поэтому начнем мы с того, что такое обещание в жизни, и как мы к нему относимся? А затем посмотрим: как примеры обещаний будут реализованы в коде. Рассмотрим следующие рисунки (рис. 1, 2, 3).


Promise State
рис 1. ( [[PromiseState]] — как результат обещания )

Promise Result
рис 2. ( [[PromiseResult]] — как информация, связанная с результатом выполненного или невыполненного обещания )

Promise Reactions
рис 3. ( [[PromiseFulfillReactions]], [[PromiseRejectReactions]] — как последствия, которые наступают после выполнения или невыполнения обещания )

Мы видим, что само понятие обещания стоит на 3-х китах. 1) Было ли выполнено обещание вообще? 2) Какую дополнительную информацию мы можем извлечь после выполнения или отказа обещания? 3) Какие последствия наступят в случае положительного или отрицательного исхода нашего обещания?


Технически же обещание — это обыкновенная сущность, выраженная через такой тип данных, как объект. У этой сущности есть имя / класс Promise. Объекты, рожденные от этого класса, имеют в цепочке своих прототипов Promise.prototype. И данная сущность должна быть как-то связана со всей той «информацией из жизни», которую мы рассмотрели выше. Спецификация ECMAScript эту информацию закладывает в промис еще на уровне, который ниже по абстракции, чем сам JavaScript. Например, на уровень С++. Соответственно в объекте промиса есть место и под статус, и под результат, и под последствия обещания. Взгляните на то, из чего состоит обещание по по версии ECMAScript (рис. 4).


Promise fields
рис 4. ( Внутренние поля promise объекта по версии ECMAScript спецификации )

Какими новыми красками заиграла фраза «обещать — не значит жениться» с точки зрения программиста? 1) [[PromiseState]]. Кто-то не женился. 2) [[PromiseResult]]. Потому что ему не хватило денег на свадьбу. 3) [[PromiseRejectReactions]]. Как следствие — у него появилось куча свободного времени, которое он потратил на саморазвитие 4) [[PromiseFulfillReactions]]. А зачем человеку план B, когда он уже выбрал план A?


Да, есть и пятое поле [[PromiseIsHandled]]. Оно не очень для нас, людей, важное, и оперировать им в дальнейшем уже не будем. Вкратце: там хранится сигнал интерпретатору о том, был ли обработан отказ / reject обещания программистом или нет. Если нет, то необработанный reject обещания интерпретируется движком JS как ошибка. Для нетерпеливых: если программист не повесил вторым аргументом через Promise.prototype.then() функцию-callback-обработчик reject-а состояния промиса — то возникшее состояние «rejected» promise объекта покажет вам красную ошибку в консоли разработчика.


Обратили внимание, что поля объекта promise заключены в «[[» и «]]»? Этим подчеркивается, что доступ к данной информации у программиста JS напрямую не имеется. Только через специальные средства / команды / API, как, например, команду Promise.prototype.then(). Если же у вас есть непреодолимое желание управлять «этой кухней» напрямую, то добро пожаловать в клуб разработчиков стандартов спецификации EcmaScript.


Небольшое замечание в конце данной подглавы. Если в жизни у нас могут быть обещания выполнены частично, то в EcmaScript — нет. То есть, если человек обещал отдать миллион, а отдал 950 тысяч, то в жизни, может, он и надежный партнер, но для для JavaScript такой должник будет занесен в черный список через [[PromiseState]] === «rejected». Promise объект меняет свое состояние однозначно и всего лишь один раз. Как это технически реализуется — немного позже.


Конструктор Promise, его философия. Callback функция executor — как «выполнитель» обещания. Схема взаимодействия: Promise ( конструктор ) — executor ( callback ) — promise ( объект )


Итак, мы выяснили, что promise — это сущность, которая технически представляет собой JS объект с особыми скрытыми внутренними полями, которые в свою очередь обеспечивают философское наполнение смыслом слова «обещание».


Когда новичок первый раз создает объект promise, то его ожидает следующая картина (рис. 5).


wrong creating promise object
рис 5. ( Самый первый раз интуитивно создаем promise объект )

Что пошло не так, и почему ошибка — стандартный вопрос. При ответе на него лучше снова привести какую-то аналогию из жизни. Например, мало кто любит «пустозвонов» вокруг нас: которые только обещают, но ничего не делают по выполнению своих заявлений (политика не в счет). Мы намного лучше относимся к тем людям, которые после своего обещания имеют план и предпринимают сразу же какие-то действия для достижения обещанного результата.


Так и философия ECMAScript подразумевает, что если вы создаете обещание, то сразу же укажите, как вы его будете выполнять. Свой план действий программисту необходимо оформить в виде параметра-функции, которую передадите в конструктор Promise. Следующий эксперимент выглядит так (рис. 6).


Promise constructor uses executor
рис 6. ( Создаем promise объект, передавая в конструктор Promise функцию executor )

Из подписи к рисунку мы видим, что функция (параметр конструктора Promise) имеет собственное название — executor. Её задача — начать выполнение обещания и, желательно, привести его к какому-то логическому завершению. И если программист может писать какой угодно код в executor-е, то как программисту просигнализировать JS-у, что все — работа сделана — можно идти и смотреть результаты обещания?


Маркеры или сигналы, которые помогут программисту сообщить, что обещание завершено, передаются автоматически в параметры executor-a в виде аргументов, специально сформированных JavaScript-ом. Эти параметры можно называть как угодно, но чаще всего вы встретите их под такими именами, как res и rej. В спецификации ECMAScript их полное название — resolve function и reject function. Эти маркеры-функции имеют свои особенности, которые рассмотрим чуть ниже.


Для осознания новой информации новичку предлагается самостоятельно закодировать следующее утверждение: «Обещаю, что смогу разделить одно число на другое и выдать ответ, если только делитель не ноль». Вот как будет выглядеть приблизительно такой код (рис. 7).


promise task: division by zero
рис 7. ( Решение задачи на деление 2-х чисел через промисы )

Теперь можно проанализировать полученный результат. Мы видим, что уже второй раз консоль браузера показывает объект промис в интересном виде. А именно: указаны 2 дополнительных поля в двойных квадратных скобках. Можно спокойно провести аналогию между [[PromiseState]] и [[PromiseStatus]], fulfilled и resolved, [[PromiseValue]] и [[PromiseResult]]. Да, браузер сам пытается подсказать программисту о наличии и значении внутренних полей promise объекта. Также мы видим воедино связанную систему объекта promise, функции executor, специальных функций-callback-маркеров res и rej.


Чтобы ученик / напарник раскрепостился еще больше в этом материале, ему предлагается следующий код (рис. 8). Необходимо его проанализировать и ответить на следующие вопросы.


promise task: division by zero. Alternative version
рис 8. ( Вариация решения задачи на деление 2-х чисел через промисы )

Отработает ли код? Где здесь функция executor и какое она имеет имя? Подходящее ли в этом коде название «wantToDivide»? Что возвращает после себя функция bind? Почему в функцию bind аргументы передаются только на втором и третьем месте? Куда исчезли специальные функции resolve function и reject function? Каким образом необходимые вводные данные number1 и number2 попали в «план выполнения обещания»? Сколько элементов в псевдомассиве «arguments»? Можно ли по памяти восстановить то, как будет выглядеть ответ в консоли браузера?


Читателю предлагается самому подумать над ответами на вопросы. А также
поэкспериментировать в коде. Благо код небольшой и сама идея задачи — простая. Да, тут есть вопросы как на промисы, так и на общие знания JavaScript. Что поделать, везде нас поджидают неожиданности, которые не дают нам расслабиться. Как только вам станет все понятно — можно двигаться дальше.


Посмотреть / скопировать код
let number1 = Number(prompt("input number 1"));
let number2 = Number(prompt("input number 2"));

let wantToDivide = function() {
  if (arguments[1] === 0) {
    arguments[3]("it is forbidden to divide by zero");
    return;
  }
  let result = arguments[0] / arguments[1];
  arguments[2](result);
};

let myPromise = new Promise(wantToDivide.bind(null, number1, number2));
console.log(myPromise);


Рассматриваем аргументы executor-a: функции resolve и reject


Итак, попили кофейку — двигаемся дальше. Рассмотрим поподробнее специальные функции resolve function и reject function, автоматически формируемые JavaScript-ом для перевода promise объекта в состояние fulfilled или rejected, символизирующее окончание выполнения обещания.


Попробуем для начала взглянуть на них просто в консоли разработчика (рис 9).


research resolve function
рис 9. ( Исследование функции resolve function — res )

Мы видим, что resolve function представляет собой функцию, которая принимает один аргумент (свойство length === 1). А её прототипом является Function.prototype.


Хорошо, продолжим эксперименты. А что будет, если ссылку на resolve / reject function вынесем из executor-а во внешнюю область видимости? Не поломается ли чего (рис. 10)?


external contol of promise object
рис 10. ( Переводим промис myPromise в состояние fulfilled снаружи промиса )

Ничего необычного. Функции как подвид объекта в JavaScript передаются по ссылке. Все отработало так, как мы и ожидали. В переменную из замыкания outerRes попала ссылка на нашу resolving function res. И мы воспользовались её функциональностью, чтобы перевести промис в состояние fulfilled снаружи самого executor-а. Следующий слегка модифицированный пример показывает ту же самую мысль, поэтому посмотрите на код и подумайте, в каком состоянии и с каким значением будут находиться myPromise1 и myPromise2 (рис.11). Потом можете проверить ваши предположения под спойлером.


promise task. Questionрис 11. ( Задачка на размышление. В каком состоянии и с каким значением будут находиться промисы myPromise1 и myPromise2 в консоли разработчика? )

Ответ к задаче на рисунке 11 (рис. 12).
promise task. Answer
рис 12. ( Ответ к задаче на рисунке 11 )

А теперь можно задуматься над одним интересным вопросом. А как resolve / reject function всегда точно знает какой promise объект переводить в необходимое состояние? Обратимся к алгоритму в спецификации, где описано, как эти функции создаются (рис. 13).


create resolving functions
рис 13. ( Особенности создания resolving functions для одного конкретного promise объекта )

Важные моменты, на которые надо обратить внимание:


  • в момент создания функций resolve / reject они жестко привязываются к единственному соответствующему ему объекту promise
  • у функций resolve / reject как объектного типа данных существуют свои скрытые поля [[Promise]] и [[AlreadyResolved]], которые обеспечивают всем так знакомую интуитивную логику того, что а) — resolving functions сами собой переводят promise объект в необходимое состояние; и факт того, что б) — промис нельзя перевести в другое состояние, если хоть раз над ним вызывалась resolve или reject function. Данный алгоритм можно представить следующим рисунком (рис. 14).

    resolving functions and promise object
    рис 14. ( Скрытые поля функций resolve function и reject function )

    Алгоритмы, которые используют данную информацию из скрытых полей, сейчас не будем рассматривать, так как они многословные и более сложные. К ним еще надо подготовиться как в теоретическом, так и в моральном плане. Пока что просто могу подтвердить вашу мысль: «Ух, как просто, оказывается. Наверное, при каждом разрешении / резолвинге promise объекта будет проверяться „объектный“ флаг { [[Value]]: false }. И если он установлен в true, останавливаем процесс перевода promise в другое состояние простым return-ом». Да — это происходит именно так. Думается, правильно ответить на следующий вопрос вы сможете без проблем. Какой будет результат в консоли разработчика (рис. 15)?


    expertiment with linking resolving functions and promise object
    рис 15. ( Эксперимент, показывающий связь resolve и reject functions c одним конкретным promise объектом )

    Алгоритм создания promise объекта по спецификации ECMAScript


    Рассмотрим тот завораживающий момент, когда на свет рождается он — полноценный promise объект (рис. 16).


    promise creation in ecmascript
    рис 16. ( Алгоритм создания promise объекта из EcmaScript спецификации )

    Никаких сложных вопросов при его просмотре возникать уже не должно:

    • конструктор Promise должен быть вызван обязательно в режиме конструктора, а не обычного вызова функции
    • конструктор Promise требует наличия executor функции
    • создаем JavaScript объект со специфическими скрытыми полями
    • инициализируем скрытые поля какими-то начальными значениями
    • создаем связанные с promise объектом функции resolve и reject
    • вызываем на исполнение executor функцию, передавая туда в качестве аргументов уже сформированные маркеры resolve function и reject function
    • если в процессе выполнения executor-a что-то пошло не так, переводим наш promise объект в состояние rejected
    • возвращаем в переменную родившийся на свет promise объект.

    Не знаю, стало ли для вас открытием, что алгоритм executor функции выполняется здесь и сейчас, в обычном синхронном режиме, еще до того, как что-то будет записано в переменную слева от конструктора Promise. Но в свое время для меня это стало откровением.


    Раз уж затронули тему синхронности и асинхронности, то вот вам следующий код «на подумать» или для экспериментов. Вопрос: просмотрев некое творение программиста Димы, сможете ли вы ответить, какой смысл игры закодирован ниже?


    function randomInteger(min, max) {
      return Math.floor(min + Math.random() * (max + 1 - min));
    }
    
    function game() {
      let guessCubeNumber = Number(prompt("Throw dice? Guess number?", 3));
      console.log("throwing dice ... wait until it stop");
      let gameState = new Promise(function(res, rej) {
        setTimeout(function() {
          let gottenNumberDice = randomInteger(1, 6);
          gottenNumberDice === guessCubeNumber
            ? res("you win!")
            : rej(`you loose. ${gottenNumberDice} points dropped on dice`);
        }, 3000);
      });
      return gameState;
    }
    
    console.log(game());
    

    Конечно же, это эмуляция броска игрального кубика. Сможет пользователь угадать выпавшее число или нет? Посмотрите как органично асинхронный setTimeout встраивается в синхронный executor — в наш план бросить кубик и узнать выпавшее число. Как можно уже по-особому интерпретировать результаты в консоли разработчика (рис. 17)?


    Если мы попробуем посмотреть промис до того времени, как кубик остановится (3000 мс указано в коде), то мы увидим, что промис до сих пор находится в состоянии ожидания: игра не завершилась, кубик еще не остановился, выпавшего числа нет. Если же мы попробуем посмотреть promise объект после остановки кубика, то мы увидим вполне конкретную информацию: выиграл ли пользователь (угадав число), или проиграл и почему (какое число выпало на самом деле).


    promise game - throwing dice
    рис 17. ( Состояние promise объекта при наличие асинхронной операции в executor функции )

    Если вас заинтересовал данный пример, или вы хотите угадать выпавшее число подброшенного кубика — можете скопировать код и провести свои эксперименты. Смелей!


    Promise reaction как последствие исполненного обещания


    Как вы могли заметить на рисунке 14, последствия разрешения / резолвинга promise объекта подписаны как «+реакция» и «-реакция». Официальный термин для этих слов из ECMAScript спецификации — promise reaction. Предполагается, что в следующих статьях эта тема будет рассмотрена подробно. Пока что ограничимся общим представлением того, что такое promise reaction, чтобы можно было правильно ассоциировать этот термин с философским смыслом этого слова и его техническим исполнением.


    Как мы помним, у обещания могут быть последствия, а могут и не быть. Что же такое последствие? Это действие, которое произойдет некоторым временем позже: после того как обещание исполнится. А раз это действие, то последствие может быть выражено обычной JavaScript функцией. Одни функции исполнятся в случае успешного резолвинга промиса (+реакция); другие функции — в случае, когда промис перейдет в состояние rejected (-реакция). Технически эти функции (последствия) передаются аргументами при вызове метода Promise.prototype.then().


    Таким образом важной частью promise reaction является асинхронное действие, выполняемое когда-то в будущем. Есть и вторая важная составляющая часть promise reaction — это новосозданный промис, возвращаемый после выполнения команды Promise.prototype.then(). Это связано с тем, что последствия влияют на другие обещания. Например, есть обещание купить машину, но только после того, как будет выполнено обещание по зарабатыванию определенной суммы денег. Выполнили одно обещание — отработало последствие — теперь можно выполнить второе.


    По факту promise reaction связывает обещания между собой в каком-то временном интервале. Важно помнить тот момент, что реакции обрабатываются в автоматическом режиме. Вызовы функций — последствий разрешения промиса — осуществляются движком JS, не программистом (рис. 18). И, так как реакции тесно связаны с самими promise объектами (обещаниями), логично предположить, что алгоритмы promise reaction используют их внутренние поля в своей логике. И лучше знать о всех этих нюансах, чтобы уметь осознанно контролировать асинхронную логику, построенную на обещаниях.


    promise reaction in then() method
    рис 18. ( Последствия разрешения обещания записываются callback функциями в методе then(). Callback будет вызван асинхронно автоматически движком JS )

    Подводим итоги


    1) Мы познакомились с обещаниями в JavaScript, их философией и техническим исполнением. Реализовано все это с помощью специальных внутренний полей promise объекта: [[PromiseState]], [[PromiseValue]], [[PromiseFulFillReactions]], [[PromiseRejectReactions]].


    2) Программисту дается возможность выполнить заявленное им обещание через функцию executor, передаваемую аргументом в конструктор Promise.


    3) Границы выполненного или невыполненного обещания определяются специальными маркерами-функциями resolve function и reject function, часто в коде именуемыми res и rej. Данные функции создаются автоматически JavaScript-ом и передаются аргументами в executor.


    4) resolve function и reject function всегда имеют связанный с ними promise объект, а также общее специальное поле { [[Value]]: false }, которое обеспечивает разрешение обещания только один раз.


    5) [[PromiseFulFillReactions]] и [[PromiseRejectReactions]] являются внутренними полями promise объекта, которые хранят последствия разрешения обещания, важной частью которых являются пользовательские асинхронные функции, задаваемые через метод Promise.prototype.then() promise объекта.


    P. S.


    Данная статья подготовлена как конспект видеозанятия группы InSimpleWords. Там достаточно подобных «видеозанятий» и есть еще материал для конспектирования. Другой вопрос — интересно ли будет участникам сообщества читать уже какую по счету статью про промисы? Ждем ваших комментариев.

Теги:
Хабы:
+10
Комментарии 3
Комментарии Комментарии 3

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн