https://habr.com/company/ruvds/blog/358808/- Разработка веб-сайтов
- JavaScript
- Блог компании RUVDS.com
Лес чуден, тёмен — глянь в глубину.
Но прежде я все долги верну…
И много миль, пока я усну,
Так много миль, пока я усну...
Роберт Фрост
Промисы — это одно из самых замечательных новшеств 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?
