Лес чуден, тёмен — глянь в глубину.
Но прежде я все долги верну…
И много миль, пока я усну,
Так много миль, пока я усну...

Роберт Фрост

image

Промисы — это одно из самых замечательных новшеств ES6. JavaScript поддерживает асинхронное программирование посредством функций обратного вызова и с помощью других механизмов. Однако при использовании функций обратного вызова мы сталкиваемся с некоторыми проблемами. Среди них — «ад коллбэков» и «пирамида ужаса». Промисы — это паттерн, который значительно упрощает асинхронное программирование на JS. Асинхронный код, написанный с использованием промисов, выглядит как синхронный и лишён проблем, связанных с коллбэками.

Материал, перевод которого мы сегодня публикуем, посвящён промисам и их практическому использованию. Он рассчитан на начинающих разработчиков, которым хочется разобраться с промисами.

Что такое промис?


Вот определение промисов, данное ECMA: «Промис — это объект, который используется как местозаполнитель для возможного будущего результата отложенных (и возможно асинхронных) вычислений.

Проще говоря — промис (promise) — это контейнер для некоего будущего значения. Тут стоит отметить, что нередко, говоря о промисах, их называют «обещаниями» и «обещанными результатами». Если немного подумать, то это похоже на то, как люди используют слово «обещание» (promise) в обычной жизни. Например, вы забронировали билет на самолёт, который летит в Индию. Там вы собираетесь посетить прекрасную горную станцию Дарджилинг. После завершения операции бронирования вы получаете билет. Это билет, по сути, является обещанием авиакомпании предоставить вам место в самолёте в день, когда вы хотите отправиться в путь. В целом, билет — это местозаполнитель для будущего значения, а именно, для кресла в самолёте.

Вот ещё один пример. Вы пообещали другу, что вернёте ему его книгу «Искусство программирования» после того, как прочтёте её. В данном случае местозаполнитель — это ваши слова. А «будущий результат» — это книга.

В обычной жизни можно найти и другие похожие ситуации. Скажем, ожидание в приёмной врача, заказ еды в ресторане, выдача книги в библиотеке, и многие другие. Все эти ситуации включают в себя некую форму обещания. Пожалуй, мы привели достаточно простых примеров, поэтому займёмся кодом.

Создание промисов


Промисы создают в ситуациях, когда нельзя точно сказать, сколько времени требуется на выполнение некоей операции, или ожидается, что эта операция будет выполняться очень долго. Например — на выполнение сетевого запроса может понадобиться от 10 до 200 мс, что зависит от скорости соединения. Мы не хотим в бездействии ждать получения этих данных. Для человека 200 мс — это крайне мало, но для компьютера это весьма существенный отрезок времени. Промисы упрощают и облегчают решение подобных задач.

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

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 100 <= 90) {
        resolve('Hello, Promises!');
    }
    reject(new Error('In 10% of the cases, I fail. Miserably.'));
});

Обратите внимание на то, что конструктор принимает функцию с двумя параметрами. Эта функция называется исполняющей функцией (executor function), она описывает вычисления, которые необходимо выполнить. Параметры принято называть resolve и reject, они, соответственно, используются для указания на успешное и неуспешное завершение исполняющей функции.

Параметры resolve и reject — это тоже функции, они используются для возврата значений объекту промиса. Если вычисления завершились успешно, или будущее значение готово, мы отправляем это значение с использованием функции resolve. В такой ситуации говорят об успешном разрешении промиса.

Если вычисления выполнить не удалось или в ходе работы возникла ошибка, мы сообщаем об этом, передавая объект ошибки в функции reject. В этом случае говорят о том, что промис отклонён. На самом деле, функция reject принимает любое значение, однако, рекомендовано передавать ей объект Error, так как это помогает в ходе отладки при трассировке стека.

В нашем примере функция Math.random() используется для генерирования случайных чисел. В 90% случаев, исходя из равной вероятности выдачи различных случайных чисел, промис будет разрешён. В остальных случаях он будет отклонён.

Использование промисов


Выше мы создали промис и сохранили ссылку на него в myPromise. Как получить доступ к значениям, передаваемым функциями resolve и reject? В этом нам поможет функция .then(), которая имеется у всех promise-объектов. Взглянем на то, как с ней работать.

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 100 < 90) {
        console.log('resolving the promise ...');
        resolve('Hello, Promises!');
    }
    reject(new Error('In 10% of the cases, I fail. Miserably.'));
});

// Две функции 
const onResolved = (resolvedValue) => console.log(resolvedValue);
const onRejected = (error) => console.log(error);

myPromise.then(onResolved, onRejected);

// То же самое, но тут это записано короче
myPromise.then((resolvedValue) => {
    console.log(resolvedValue);
}, (error) => {
    console.log(error);
});

// Вывод (в 90% случаев)

// resolving the promise ...
// Hello, Promises!
// Hello, Promises!

Метод .then() принимает две функции обратного вызова. Первая вызывается при разрешении промиса. Вторая выполняется в том случае, если промис оказывается отклонённым.

Обратите внимание на две функции, onResolved и onRejected. Они, в роли функций обратного вызова, передаются методу .then(). Можно записать то же самое короче, это показано в том же примере ниже. Возможности такой конструкции не отличаются от возможностей той, где функции были описаны до передачи их .then().

Здесь хотелось бы обратить особое внимание на несколько важных вещей. Мы создали промис myPromise. Затем мы дважды присоединили к нему обработчик .then(). И у того и у другого одинаковый функционал, но воспринимаются они как различные сущности. В этой связи нужно отметить следующее:

  • Промис может разрешиться или оказаться отклонённым лишь один раз. Он не может разрешиться дважды, его нельзя дважды отклонить, а после того, как он разрешён или отклонён, нельзя изменить его состояние на противоположное.
  • Если промис был разрешён или отклонён, а соответствующий коллбэк (то есть, .then()), был добавлен к нему уже после этого события, то, всё равно, будет вызвана правильная функция обратного вызова, хотя промис был разрешён или отклонён до подключения .then().

Всё это означает, что как только промис достигает своего финального состояния, это состояние не меняется (то есть, вычисления не выполняются повторно) даже если подключить к промису несколько обработчиков .then().

Для того чтобы это проверить, можете взглянуть на вызов console.log() в самом начале примера. Когда код запускают и присоединяют к нему два обработчика .then(), вызов console.log() будет выполнен лишь один раз. Это указывает на то, что промис кэширует результат и выдаёт, при подключении ещё одного .then(), то же самое.

Ещё одна важная вещь, на которую надо обратить внимание, заключается в том, что промисы используют стратегию энергичных вычислений. При таком подходе вычисления в промисе начинаются сразу после его объявления и записи ссылки на него в переменную. Тут нет методов наподобие .start() или .begin(), которые можно было бы использовать для принудительного запуска промиса. В предыдущем примере всё происходит именно так.

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

Обработка ошибок в промисах


До сих пор мы, чтобы не усложнять повествование, рассматривали лишь случаи успешного разрешения промисов. Поговорим теперь о том, что происходит, когда в исполняющей функции возникает ошибка. В подобной ситуации вызывается второй коллбэк .then(), то есть, функция onRejected. Рассмотрим пример.

const myPromise = new Promise((resolve, reject) => {
  if (Math.random() * 100 < 90) {
    reject(new Error('The promise was rejected by using reject function.'));
  }
  throw new Error('The promise was rejected by throwing an error');
});

myPromise.then(
  () => console.log('resolved'), 
  (error) => console.log(error.message)
);

// Вывод (в 90% случаев)

// The promise was rejected by using reject function.

Пример это точно такой же, как и предыдущий, с той разницей, что теперь промис будет отклонён с вероятностью в 90% и выдаст ошибку в оставшихся 10% случаев.

Тут объявлены функции обратного вызова onResolved и onRejected. Обратите внимание на то, что коллбэк onRejected будет выполнен даже в том случае, если в ходе выполнения кода промиса будет выброшена ошибка. Нет необходимости явно отклонять промис, передавая объект ошибки функции reject. То есть, промис будет отклонён в обоих случаях.

Так как обработка ошибок — это необходимое условие разработки надёжных программ, для работы с ошибками в промисах предусмотрен специальный механизм. Вместо того чтобы писать нечто вроде .then(null, () => {...}), если надо обрабатывать ошибки, мы можем использовать конструкцию .catch(onRejected), которая принимает один коллбэк — onRejected. Вот как будет выглядеть новый фрагмент вышеприведённого кода при добавлении к нему этого механизма.

myPromise.catch(
  (error) => console.log(error.message)
);

Помните о том, что .catch(), на самом деле, это всего лишь «синтаксический сахар» для .then(undefined, onRejected).

Объединение промисов в цепочки


Методы .then() и .catch() всегда возвращают промисы. Поэтому можно объединять множество вызовов .then() в цепочки. Разберём это на примере.

Для начала создадим функцию delay(), которая возвращает промис. Возвращённый промис разрешится через заданное время. Вот как выглядит эта функция.

const delay = (ms) => new Promise(
  (resolve) => setTimeout(resolve, ms)
);

В данном примере мы используем функцию для того, чтобы обернуть в неё промис, в результате чего промис не будет выполнен немедленно. Функция delay() принимает, в качестве параметра, время, выраженное в миллисекундах. Исполняющая функция имеет доступ к параметру ms благодаря замыканию. Здесь, кроме того, содержится вызов setTimeout(), который вызовет функцию resolved после того, как пройдёт заданное число миллисекунд, что приводит к разрешению промиса. Вот как пользоваться этой функцией.

delay(5000).then(() => console.log('Resolved after 5 seconds'));

А вот как объединять несколько вызовов .then() в цепочку.

const delay = (ms) => new Promise(
  (resolve) => setTimeout(resolve, ms)
);

delay(2000)
  .then(() => {
    console.log('Resolved after 2 seconds')
    return delay(1500);
  })
  .then(() => {
    console.log('Resolved after 1.5 seconds');
    return delay(3000);
  }).then(() => {
    console.log('Resolved after 3 seconds');
    throw new Error();
  }).catch(() => {
    console.log('Caught an error.');
  }).then(() => {
    console.log('Done.');
  });

// Resolved after 2 seconds
// Resolved after 1.5 seconds
// Resolved after 3 seconds
// Caught an error.
// Done.

Этот код начинает работу со строки, где производится вызов функции delay. Затем здесь происходит следующее:

  • Функция delay(2000) возвращает промис, который разрешается через 2 секунды.

  • Затем выполняется первый блок .then(). Он пишет в лог строку Resolved after 2 seconds. Затем он возвращает ещё один промис, вызывая delay(1500). Если .then() возвращает промис, разрешение (технически называемое settlement) этого промиса передаётся следующему вызову .then().
  • Этот процесс продолжается до тех пор, пока не закончится цепочка.

Кроме того, обратите внимание на фрагмент кода, где мы выполняем команду throw new Error(), то есть — выбрасываем ошибку в .then(). Это означает, что текущий промис будет отклонён, и будет вызван следующий обработчик .catch(). В результате в лог выводится строка Caught an error. Именно поэтому дальше вызывается блок .then(), идущий за .catch().

Рекомендовано, для обработки ошибок, использовать .catch(), а не .then() с параметрами onResolved и onRejected. Вот код, который разъясняет данную рекомендацию.

const promiseThatResolves = () => new Promise((resolve, reject) => {
  resolve();
});

// Ведёт к UnhandledPromiseRejection
promiseThatResolves().then(
  () => { throw new Error },
  (err) => console.log(err),
);

// Правильная обработка ошибок
promiseThatResolves()
  .then(() => {
    throw new Error();
  })
  .catch(err => console.log(err));

В самом начале этого примера мы создаём промис, который всегда разрешается. Когда имеется .then() с двумя коллбэками, onResolved и onRejected, можно обрабатывать ошибки и ситуации, в которых промис оказывается отклонённым, только для исполняющей функции. Предположим, что обработчик в .then() тоже выбрасывает ошибку. Это, как можно видеть из кода, не приведёт к вызову коллбэка onRejected.

Однако, если после .then() имеется блок .catch(), этот блок будет перехватывать и ошибки исполняющей функции и ошибки .then(). Это имеет смысл, так как .then() всегда возвращает промис.

Итоги


Вы можете самостоятельно выполнить все примеры, что позволит вам, через практику, лучше освоить то, о чём шла речь в этом материале. Для того чтобы изучить промисы, можно потренироваться в реализации функций, основанных на коллбэках, в виде промисов. Если вы работаете в Node.js, обратите внимание на то, что множество функций в fs и в других модулях основаны на коллбэках. Существуют утилиты, которые позволяют автоматически конвертировать такие функции, в конструкции, основанные на промисах. Скажем, это util.promisify из Node.js, и pify.

Однако, если вы только изучаете всё это, рекомендовано придерживаться принципа WET (Write Everything Twice, пишите всё по два раза) и реализовывать самостоятельно (или, по крайней мере, внимательно читать) как можно больший объём кода изучаемых библиотек. В других случаях, особенно, если вы пишете код, который попадёт в продакшн, придерживайтесь принципа DRY (Don’t Repeat Yourself, не повторяйтесь). В том, что касается работы с промисами, есть ещё много такого, что не попало в этот материал. Например, это статические методы Promise.all, Promise.race, и другие. Кроме того, здесь очень кратко освещена обработка ошибок. Существуют широко известные анти-паттерны и тонкости, о которых стоит знать, работая с промисами. Вот несколько материалов, на которые полезно будет взглянуть тем, кому всё это интересно: спецификация ECMA, материалы Mozilla Docs, руководство Google по промисам, глава книги Exploring JS, посвящённая промисам, полезная статья по основам промисов.

Уважаемые читатели! Как вы пишете асинхронный код на JavaScript?

Комментарии (16)


  1. AstarothAst
    18.05.2018 11:31

    Сколько лет прошло, а промисы все еще настолько интуитивны, что в них по-прежнему надо разбираться… Эх.


    1. EvilGenius18
      18.05.2018 13:11
      -1

      Это потому, что все примеры показываются с использованием delay(). Новички не знают как это применить потом. Показали бы как нужно промисы писать на реальных примерах.

      Нет… зачем? Новички же и так с легкостью могут экстраполировать delay() до реального кода, выполняющего какую-либо обработку данных. В итоге, как обычно, получаем следующее:

      Как нарисовать сову


  1. vvadzim
    18.05.2018 13:29
    +1

    Мало где встречаю следующий паттерн: в конструкторе промиса
    new Promise((resolve, reject) => {...})
    вызов reject(someError) эквивалентен вызову resolve(Promise.reject(someError)).

    В самом конструкторе это обычно не нужно — и так есть непосредственный доступ и к resolve и к reject. Но иногда нужно промис вернуть сейчас, а resolve отослать в другое место, чтобы там когда-нибудь его вызвали. Так вот чтобы иметь возможность записать и результат и ошибку, часто передают объект {resolve, reject}. Это обычно усложняет логику, и это не необходимо, поскольку ошибку всегда можно записать в resolve:

    resolve(Promise.reject(someError))


  1. lleo_aha
    18.05.2018 13:45

    И никто никогда не пишет что будет если .catch сунуть в середину цепочки или сделать несколько таких вызовов


    1. aleksand44
      18.05.2018 15:37

      .catch в середине цепочки сможет обрабатывать ошибки, которые произошли до нее.
      несколько вызовов .catch в цепочке будут по цепочке обрабатывать объект ошибки, по аналогии с then


      1. lleo_aha
        18.05.2018 15:47

        Спасибо! Ещё вопрос, вот в такой цепочке .then().catch().then() последний что нибудь получит вообще если ошибок никаких не было?


        1. vvadzim
          18.05.2018 16:26
          +2

          получит, если в обработчике catch ошибки не было


          1. niksite
            18.05.2018 17:11

            В любом случае будет вызван.
            Вторым then обычно finally эмулировали, пока finally в стандарт не добавили.


            1. vvadzim
              18.05.2018 17:15
              +1

              Promise.resolve().then(()=>{throw 1}).catch(()=>{throw 2}).then(()=>console.log(3))
              В данном случае console.log вызван не будет, поскольку в catch выброшена ошибка.


              1. niksite
                18.05.2018 17:38

                Да, вы правы.


                Поэтому мы теперь имеем полноценный finally вместо не всегда работающего второго then.


                1. xitt
                  18.05.2018 22:19

                  Promise.resolve().then(()=>{throw 1}).catch(()=>{return Promise.reject(2)}).then(()=>console.log(3)) Ну здесь нет ошибки в catch.


          1. Gennadii_M
            21.05.2018 08:23

            Получит, undefined, если catch ничего явно не вернул.


  1. lovespy
    18.05.2018 18:20
    +1

    Для меня с промисами есть несколько вопросов:
    1) поддержка — приходится тащить за собой полифилы
    2) несовсем всегда понятно когда надо возвращать промис (передаем джейсон респонс например, все эти === «0» а где-то ===0 привет ИЕ11)
    3) сложность в отлавливании ошибок — через выброса эррора, но надо знать проперти нейм в случае с фетч например это .ok

    то есть очень хорошо, но прям так и хочется по старинке лапшой с коллбэками


  1. xitt
    20.05.2018 15:33

    Часто вижу как пишут «немедленно исполняется». И часто вижу как кандидаты на интервью не могут правильно ответить на вопрос, что выведет данный код:

    let a = 1; Promise.resolve().then(()=>console.log('a=', a)); a = 2;

    Кстати для разнообразия можно попробовать реализацию jQuery 2.x Deferred, поведение которого было поправлено в 3.x соответсвенно промисам в JS.


  1. netch80
    20.05.2018 21:55

    Почему интерфейс коллбэка исходной функции — в конструкторе Promise — и добавляемых по then такой разный?
    В исходной есть методы resolve и reject, и мы обязаны их вызвать (иначе промис останется pending).
    В вызываемых по then надо или возвращать значение, или генерировать исключение. Коллбэков в аргументах нет (практика не подтверждает).

    Разумеется, можно передать во then() функцию, которая сгенерирует новый промис и воспользуется переданными коллбэками. Это может быть важно для связи с некоторыми модулями, например (кусок тестового кода, прошу игнорировать стиль и т.п.)

    var promise2 = new Promise(
        function(resolve, reject) {
          var req = http.request({hostname: 'qm2a.tr2.local', port: 7380, path: '/',
                'method': 'GET'},
              function (res) {
                  console.log(`STATUS: ${res.statusCode}`);
                  var data = '';
                  res.on('data', (chunk) => { data = data + chunk; });
                  res.on('end', () => { resolve(data); }); // тут зову из откуда-то ещё далее асинхронного...
             });
          req.end();
      });
    


    но это кажется некузявым — создавать дополнительный объект ради странностей интерфейса…

    Я бы понял стандартный набор аргументов таких коллбэков — input_value (даже для исходного), resolve_cb, reject_cb.

    Думаю, авторы стандарта на промисы тоже хотели универсальности. Так что им помешало?


    1. lovespy
      21.05.2018 08:10

      Очень хочется добавить что например СПА приложения не терпят промис плюс фетч — электрон приложения тоже — приходится по старинке иксэйчаром подгружать контент. Возможно я некстати это сказал — простго хочется писать все с новыми апи ан нет. про выброс эксепшнов можно пример? Я не подкалываю — хотелось бы сравнить так же я делаю. try {
      var navigationJsonObj = JSON.parse(navigationJsonResponse);
      if (!navigationJsonObj.navbar_popular) {
      throw new Error("incomplete JSON data: no navbar_popular");
      } else if (!navigationJsonObj.navbar_more) {
      throw new Error("incomplete JSON data: no navbar_more");
      } else {
      if (!navigationJsonObj.b_carousel) {
      throw new Error("incomplete JSON data: no b_carousel");
      }
      }
      navigationJsonObj = null;
      } catch (err) {
      console.log("cannot init processNavigationJsonResponse", err);
      return;
      }