Руководство по промисам для тех, кто хочет в них разобраться

в 8:23, , рубрики: ES6, javascript, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

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

Роберт Фрост

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?

Автор: ru_vds

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js