Лес чуден, тёмен — глянь в глубину.
Но прежде я все долги верну…
И много миль, пока я усну,
Так много миль, пока я усну...
Роберт Фрост
Промисы — это одно из самых замечательных новшеств 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