JavaScript: Что такое Промисы / Promise

Источник: «An Overview of JavaScript Promises»
Разберёмся как создавать промисы и работать с ними в JavaScript. Рассмотрим цепочки промисов, обработку ошибок и некоторые из новых методов промисов, добавленных в язык.

Что такое Промисы/Promise в JavaScript

В JavaScript некоторые операции асинхронны. Это означает, что результат или значение, которые они производят, не доступны сразу после завершения операции.

Промис/Promise — специальный объект JavaScript предоставляющий конечный результат такой асинхронной операции. Он действует как прокси для результата операции.

Старые недобрые времена: функции обратного вызова

До того как появились JavaScript промисы, предпочтительным способом работы с асинхронной операцией было использование обратного вызова. Обратный вызов или Callback — это функция, запускающаяся, когда готов результат асинхронной операции. Например:

setTimeout(function() {
console.log('Hello, World!');
}, 1000);

setTimeout — асинхронная функция запускающая любую переданную ей функцию обратного вызова через указанное количество миллисекунд. В данном случае Hello, World! выведется в консоль через одну секунду.

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

setTimeout(function() {
console.log(1);
setTimeout(function() {
console.log(2);
setTimeout(function() {
console.log(3);
setTimeout(function() {
console.log(4);
setTimeout(function() {
console.log(5);
}, 1000);
}, 1000);
}, 1000);
}, 1000);
}, 1000);

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

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

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

Как создать объект Promise в JavaScript

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

const promise = new Promise((resolve, reject) => {
//здесь идёт асинхронный код
});

Начинаем с создания экземпляра нового объекта Promise с помощью конструктора Promise() и передачи ему функции обратного вызова. Обратный вызов принимает два аргумента, resolve и reject, являющиеся функциями. Весь асинхронный код находится внутри этого вызова.

Если всё выполняется успешно, промис fulfilled и выполняется вызов resolve. В случае ошибки промис отклоняется вызовом reject. Мы можем передавать значение обоим параметрам, которые затем будут доступны в коде.

Для демонстрации как это работает на практике, рассмотрим следующий код. Он делает асинхронный запрос к веб-сервису возвращающему случайные шутки в формате JSON:

const promise = new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('GET', 'https://icanhazdadjoke.com/');
request.setRequestHeader('Accept', 'application/json');

request.onload = () => {
if (request.status === 200) {
resolve(request.response); // у нас есть данные, так что resolve Promise
} else {
reject(Error(request.statusText)); // статус не 200 OK, так что reject
}
};

request.onerror = () => {
reject(Error('Error fetching data.')); // произошла ошибка, reject Promise
};

request.send(); // посылаем запрос
});

https://icanhazdadjoke.com/ ограничили доступ для некоторых стран, поэтому примеры могут не работать. Попробуйте прокси или VPN.

Конструктор Promise

Создаём объект promise с использованием конструктора Promise. Конструктор используется для оборачивания функции или API, которые не поддерживают промисы, например объект XMLHttpRequest. Обратный вызов, переданный конструктору промиса содержит асинхронный код, используемый для получения данных из удалённой службы. Обратите внимание, что используется стрелочная функция. Внутри обратного вызова создаём Ajax запрос к https://icanhazdadjoke.com/, который возвращает случайную шутку в формате JSON.

Когда от удалённого сервера получен успешный ответ, он передаётся методу resolve. В случае возникновения какой-либо ошибки — либо на сервере, либо на сетевом уровне — вызывается reject с объектом Error.

Метод then

Когда мы создаём объект promise, то получаем прокси для данных, которые будут доступны в будущем. В нашем случае мы ожидаем, что некие данные будут возвращены удалённой службой. Итак, как мы узнаем, когда данные будут доступны? Для этого используется функция Promise.then():

const promise = new Promise((resolve, reject) => { ... });

promise.then((data) => {
console.log('Got data! Promise fulfilled.');
document.body.textContent = JSON.parse(data).joke;
}, (error) => {
console.error('Promise rejected.');
console.error(error.message);
});

Эта функция может принимать два аргумента: обратный вызов при успешном выполнении resolve и обратный вызов при отказе reject. Эти обратные вызовы выполняются когда промис завершён (то есть либо выполнен resolve, либо отклонён reject). Если промис был выполнен, обратный вызов resolve будет выполнен с фактическими данными полученными из удалённого сервиса. Если промис был отклонён, то будет вызван обратный вызов reject. Всё, что мы передали для отказа, будет передано в качестве аргумента этому обратному вызову.

Можете попробовать этот код в демо на CodePen. Чтобы посмотреть новую случайную шутку, нажмите кнопку RERUN в правом нижнем углу окна. Может не работать без прокси или VPN из некоторых стран

See the Pen

Какие состояния бывают у промиса в JavaScript

В приведённом выше коде мы увидели, что можем изменить состояние промиса, вызвав методы resolve или reject. Прежде чем пойдём дальше, давайте рассмотрим жизненный цикл промиса.

Промис может быть в одном из этих состояний:

Промис начинает жизненный цикл с состояний pending. Это означает, что он не был ни fulfilled, ни rejected. Если действие связанное с промисом успешно выполнено (в нашем случае удалённый вызов API) и вызывается метод resolve, считается, что промис fulfilled — выполнен. Наконец, считается, что промис settled — выполнен, если он находится в состоянии fulfilled или rejected, но не pending.

Состояния промиса

Как только промис отклоняется rejected или выполняется fulfilled, этот статус навсегда ассоциируется с ним. Это означает, что промис может быть успешным или проваленным только один раз. Если промис уже был fulfilled, а позже мы присоединяем к нему .then() с двумя обратными вызовами, успешный обратный вызов будет корректно вызван. Итак, в мире промисов нам не интересно, когда промис выполнился. Нас интересует только окончательный результат промиса.

Но разве мы не должны использовать Fetch API

Сейчас мы можем спросить, почему не используем Fetch API для получения данных с удалённого сервера, и ответ заключается в том, что, вероятно, нам следует это сделать.

В отличие от объекта XMLHttpRequest, Fetch API основан на промисах, это означает, что мы можем переписать наш код следующим образом (минус обработка ошибок):

fetch('https://icanhazdadjoke.com', {
headers: { 'Accept': 'application/json' }
})
.then(res => res.json())
.then(json => console.log(json.joke));

Причина использования XMLHttpRequest заключалась в том, чтобы лучше понять, что происходит под капотом.

Цепочки Промисов

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

Мы могли бы начать с создания нового объекта промиса, как мы делали это ранее:

const promise = new Promise((resolve, reject) => {
setTimeout(() => { resolve() }, 1000)
});

promise.then(() => {
console.log(1);
});

Как и ожидалось, промис завершается через одну секунду и выводит в консоль 1.

Для продолжения цепочки необходимо вернуть второй промис после вывода в консоль и передать его второму then:

const promise = new Promise((resolve, reject) => {
setTimeout(() => { resolve() }, 1000)
});

promise.then(() => {
console.log(1);
return new Promise((resolve, reject) => {
setTimeout(() => { resolve() }, 1000)
});
}).then(() => {
console.log(2);
});

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

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

Теперь мы можем использовать этот фрагмент для сглаживания вложенного кода:

sleep(1000)
.then(() => {
console.log(1);
return sleep(1000);
}).then(() => {
console.log(2);
return sleep(1000);
}).then(() => {
console.log(3);
return sleep(1000);
})
...

А поскольку сам метод then возвращает объект промис, и мы не передаём никаких значений от одной асинхронной операции к другой, это позволяет нам ещё больше упростить ситуацию:

sleep(1000)
.then(() => console.log(1))
.then(() => sleep(1000))
.then(() => console.log(2))
.then(() => sleep(1000))
.then(() => console.log(3))
...

Этот код намного элегантнее, чем первоначальный вариант.

Обратите внимание, что если вы хотите больше узнать о реализации sleep() в JavaScript, возможно вам будет интересна статья: JavaScript: Delay, Sleep, Pause, & Wait

Передача данных по цепочке промисов

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

Например, нам может понадобиться получить список участников репозитория GitHub, а затем использовать эту информацию для получения имени первого участника:

fetch('https://api.github.com/repos/eslint/eslint/contributors')
.then(res => res.json())
.then(json => {
const firstContributor = json[0].login;
return fetch(`https://api.github.com/users/${firstContributor}`)
})
.then(res => res.json())
.then(json => console.log(`The first contributor to ESLint was ${json.name}`));

// The first contributor to ESLint was Nicholas C. Zakas

Как видите, возвращая промис из второго вызов fetch, ответ сервера res доступен в следующем блоке .then.

Обработка ошибок промиса

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

promise.then((data) => {
console.log('Got data! Promise fulfilled.');
...
}, (error) => {
console.error('Promise rejected.');
console.error(error.message);
});

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

Метод catch

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

Возьмём в качестве примера предыдущий код:

fetch('https://api.github.com/repos/eslint/eslint/contributors')
.then(res => res.json())
.then(json => {
const firstContributor = json[0].login;
return fetch(`https://api.github.com/users/${firstContributor}`)
})
.then(res => res.jsn())
.then(json => console.log(`The top contributor to ESLint wass ${json.name}`))
.catch(error => console.log(error));

Обратите внимание, что в дополнение к добавленному обработчику ошибок в конце блока неправильно написан res.json(), в седьмой строке (она выделена) написано res.jsn().

При запуске кода получаем следующее сообщение:

TypeError: res.jsn is not a function
<anonymous> http://0.0.0.0:8000/index.js:7
promise callback* http://0.0.0.0:8000/index.js:7

index.js:9:27

Файл с которым мы работаем называется index.js. Строка 7 содержит ошибку, а строка 9 — это блок catch, обнаруживший ошибку.

Метод finally

Метод Promise.finally запускается, когда промис выполнен, то есть либо resolved, либо rejected. Как и catch, он помогает предотвратить дублирование кода и полезен для выполнения задач очистки, таких как закрытие соединения с базой данных или удаления спиннера загрузки из пользовательского UI.

function getFirstContributor(org, repo) {
showLoadingSpinner();
fetch(`https://api.github.com/repos/${org}/${repo}/contributors`)
.then(res => res.json())
.then(json => {
const firstContributor = json[0].login;
return fetch(`https://api.github.com/users/${firstContributor}`)
})
.then(res => res.json())
.then(json => console.log(`The first contributor to ${repo} was ${json.name}`))
.catch(error => console.log(error))
.finally(() => hideLoadingSpinner());
};

getFirstContributor('facebook', 'react');

Он не получает никаких аргументов и возвращает промис, так что можно добавить ещё then, catch и finally вызовов с его возвращаемым значением.

Дополнительные методы промиса

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

Promise.all()

В отличие от предыдущего примера, где нам нужно было завершить первый вызов Ajax, прежде чем мы сможем сделать второй, иногда у нас будет множество асинхронных операций, которые вообще не зависят друг от друга. Именно тогда появляется Promise.all.

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

Promise.all([
new Promise((resolve, reject) => setTimeout(() => resolve(1), 0)),
new Promise((resolve, reject) => setTimeout(() => resolve(2), 1500)),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
])
.then(values => console.log(values))
.catch(err => console.error(err));

Этот код выведет в консоль [1, 2, 3] через три секунды.

Однако если какой-либо из промисов будет отклонён, метод all будет отклонён со значением этого промиса и не будет принимать никаких других промисов.

Promise.allSettled()

В отличие от all, Promise.allSettled() будет ждать завершения каждого переданного промиса. Он не останавливает выполнение в случае отклонения промиса:

Promise.allSettled([
new Promise((resolve, reject) => setTimeout(() => resolve(1), 0)),
new Promise((resolve, reject) => setTimeout(() => reject(2), 1500)),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
])
.then(values => console.log(values))
.catch(err => console.error(err));

Этот код вернёт список статусов и значений (если промис будет выполнен) или причин (если промис будет отклонён):

[
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
{ status: "fulfilled", value: 3 },
]

Promise.any()

Promise.any возвращает значение первого выполненного промиса. Если какие-либо промисы были отклонены, они игнорируются:

Promise.any([
new Promise((resolve, reject) => setTimeout(() => reject(1), 0)),
new Promise((resolve, reject) => setTimeout(() => resolve(2), 1500)),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
])
.then(values => console.log(values))
.catch(err => console.error(err));

Этот код выведет в консоль 2 через полторы секунды.

Promise.race()

Promise.race также получает массив промисов и (как и другие вышеперечисленные методы) возвращает новый промис. Как только один из полученных промисов выполняется или отклоняется, race сама либо выполняет, либо отклоняет значение или причину из завершённого промиса. Проще говоря, race возвращает результат первого завершившегося промиса. Если промис завершился удачно, то возвращается результат. Если промис был отклонён, то возвращается причина отклонения:

Promise.race([
new Promise((resolve, reject) => setTimeout(() => reject('Rejected with 1'), 0)),
new Promise((resolve, reject) => setTimeout(() => resolve(2), 1500)),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
])
.then(values => console.log(values))
.catch(err => console.error(err));

Код выведет в консоль Rejected with 1, так как первый промис в массиве немедленно отклоняется, и отклонение перехватывается блоком catch.

Мы могли бы изменить код так (в первом промисе заменим reject на resolve):

Promise.race([
new Promise((resolve, reject) => setTimeout(() => resolve('Resolved with 1'), 0)),
new Promise((resolve, reject) => setTimeout(() => resolve(2), 1500)),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
])
.then(values => console.log(values))
.catch(err => console.error(err));

В консоль выведется Resolved with 1.

В обоих случаях два других промиса игнорируются.

Примеры JavaScript промисов

Давайте посмотрим на код в действии. Вот две демонстрации объединяющие несколько концепций, рассмотренных нами на протяжении всей статьи.

Поиск автора репозитория GitHub

Первая демонстрация позволяет пользователю ввести URL-адрес репозитория GitHub. Затем выполняется Ajax-запрос для получения списка первых 30 участников этого репозитория. Когда этот запрос завершится, выполниться второй запрос, получающий имя исходного участника и отобразит его на странице. Для второго запроса используются данные возвращённые первым запросом.

Чтобы продемонстрировать использование finally, я добавил к сетевому запросу задержку, в течении которой показывается спиннер загрузки. Он удаляется, когда запрос завершён.

See the Pen

Определение у какого репозитория GitHub больше звёзд

В этом примере пользователь ввод два URL-адреса GitHub репозиториев. Затем скрипт использует Promise.all для выполнения двух параллельных запросов получения базовой информации об этих репозиториях. Мы можем использовать all, так как оба сетевых запроса независимы друг от друга. В отличие от предыдущего примера, результат одного запроса не зависит от другого.

После выполнения запросов, скрипт выведет в каком репозитории больше звёзд, а в каком меньше.

See the Pen

Промисы, обратные вызовы или async/await: что нужно использовать

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

Например, так мы могли бы переписать предыдущий код:

async function getFirstContributor(org, repo) {
showLoadingSpinner();
try {
const res1 = await fetch(`https://apiy.github.com/repos/${org}/${repo}/contributors`);
const contributors = await res1.json();
const firstContributor = contributors[0].login;
const res2 = await fetch(`https://api.github.com/users/${firstContributor}`)
const details = await res2.json();
console.log(`The first contributor to ${repo} was ${details.name}`);
} catch (error) {
console.error(error)
} finally {
hideLoadingSpinner();
}
}

getFirstContributor('facebook', 'react');

Как видите мы используем синтаксис try...catch для обработки ошибок, и мы можем сделать небольшую чистку внутри блока finally.

Думаю, что приведённый выше код немного легче разобрать, чем версию на промисах. Тем не менее я посоветовал ознакомиться с синтаксисом async/await и посмотреть, что лучше подходит для вас. Хорошим стартом будет статья JavaScript: Управление потоком, в которой рассматриваются многие преимущества и недостатки соответствующих методов.

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

Заключение

В этой статье мы рассмотрели, как создавать JavaScript промисы и как с ними работать. Мы научились создавать цепочки промисов и передавать данные из одной асинхронной операции в другую. Также рассмотрели обработку ошибок и различные служебные методы промисов.

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

Дополнительные материалы

Предыдущая Статья

JavaScript: Delay, Sleep, Pause, & Wait

Следующая Статья

CORS и заголовок ответа Access-Control-Allow-Origin