JS. Начало #7. Promise

Автор: Рябкова Анна, Коптикова Лилия, Манюшкина Дарья

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

Пример синхронного кода:


const btn = document.querySelector("button");

	btn.addEventListener("click", () => {

	alert("You clicked me!");

	  

	let pElem = document.createElement("p");

	pElem.textContent = "This is a newly-added paragraph.";

	document.body.appendChild(pElem);

});

В этом блоке кода команды выполняются одна за другой. Во время выполнения каждой операции JavaScript блокирует основной поток, так как работает однопоточно. Пока не завершится текущая команда, другие действия не начнутся. В примере выше абзац появится только после закрытия окна alert().

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

Что такое асинхронность в JavaScript

Асинхронный код позволяет выполнять долгие операции, не блокируя основной поток. В JavaScript используются два подхода: колбэки (callbacks) и более новый — промисы (promises). Асинхронные задачи ставятся в очередь и выполняются после завершения основного кода.

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

Почему Promise важны для работы с асинхронным кодом

Ранее для работы с асинхронностью использовались callback-функции, однако у них есть ряд недостатков, таких как сложность чтения, вложенность кода и трудности с обработкой ошибок.

Callback (колбэк, функция обратного вызова) — функция, которая вызывается в ответ на совершение некоторого события.

Как раньше использовались callback-функции и какие у них были проблемы

Нагляднее всего главную проблему callback-hell колбэков можно показать на примере.

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


function request(url, onSuccess) {

	/*...*/

}

request('/api/users/1', function (user) {

	request(`/api/photos/${user.id}/`, function (photo) {

		request(`/api/crop/${photo.id}/`, function (response) {

			console.log(response)
		})
	})
})

Такой код сложно читать и тестировать.

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



function request(url) {

	return new Promise(function (resolve, reject) {

		let responseFromServer

		/*...*/

		resolve(responseFromServer)

	})

}

request('/api/users/1')

	.then((user) => request(`/api/photos/${user.id}/`))

	.then((photo) => request(`/api/crop/${photo.id}/`))

	.then((response) => console.log(response))

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

Что такое Promise

Определение Promise и его ключевые особенности

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

Состояния Promise (pending, fulfilled, rejected)

Promise может находиться в трёх состояниях:

  • ожидание (pending): начальное состояние, не исполнен и не отклонён.

  • исполнено (fulfilled): операция завершена успешно.

  • отклонено (rejected): операция завершена с ошибкой.

При создании промис находится в ожидании (pending), а затем может стать исполненным (fulfilled), вернув полученный результат (значение), или отклонённым (rejected), вернув причину отказа. В любом из этих случаев вызывается обработчик, прикреплённый к промису методом then. (Если в момент назначения обработчика промис уже исполнен или отклонён, обработчик всё равно будет вызван)

Как создать Promise с new Promise()

Объект Promise создаётся при помощи ключевого слова new и своего конструктора. Конструктор Promise принимает в качестве аргумента функцию, называемую “исполнитель” (executor function). Эта функция должна принимать две функции-колбэка в качестве параметров. Первый из них (resolve) вызывается, когда асинхронная операция завершилась успешно и вернула результат своего исполнения в виде значения. Второй колбэк (reject) вызывается, когда операция не удалась, и возвращает значение, указывающее на причину неудачи, чаще всего объект ошибки.



const myFirstPromise = new Promise((resolve, reject) => {

	// выполняется асинхронная операция, которая в итоге вызовет:

	// resolve(someValue); // успешное завершение

	// или

	// reject("failure reason"); // неудача

});

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


function myAsyncFunction(url) {

	return new Promise((resolve, reject) => {

		const xhr = new XMLHttpRequest();

		xhr.open("GET", url);

		xhr.onload = () => resolve(xhr.responseText);

		xhr.onerror = () => reject(xhr.statusText);

		xhr.send();

	});

}

Работа с then, catch и finally

then

Наиболее важный и фундаментальный метод – .then.

Синтаксис:


promise.then(
  
	function(result) `{ /* обработает успешное выполнение */ }`,
	  
	function(error)` { /* обработает ошибку */ }`

);  

Первый аргумент метода .then – функция, которая выполняется, когда промис переходит в состояние «выполнен успешно», и получает результат.

Второй аргумент .then – функция, которая выполняется, когда промис переходит в состояние «выполнен с ошибкой», и получает ошибку.

Например, вот реакция на успешно выполненный промис:


let promise = new Promise(function(resolve, reject) {

	setTimeout(() => resolve("done!"), 1000);

});

  
// resolve запустит первую функцию, переданную в .then

promise.then(

	result => alert(result), // выведет "done!" через одну секунду

	error => alert(error) // не будет запущена

);

Выполнилась первая функция.

А в случае ошибки в промисе – выполнится вторая:

let promise = new Promise(function(resolve, reject) {

	setTimeout(() => reject(new Error("Whoops!")), 1000);

});

  
// reject запустит вторую функцию, переданную в .then

promise.then(

	result => alert(result), // не будет запущена

	error => alert(error) // выведет "Error: Whoops!" спустя одну секунду

);

Если мы заинтересованы только в результате успешного выполнения задачи, то в then можно передать только одну функцию:


let promise = new Promise(resolve => {

	setTimeout(() => resolve("done!"), 1000);

});


promise.then(alert); // выведет "done!" спустя одну секунду

catch

Если мы хотели бы только обработать ошибку, то можно использовать null в качестве первого аргумента: .then(null, errorHandlingFunction). Или можно воспользоваться методом .catch(errorHandlingFunction), который сделает то же самое:



let promise = new Promise((resolve, reject) => {

	setTimeout(() => reject(new Error("Ошибка!")), 1000);

});

  

// .catch(f) это то же самое, что promise.then(null, f)

promise.catch(alert); // выведет "Error: Ошибка!" спустя одну секунду

Вызов .catch(f) – это сокращённый, «укороченный» вариант .then(null, f).

finally

По аналогии с блоком finally из обычного try {…} catch {…}, у промисов также есть метод finally.

Вызов .finally(f) похож на .then(f, f), в том смысле, что f выполнится в любом случае, когда промис завершится: успешно или с ошибкой.

Идея finally состоит в том, чтобы настроить обработчик для выполнения очистки/доведения после завершения предыдущих операций.

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

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

Код может выглядеть следующим образом:



new Promise((resolve, reject) => {

	/* сделать что-то, что займет время, и после вызвать resolve или может reject */

	})

	// выполнится, когда промис завершится, независимо от того, успешно или нет

	.finally(() => остановить индикатор загрузки)

	// таким образом, индикатор загрузки всегда останавливается, прежде чем мы продолжим

	.then(result => показать результат, err => показать ошибку)

Успешное состояние (resolve)

Когда промис выполняется успешно, его состояние становится resolved (исполнен), и вызывается метод resolve(). Для обработки этого состояния используется метод then(), который позволяет обработать результат успешного выполнения промиса.


let promise = new Promise((resolve, reject) => {

	let success = true; // Задаём успешное состояние

	if (success) {

		resolve("Операция успешна!"); // Промис выполнен успешно

	}

});

  

promise.then((result) => {

	console.log(result); // Выведет: "Операция успешна!"

});

Ошибочное состояние (reject)

Когда промис завершён с ошибкой, его состояние становится rejected (отклонён), и вызывается метод reject(). Для обработки ошибки используется метод catch(), который позволяет обработать ошибку, если промис не был выполнен успешно.


let promise = new Promise((resolve, reject) => {

	let success = false; // Задаём ошибку

	if (success) {

		resolve("Операция успешна!");

	} else {

		reject("Произошла ошибка."); // Промис отклонён

	}

});

promise.catch((error) => {

	console.log(error); // Выведет: "Произошла ошибка."

});

Метод then() может принимать два аргумента: первый — для обработки успешного завершения промиса (resolve), второй — для обработки ошибки (reject). Это позволяет обрабатывать оба состояния в одном месте.


let promise = new Promise((resolve, reject) => {

	let success = true; // Успешное выполнение

	if (success) {

		resolve("Операция успешна!");

	} else {

		reject("Произошла ошибка.");

	}

});

promise.then(

	(result) => console.log(result), // Успех

	(error) => console.log(error) // Ошибка

);

Цепочки промисов (Promise Chaining)

Цепочка промисов выглядит вот так:


new Promise(function(resolve, reject) {
	setTimeout(() => resolve(1), 1000); // (*)
	
	}).then(function(result) { // (**)
		
		alert(result); // 1
		
		return result * 2;
		
	}).then(function(result) { // (***)
	
		 alert(result); // 2

		return result * 2;
		
	}).then(function(result) {
		
		alert(result); // 4

		return result * 2;
});

Идея состоит в том, что результат первого промиса передаётся по цепочке обработчиков .then.

Поток выполнения такой:

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

  2. Затем вызывается обработчик в .then (**).

  3. Возвращаемое им значение передаётся дальше в следующий обработчик .then (***)

  4. …и так далее.

В итоге результат передается по цепочке обработчиков, и мы видим несколько alert подряд, которые выводят: 1 → 2 → 4.

Всё это работает, потому что вызов promise.then тоже возвращает промис, так что мы можем вызвать на нём следующий .then.

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

Классическая ошибка новичков: технически возможно добавить много обработчиков .then к единственному промису. Но это не цепочка.

Как передаются данные из одного .then() в другой

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

Как работает return внутри промисов

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

Обработка ошибок в цепочках промисов

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

Пример с ошибкой:



new Promise(function(resolve, reject) {

	setTimeout(() => resolve(1), 1000);

	}).then(function(result) {

		alert(result); // 1

		return result * 2;

	}).then(function(result) {

		alert(result); // 2

		throw new Error("Ошибка!"); // Генерируем ошибку

	}).catch(function(error) {

		alert("Ошибка: " + error.message); // Ошибка: Ошибка!

});

В третьем .then() мы намеренно генерируем ошибку с помощью throw new Error().

Ошибка передается в блок .catch(), где мы можем её обработать.

Если бы не было обработчика .catch(), ошибка привела бы к необработанному отклонению промиса.

Методы Promise

В классе Promise есть 6 статических методов. Давайте познакомимся с ними.

Promise.all ожидание выполнения всех промисов

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

Например, параллельно загрузить несколько файлов и обработать результат, когда он готов.

Для этого как раз и пригодится Promise.all.

Синтаксис:


let promise = Promise.all(iterable);

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

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

Например, Promise.all, представленный ниже, выполнится спустя 3 секунды, его результатом будет массив [1, 2, 3]:


Promise.all([
	
	new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1

	new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2

	new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 3

]).then(alert); // когда все промисы выполнятся, результат будет 1,2,3

// каждый промис даёт элемент массива

Promise.race выполнение первого завершенного промиса

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

Синтаксис:


let promise = Promise.race(iterable);

Например, тут результат будет 1:


Promise.race([

	new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),

	new Promise((resolve, reject) => setTimeout(() => reject(new Error("Ошибка!")), 2000)),

	new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))

]).then(alert); // 1

Быстрее всех выполнился первый промис, он и дал результат. После этого остальные промисы игнорируются.

Promise.allSettled и Promise.any, в чем разница

Promise.allSettled - синтаксис:

let promise = Promise.allSettled(iterable);

Метод Promise.allSettled всегда ждёт завершения всех промисов. В массиве результатов будет

  • {status:“fulfilled”, value:результат} для успешных завершений,

  • {status:“rejected”, reason:ошибка} для ошибок.

Например, мы хотели бы загрузить информацию о множестве пользователей. Даже если в каком-то запросе ошибка, нас всё равно интересуют остальные.

Используем для этого Promise.allSettled:


let urls = [

  'https://api.github.com/users/iliakan',

  'https://api.github.com/users/remy',

  'https://no-such-url'

];


Promise.allSettled(urls.map(url => fetch(url)))

  	.then(results => { // (*)

  	results.forEach((result, num) => {

  	if (result.status == "fulfilled") {

  	alert(`${urls[num]}: ${result.value.status}`);

  }

  if (result.status == "rejected") {

  	alert(`${urls[num]}: ${result.reason}`);

  }

  });

});

Promise.any - метод очень похож на Promise.race, но ждёт только первый успешно выполненный промис, из которого берёт результат.

Если ни один из переданных промисов не завершится успешно, тогда возвращённый объект Promise будет отклонён с помощью AggregateError – специального объекта ошибок, который хранит все ошибки промисов в своём свойстве errors.

Синтаксис:


let promise = Promise.any(iterable);

Например, здесь, результатом будет 1:


Promise.any([

	new Promise((resolve, reject) => setTimeout(() => reject(new Error("Ошибка!")), 1000)),

	new Promise((resolve, reject) => setTimeout(() => resolve(1), 2000)),

	new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))

]).then(alert); // 1

Связь Promise и async/await

Как async/await упрощает работу с промисами

Начнем с ключевого слова async. Оно ставится перед функцией, вот так:


async function f() {

	return 1;

}

У слова async один простой смысл: эта функция всегда возвращает промис. Значения других типов оборачиваются в завершившийся успешно промис автоматически.

Например, эта функция возвратит выполненный промис с результатом 1:


async function f() {

	return 1;
}

f().then(alert); // 1

Можно и явно вернуть промис, результат будет одинаковым:


async function f() {

	return Promise.resolve(1);

}

f().then(alert); // 1

Так что ключевое слово async перед функцией гарантирует, что эта функция в любом случае вернет промис. Согласитесь, достаточно просто? Но это ещё не всё. Есть другое ключевое слово – await, которое можно использовать только внутри async-функций.

Синтаксис:

// работает только внутри async–функций

let value = await promise;

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

Переписывание кода с then/catch на async/await

Давайте перепишем пример showAvatar() из раздела Цепочка промисов с помощью async/await:

  1. Нам нужно заменить вызовы .then на await.

  2. И добавить ключевое слово async перед объявлением функции.

async function showAvatar() {

	// запрашиваем JSON с данными пользователя

	let response = await fetch('/article/promise-chaining/user.json');

	let user = await response.json();

	// запрашиваем информацию об этом пользователе из github

	let githubResponse = await fetch(`https://api.github.com/users/${user.name}`);

	let githubUser = await githubResponse.json();

	  
	// отображаем аватар пользователя

	let img = document.createElement('img');

	img.src = githubUser.avatar_url;

	img.className = "promise-avatar-example";

	document.body.append(img);
 

	// ждём 3 секунды и затем скрываем аватар

	await new Promise((resolve, reject) => setTimeout(resolve, 3000));

	img.remove();

	return githubUser;

}

 
showAvatar();

Как обрабатывать ошибки с try/catch

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

Такой код:

async function f() {

	await Promise.reject(new Error("Упс!"));

}

Делает то же самое, что и такой:

async function f() {

	throw new Error("Упс!");

}

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

Такие ошибки можно ловить, используя try..catch, как с обычным throw:

async function f() {

	try {

		let response = await fetch('http://no-such-url');

	} catch(err) {

		alert(err); // TypeError: failed to fetch

	}
}

f();

В случае ошибки выполнение try прерывается и управление прыгает в начало блока catch. Блоком try можно обернуть несколько строк:


async function f() {

  

	try {

		let response = await fetch('/no-user-here');

		let user = await response.json();

	} catch(err) {

	// перехватит любую ошибку в блоке try: и в fetch, и в response.json

		alert(err);
	}
}

f();

Практические примеры использования Promise

Запросы к серверу с fetch

Метод fetch() — современный и очень мощный, поэтому начнём с него. Он не поддерживается старыми (можно использовать полифил), но поддерживается всеми современными браузерами.

Базовый синтаксис:


let promise = fetch(url, [options])
  • url – URL для отправки запроса.

  • options – дополнительные параметры: метод, заголовки и так далее.

Без options это простой GET-запрос, скачивающий содержимое по адресу url.

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

Мы можем увидеть HTTP-статус в свойствах ответа:

  • status – код статуса HTTP-запроса, например 200.

  • ok – логическое значение: будет true, если код HTTP-статуса в диапазоне 200-299.

Например:

let response = await fetch(url);

  

if (response.ok) { // если HTTP-статус в диапазоне 200-299

	// получаем тело ответа (см. про этот метод ниже)

	let json = await response.json();

} else {

	alert("Ошибка HTTP: " + response.status);

}

Response предоставляет несколько методов, основанных на промисах, для доступа к телу ответа в различных форматах:

  • response.text() – читает ответ и возвращает как обычный текст,

  • response.json() – декодирует ответ в формате JSON,

  • response.formData() – возвращает ответ как объект FormData,

  • response.blob() – возвращает объект как Blob (бинарные данные с типом),

  • response.arrayBuffer() – возвращает ответ как ArrayBuffer (низкоуровневое представление бинарных данных),

  • помимо этого, response.body – это объект ReadableStream, с помощью которого можно считывать тело запроса по частям.

Имитация асинхронных операций (setTimeout и Promise)

console.log('Начало');


setTimeout(() => {

	console.log('Асинхронная операция завершена');

}, 2000); // Таймаут 2 секунды 

console.log('Конец');

Объяснение:

  • В этом примере setTimeout задерживает выполнение кода на 2 секунды.

  • Вызов setTimeout не блокирует выполнение других операций в потоке. Поэтому console.log(‘Конец’)будет выполнен сразу, а асинхронная операция (console.log(‘Асинхронная операция завершена’)) выполнится через 2 секунды.


console.log('Начало');
 

function simulateAsyncOperation() {

	return new Promise((resolve, reject) => {

		setTimeout(() => {

			resolve('Асинхронная операция завершена');

		}, 2000); // Таймаут 2 секунды

	});

}

simulateAsyncOperation()

	.then((message) => {

		console.log(message);

});

console.log('Конец');

Объяснение:

  • Мы создаем функцию simulateAsyncOperation, которая возвращает Promise. Внутри Promise используется setTimeout, чтобы имитировать асинхронную операцию с задержкой.

  • Метод .then() используется для обработки результата выполнения Promise (в нашем случае — это строка ‘Асинхронная операция завершена’).

  • Как и в первом примере, console.log(‘Конец’) будет выполнен сразу, а вывод из .then() произойдёт после 2 секунд.

Обработка нескольких запросов одновременно

Promise.all позволяет ожидать завершения всех промисов, и вернёт массив результатов, как только все промисы будут выполнены. Если один из промисов отклоняется, вся операция будет отклонена.

function fetchData1() {

	return new Promise(resolve => setTimeout(() => resolve('Данные из запроса 1'), 1000));

}
 

function fetchData2() {

	return new Promise(resolve => setTimeout(() => resolve('Данные из запроса 2'), 2000));

}


function fetchData3() {

	return new Promise(resolve => setTimeout(() => resolve('Данные из запроса 3'), 1500));

}


console.log('Запросы начинаются');


Promise.all([fetchData1(), fetchData2(), fetchData3()])

	.then((results) => {

		console.log('Все запросы завершены:');

		console.log(results); // ['Данные из запроса 1', 'Данные из запроса 2', 'Данные из запроса 3']
})

	.catch((error) => {

	console.error('Один из запросов не удался:', error);

});  

console.log('Запросы отправлены');

Все три функции (fetchData1, fetchData2, fetchData3) возвращают промисы.

Promise.all запускает все промисы одновременно. Когда все запросы завершатся, результат будет доступен в виде массива.

Если любой из промисов не выполнится (например, будет отклонен), то вся операция отклоняется.

Рябкова Анна, Коптикова Лилия, Манюшкина Дарья

Рябкова Анна, Коптикова Лилия, Манюшкина Дарья