[Перевод] Элегантное асинхронное программирование с помощью «промисов»

?v=1

Доброго времени суток, друзья!

Представляю вашему вниманию перевод статьи «Graceful asynchronous programming with Promises» с MDN.

«Обещания» (промисы, promises) — сравнительно новая особенность JavaScript, которая позволяет откладывать выполнение действия до завершения выполнения предыдущего действия или реагировать на неудачное выполнение действия. Это способствует правильному определению последовательности выполнения асинхронных операций. В данной статье рассматривается, как работают обещания, как они используются в Web API, и как можно написать собственное обещание.

Условия: базовая компьютерная грамотность, знание основ JS.
Задача: понять, что такое обещания и как они используются.

Что такое обещания?


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

По сути, обещание — это объект, представляющий промежуточное состояние операции — он «обещает», что результат будет возвращен в будущем. Неизвестно, когда именно операция будет завершена и когда будет возвращен результат, но существует гарантия того, что когда операция завершится, ваш код либо что-то сделает с результатом, либо изящно обработает ошибку.

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

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

Обработчик кнопки вызывает getUserMedia (), чтобы получить доступ к камере и микрофону пользователя. С того момента, как getUserMedia () обращается к пользователю за разрешением (использовать устройства, какое из устройств использовать, если у пользователя несколько микрофонов или камер, только голосовой вызов, среди прочего), getUserMedia () ожидает не только решения пользователя, но и освобождения устройств, если они в настоящее время используются. Кроме того, пользователь может ответить не сразу. Все это может привести к большим задержкам во времени.

Если запрос на разрешение использования устройств делается из основного потока, браузер блокируется до завершения выполнения getUserMedia (). Это недопустимо. Без обещаний всё в браузере становится «некликабельным» до тех пор, пока пользователь не даст разрешения на использование микрофона и камеры. Поэтому вместо того, чтобы ждать решения пользователя и возвращать MediaStream для потока, созданного из источников (камеры и микрофона), getUserMedia () возвращает обещание, которое обрабатывает MediaStream, как только он становится доступным.

Код приложения-видеочата может выглядеть так:

function handle CallButton(evt){
    setStatusMessage('Calling...')
    navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then(chatStream => {
        selfViewElem.srcObject = chatStream
        chatStream.getTracks().forEach(track => myPeerConnection.addTrack(track, chatStream))
        setStatusMessage('Connected')
    }).catch(err => {
        setStatusMessage('Failed to connect')
    })
}


Функция начинается с вызова setStatusMessage (), отображающей сообщение 'Calling…', которое служит индикатором того, что предпринимается попытка вызова. Затем вызывается getUserMedia (), запрашивающая поток, который содержит видео и аудио дорожки. Как только поток сформирован, устанавливается видео элемент для отображения потока из камеры, именуемого 'self view', аудио дорожки добавляются в WebRTCRTCPeerConnection, представляющий собой подключение к другому пользователю. После этого статус обновляется до 'Connected'.

Если getUserMedia () завершается неудачно, запускается блок catch. Он использует setStatusMessage () для отображения сообщения об ошибке.

Обратите внимание, что вызов getUserMedia () возвращается, даже если видео поток еще не получен. Даже если функция handleCallButton () вернула управление вызвавшему ее коду, как только getUserMedia () завершит выполнение, она вызовет обработчик. До тех пор, пока приложение не «поймет», что вещание началось, getUserMedia () будет находится в режиме ожидания.

Примечание: вы можете узнать об этом больше в статье «Сигналы и видео вызовы». В этой статье приводится более полный код, чем тот, который мы использовали в примере.

Проблема функций обратного вызова


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

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

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


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

chooseToppings(function(toppings){
    placeOrder(toppings, function(order){
        collectOrder(order, function(pizza){
            eatPizza(pizza)
        }, failureCallback)
    }, failureCallback)
}, failureCallback)


Такой код тяжело читать и поддерживать (его часто называют «адом функций обратного вызова» или «адом коллбэков»). Функцию failureCallback () приходится вызывать на каждом уровне вложенности. Существуют и другие проблемы.

Используем обещания


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

chooseToppings()
.then(function(toppings){
    return placeOrder(toppings)
})
.then(function(order){
    return collectOrder(order)
})
.then(function(pizza){
    eatPizza(pizza)
})
.catch(failureCallback)


Так намного лучше — мы видим, что происходит, мы используем один блок .catch () для обработки всех ошибок, функция не блокирует основной поток (поэтому мы можем играть в видеоигры в ожидании пиццы), каждая операция гарантированно выполняется после завершения предыдущей. Поскольку в каждом обещании возвращается обещание мы можем использовать цепочку из .then. Здорово, правда?

Используя стрелочные функции, псевдокод можно еще больше упростить:

chooseToppings()
.then(toppings =>
    placeOrder(toppings)
)
.then(order =>
    collectOrder(order)
)
.then(pizza =>
    eatPizza(pizza)     
)
.catch(failureCallback)


Или даже так:

chooseToppings()
.then(toppings => placeOrder(toppings))
.then(order => collectOrder(order))
.then(pizza => eatPizza(pizza))
.catch(failureCallback)


Это работает, потому что () => x идентично () => { return x }.

Можно даже сделать так (поскольку функции просто передают параметры, нам не нужна многослойность):

chooseToppings().then(placeOrder).then(collectOrder).then(eatPizza).catch(failureCallback)


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

Примечание: псевдокод можно еще улучшить, используя async/await, о котором будет рассказываться в следующей статье.

В своей основе обещания похожи на «прослушиватели» событий, но с некоторыми отличиями:

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


Базовый синтаксис обещания: реальный пример


Знать обещания важно, поскольку многие Web API используют их в функциях, выполняющих потенциально сложные задачи. Работа с современными веб технологиями предполагает использование обещаний. Позже мы научимся писать собственные обещания, а пока рассмотрим несколько простых примеров, которые можно встретить в Web API.

В первом примере мы используем метод fetch () для получения изображения из сети, метод blob () для преобразования содержимого тела ответа в объект Blob и отобразим этот объект внутри элемента . Данный пример очень похож на пример из первой статьи, но мы сделаем его немного по-другому.

Примечание: следующий пример не будет работать, если вы просто запустите его из файла (т.е. с помощью file://URL). Запускать его нужно через локальный сервер или с помощью онлайн-решений, таких как Glitch или GitHub pages.

1. Прежде всего, загрузите HTML и изображение, которое мы будем получать.

2. Добавьте элемент .

2. Загрузите исходные файлы (coffee.jpg, tea.jpg и description.txt) или замените их своими.

3. В скрипте мы сначала определим функцию, возвращающую обещания, которые мы передадим Promise.all (). Это легко будет сделать, если мы запустим Promise.all () после завершения трех операций fetch (). Мы можем сделать следующее:

let a = fetch(url1)
let b = fetch(url2)
let c = fetch(url3)

Promise.all([a, b, c]).then(values => {
    ...
})


Когда обещание выполнится, переменная «values» будет содержать три объекта Response, по одному от каждой завершенной операции fetch ().

Однако мы этого не хотим. Для нас не имеет значения, когда операции fetch () завершатся. То, чего мы хотим на самом деле, это загруженные данные. Это означает, что мы хотим запустить блок Promise.all () после получения пригодных blob, представляющих изображения, и пригодных текстовых строк. Мы можем написать функцию, которая это делает; добавьте следующее в ваш элемент