Асинхронное программирование с промисами JavaScript

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

“Управление сложностью  —  квинтэссенция программирования”,  —  Брайн Керниган. 

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

В данной статье рассмотрим понятие промисов и их назначение. Кроме того, выясним, почему так важно связывать промисы в цепочки и как метод Promise.all() помогает объединять несколько промисов в один. 

Вперед за знаниями! 

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

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

Не следует их путать с async/await

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

const DEFAULT_TIMEOUT = 500;

function getMovieTicket(movie,fulfillMovieTicket,rejectMovieTicket){
setTimeout(() => {
if(movie.payment >= movie.ticket_price){
fulfillMovieTicket(`Success! Payment has been processed.`);
}else{
rejectMovieTicket(`Error: Payment less than ticket price.`);
}
},300)
}

function selectMovie(selection,selectedMovie,rejectMovieSelection){
setTimeout(() => {
if(selection.time_taken <= DEFAULT_TIMEOUT){
selectedMovie(selection.title)
}else{
rejectMovieSelection(`Your session has expired.`)
}
},DEFAULT_TIMEOUT)
}

const movie_obj = {
payment: 21.25,
ticket_price: 21.00,
title: 'Adventures of Pickle Rick',
time_taken: 200
}

selectMovie(movie_obj,(movie) => {
console.log(`Movie selected: ${movie}`)
getMovieTicket(movie_obj,(response) => {
console.log(response);
},(err) => {
console.error(err);
})
},(err) => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.

Перед вами пример киоска по продаже билетов в кино, который использует обратные вызовы для обработки асинхронного кода. И selectMovie(), и getMovieTicket() передают 2 обратных вызова в качестве аргументов. Первая функция вызывается в случае успешной транзакции, а вторая  —  неудачной. 

Поскольку функции selectMovie() и getMovieTicket() являются асинхронными, обратные вызовы используются, чтобы отложить выполнение других строк кода до момента завершения процесса. 

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

Перепишем вышеуказанный пример с помощью промисов и посмотрим, что изменилось: 

const DEFAULT_TIMEOUT = 500;

const getMovieTicket = (movie) =>
new Promise((resolve,reject) => {
setTimeout(() => {
if(movie.payment >= movie.ticket_price){
resolve(`Success! Payment has been processed.`);
}else{
reject(`Error: Payment less than ticket price.`);
}
},300)
});

const selectMovie = (selection) =>
new Promise((resolve,reject) => {
setTimeout(() => {
if(selection.time_taken <= DEFAULT_TIMEOUT){
resolve(selection.title)
}else{
reject(`Your session has expired.`)
}
},DEFAULT_TIMEOUT)
});

const movie_obj = {
payment: 21.25,
ticket_price: 21.00,
title: 'Adventures of Pickle Rick',
time_taken: 200
}

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
getMovieTicket(movie_obj).then(response => {
console.log(response);
}).catch(err => {
console.error(err);
})
}).catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.

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

Важно отметить, что промисы запускаются синхронно и становятся асинхронными только внутри методов then() и catch()

При разделении двух асинхронных методов изменится вывод. Дело в том, что ни один из промисов не ждет выполнения другого, поскольку теперь они независимы друг от друга. В этом и заключается разница между промисами и async/await в JavaScript.

getMovieTicket(movie_obj).then(response => {
console.log(response);
}).catch(err => {
console.error(err);
})

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
}).catch(err => {
console.error(err);
})

// Вывод: успешно! Оплата прошла.
// Фильм выбран — "Приключения огурчика Рика"

Рассмотрим подробнее, как работают эти два метода. 

Promise.then()

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

Promise.catch()

Аналогично then() метод catch() также возвращает промис, но только в случае его отклонения. Возможно, он вам известен по опыту использования блока try-catch. Если промис отклоняется, catch() вызывает функцию, которая возвращает причину возникшей ошибки. 

Посмотрим на Promise.catch() в действии: 

const movie_obj = {
payment: 19.00,
ticket_price: 21.00,
title: 'Adventures of Pickle Rick',
time_taken: 200
}

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
getMovieTicket(movie_obj).then(response => {
console.log(response);
}).catch(err => {
console.error(err);
})
}).catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Ошибка: оплата меньше стоимости билета.

Поскольку оплата меньше стоимости билета, причина отклонения промиса передается в обратный вызов rejected(), что приводит к вышеуказанному результату. 

А что если нужно вывести квитанцию по факту совершенной оплаты? 

Посмотрим, как это сделать посредством объединения промисов в цепочки. 

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

Суть процедуры проста. Если нужно записать в лог квитанцию о совершении оплаты, просто прикрепляем один метод then() к концу предыдущего. 

Рассмотрим код ниже и посмотрим, как это сработает: 

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
getMovieTicket(movie_obj).then(response => {
console.log(response);
return response
}).then(details => {
const receipt = 'Keep this as your receipt | ' + details
console.log(receipt);
})
.catch(err => {
console.error(err);
})
}).catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.
// Сохраните квитанцию об оплате. Успешно! Оплата прошла.

Для корректной работы кода необходимо убедиться, что мы возвращаем значение из первого then(), иначе второй метод вернет undefined. Вы можете связывать друг с другом желаемое число методов. 

Ограничений нет! 

Хотя данный код работает корректно, в реальных ситуациях вывод квитанции тоже будет асинхронным действием. В связи с этим следует поместить его в отдельный промис следующим образом: 

const printReceipt = (payment_details, status) =>
new Promise((resolve,reject) => {
setTimeout(() => {
if(status === 'OK'){
resolve('Keep this as your receipt | ' + payment_details)
}else{
reject('Error: The printer has encountered a jam.')
}
},300)
})

В данном фрагменте кода мы проверяем, завершает ли принтер работу в состоянии OK перед выполнением промиса. Если он сталкивается с ошибкой, отправляем отклоненный промис и записываем ошибку в консоль. 

Если добавить данный промис к остальным, получим вот такой результат: 

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
getMovieTicket(movie_obj).then(response => {
console.log(response);
printReceipt(response,'OK').then(receipt => {
console.log(receipt);
}).catch(err => {
console.error(err);
})
})
.catch(err => {
console.error(err);
})
}).catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.
// Сохраните квитанцию об оплате | Успешно! Оплата прошла.

С точки зрения читаемости данный вариант выглядит хуже. 

Для подобной ситуации есть особое название  —  “ад обратных вызовов”. Это происходит, когда несколько промисов выполняются друг за другом, чтобы завершить процесс асинхронной обработки. 

Исправить это можно путем объединения промисов в цепочки! 

Именно такое решение помогает эффективно справиться с подобными ситуациями. Убедимся на следующем примере: 

selectMovie(movie_obj).then(movie => {
console.log(`Movie selected: ${movie}`)
return getMovieTicket(movie_obj);
})
.then(response => {
console.log(response);
return printReceipt(response,'OK')
})
.then(receipt => {
console.log(receipt)
})
.catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.
// Сохраните квитанцию об оплате | Успешно! Оплата прошла.

Так намного лучше! 

Мы не только сократили число строк кода, но вдобавок сделали его более читаемым.

Стоило только разложить все по полочкам, как сложное стало простым. После выполнения каждого промиса мы возвращаем следующий промис последовательности. Этот процесс продолжается до момента завершения всех действий. 

На данный момент мы не только привели в порядок код, но и попутно решили пару задач. Однако есть еще один метод, позволяющий произвести дальнейшие сокращения. 

Случаи использования Promise.all() 

Как вы могли заметить в вышеприведенных примерах, selectMovie() и getMovieTicket() не зависят от результата другого промиса. Они отлично подходят для случаев с методом Promise.all()

Что такое Promise.all()? 

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

Следующий пример поможет понять действие метода Promise.all():

const promise1 = () => 
new Promise((resolve,reject) => {
setTimeout(() => {
resolve('Promise 1 checking in.')
},200)
})

const promise2 = () =>
new Promise((resolve,reject) => {
setTimeout(() => {
resolve('Promise 2 checking in.')
},400)
})

const promise3 = () =>
new Promise((resolve,reject) => {
setTimeout(() => {
resolve('Promise 1 checking in.')
},100)
})

Promise.all([promise1(),promise2(),promise3()])
.then(([p1,p2,p3]) => {
console.log(`${p1}\n${p2}\n${p3}`)
})

// Вывод: промис 1 записывается.
// Промис 2 записывается.
// Промис 3 записывается.

Promise.all() принимает на вход все 3 промиса. Как только они выполняются, проводится деструктуризация полученного массива значений и результат записывается в лог. 

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

Promise.all([selectMovie(movie_obj),getMovieTicket(movie_obj)])
.then(([movie,response]) => {
console.log(`Movie selected: ${movie}`)
console.log(response);
return printReceipt(response,'OK')
})
.then(receipt => {
console.log(receipt)
})
.catch(err => {
console.error(err);
})

// Вывод: фильм выбран — "Приключения огурчика Рика"
// Успешно! Оплата прошла.
// Сохраните квитанцию об оплате | Успешно! Оплата прошла.

Отлично!

Метод Promise.all() позволил почистить код и сократить его на несколько строк. Помимо этого, мы также можем задействовать его в цепочках. Очень удобно. 

Promise.all() ожидает осуществления выбора и оплаты перед тем, как вывести квитанцию. Возникает вопрос: “А почему бы не вбросить туда за компанию и третий промис?”. Дело в том, что действие с квитанцией зависит от результата оплаты, поэтому необходимо сначала подождать выполнения этого промиса, а затем переходить к следующему этапу. Следовательно, добавляем промис квитанции к результату Promise.all().

Теперь, ознакомившись с материалом статьи, вы сможете освоить работу с асинхронным кодом с помощью промисов JavaScript. 

Заключение

Подведем краткие итоги.

Промисы JavaScript помогают создавать чистый и читаемый код, а также решать такие распространенные задачи, как ад обратных вызовов. 

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

Метод Promise.all() объединяет несколько промисов в один и еще больше оптимизирует код. Его также можно добавлять в существующий код, задействующий цепочки. 

Роль промисов в обработке асинхронного кода  —  одна из интереснейших тем для обсуждения. Одно удовольствие работать с ними каждый день. 

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Michael Miller: Mastering Asynchronous Programming With JavaScript Promises

Предыдущая статьяPython/C API  -  ускорение Python при помощи кода на C
Следующая статьяБлокчейн и искусственный интеллект - мощный тандем