Хабрахабр

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

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

Роберт Фрост

image

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

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

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

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

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

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

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

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

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

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

const myPromise = new Promise((resolve, reject) => reject(new Error('In 10% of the cases, I fail. Miserably.'));
});

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

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

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

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

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

Выше мы создали промис и сохранили ссылку на него в 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() принимает две функции обратного вызова. Первая вызывается при разрешении промиса. Вторая выполняется в том случае, если промис оказывается отклонённым.

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

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

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

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

Когда код запускают и присоединяют к нему два обработчика .then(), вызов console.log() будет выполнен лишь один раз. Для того чтобы это проверить, можете взглянуть на вызов 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% случаев.

Обратите внимание на то, что коллбэк onRejected будет выполнен даже в том случае, если в ходе выполнения кода промиса будет выброшена ошибка. Тут объявлены функции обратного вызова onResolved и 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() всегда возвращает промис. Однако, если после .then() имеется блок .catch(), этот блок будет перехватывать и ошибки исполняющей функции и ошибки .then().

Итоги

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть