Ранее JavaScript не обладал механизмами для работы с асинхронным кодом, к которым все уже привыкли. Изначальным и единственным инструментом для обработки операций, время выполнения которых было неизвестно, были callback функции, или функции обратного вызова.
Callback функция в JavaScript - функция, которая передается в другую функцию в качестве аргумента.
Приведем пример функции callback:
function first ( callback ) {
callback();
console.log("1");
}
function second () {
console.log("2");
}
first(second)
В ответе мы получим следующий результат:
1
2
Кажется, что может быть проще, но данный паттерн имеет проблемы при масштабировании сложных приложений. Данная ситуация называется callback hell.
Callback Hell в JavaScript возникает при выполнении слишком большого кол-ва функций обратного вызова.
Чем больше callback функций мы используем, тем сложнее читать, поддерживать и анализировать код, что может привести к различного рода ошибкам и затруднить отладку и предсказуемость поведения.
Рассмотрим наглядный пример:
function asyncFirst(callback) {
setTimeout(() => {
console.log("1");
callback();
}, 1000);
}
function asyncSecond(callback) {
setTimeout(() => {
console.log("2");
callback();
}, 1000);
}
function asyncThird(callback) {
setTimeout(() => {
console.log("3");
callback();
}, 1000);
}
asyncFirst(function() {
asyncSecond(function() {
asyncThird(function() {
console.log("Done");
});
});
});
Мы рассмотрели вариант callback hell, но в данном случае мы видим, что у пирамиды высота равна трем, представим, как громоздко будет выглядеть код с большей высотой пирамиды.
Из-за роста сложности приложений на JavaScript паттерн использования функций обратного вызова себя исчерпал, поэтому была придумана новая и более мощная абстракция, которая была сформирована в спецификации ES6 и носила название Promise.
Promise в JavaScript - специализированный объект, который предоставляет удобный способ организации асинхронного кода.
В данной статье будет проведен детальный анализ объекта Promise, чтобы предоставить понимание данного механизма, необходимого для современной разработки на JavaScript.
Жизненный цикл Promise
Promise может находиться в 3 состояниях:
- pending(ожидание) - начальное состояние объекта, после его создания, на данном этапе promise не исполнен и не отклонен.
- fulfilled(исполнено) - состояние объекта после успешного завершения операции. Переход в данное состояние происходит в момент вызова функции resolve внутри promise.
- rejected(отклонено) - состояние объекта после завершения операции с ошибкой. Переход в данное состояние происходит в момент вызова функции rejected внутри promise.
_Важно запомнить, что переход promise из состояния pending в fulfilled или rejected является необратимым, то есть, мы можем получить только успешное завершение операции или завершение с ошибкой, изменить это состояние не получится. Это гарантирует предсказуемость операции. _
Завершенный promise - это тот, который перешел в состояние fulfilled или rejected(данный термин мы еще будем использовать).
Создание Promise
Для создания Promise используется конструктор new Promise()
, а внутрь передается специальная функция-исполнитель, именно в этой функции заключается вся логика операции, которую должен обернуть Promise.
Функция исполнитель вызывается синхронно, когда создается Promise. Она принимает два аргумент:
- resolve - функция, которую необходимо вызвать, чтобы promise успешно завершился. Аргументом данной функции является значение выполняемого promise, то есть то, что мы получили в ходе выполнения асинхронной операции.
- reject - функция, которую необходимо вызвать, чтобы promise завершился с ошибкой. В качестве аргумента передать значение, которое станет причиной отклонения данной операции.
Приведем пример:
const getData = new Promise((resolve, reject) => {
console.log('Start');
// Имитируем асинхронную операцию
setTimeout(() => {
const data = { id: 1, name: 'John Doe' };
// Успешное завершение
if (data) {
resolve(data);
}
// Завершение с ошибкой
reject(new Error('Error'));// или throw new Error("Error");
}, 1000);
});
Функция исполнитель НЕ является асинхронной сама по себе, асинхронную операцию мы инициализируем внутри данной функции, это может быть обращение к серверу, работа с файлами и т.д.
Если внутри Promise не вызвать resolve или reject, она так и останется в состоянии pending и никогда не будет завершена.
Методы .then(), .catch(), .finally()
Мы написали Promise, теперь нам необходимо извлечь и обработать данные, которые мы отправили через resolve или reject. Для того, чтобы это сделать существуют методы then(), .catch(), .finally(). Эти методы регистрируют функции-обработчики, которые будут вызваны автоматически, когда Promise перейдет в соответствующее состояние.
- then() - метод, который является основным для обработки результата, он подходит как для обработки успешного выполнения, так и для завершения с ошибкой. Принимает два аргумента:
- onFulfilled - функция, которая будет вызвана, когда Promise завершится с успехом.
- onRejected - функция, которая будет вызвана, когда Promise завершится с ошибкой.
Рассмотрим пример обработки функции, которую мы писали выше:
getData.then(
(data) => {
console.log('Полученные данные по пользователю:', data);
},
(error) => {
console.log('Ошибка:', error);
},
);
Чаще всего в функцию передается первый аргумент, а ошибка обрабатывается в методе catch()
- catch() - метод для обработки ошибок, имеет один аргумент - функция для обработки ошибок, такая же, как если бы мы передавали второй аргумент в метод then(). Только в данном случае catch() - это упрощенный и более читаемый синтаксис для регистрации обработчика ошибок.
Рассмотрим пример обработки ошибок функции getData:
getData.catch(
(error) => {
console.log('Ошибка:', error);
},
);
//Или второй вариант написания
getData.then(null ,
(error) => {
console.log('Ошибка:', error);
},
);
- finally() - метод, который выполняется в любом случае, независимо от того завершился Promise успешно или с ошибкой. Этот метод позволяет избежать дублирования в методах then() и catch(). Данный метод не принимает никаких аргументов, а также не возвращает никакие значения.
Рассмотрим пример:
getData.then((data) => {
console.log('Успех');
}).catch((error) => {
console.log('Ошибка');
}).finally(()=>{
console.log("Завершено");
})
Так в случае успешного завершения Promise мы увидим:
Успех
Завершено
А в случае завершения с ошибкой:
Ошибка
Завершено
Но и это еще не все, каждый из методов then(), catch(), finally() возращает тоже Promise, а это значит, что мы можем реализовать так называемые цепочки вызовов. Благодаря этому можно обработать несколько асинхронных операций одна за другой.
Разберемся, как это работает. Как говорилось выше, методы возвращают Promise, а значит можно продолжить цепочку дальше, в качестве их аргументов будут выступать результаты предыдущих методов. Легче разобраться на практике.
const getData = new Promise((resolve, reject) => {
resolve("Успех")
})
getData
.then((data) => {
console.log(data);
return data + " 2";
})
.then((data) => {
console.log(data);
return data + " 3";
})
.then((data) => {
console.log(data);
});
В результате мы получим:
Успех
Успех 2
Успех 2 3
Рассмотрим выполнение кода поэтапно:
- Мы создали Promise getData, который с помощью resolve переводит состояние Promise в fulfilled со значением "Успех".
- Обрабатываем с помощью then(), в качестве аргумента в функцию будет передаваться значение "Успех".
- При обработке снова возвращаем значение, которое переходит в следующий метод then()
- Далее алгоритм повторяется.
То же самое происходит с методами catch() и finally().
Рассмотрим и разберем более сложные примеры, чтобы лучше понять принцип действия.
Задача 1
Promise.resolve(5)
.then(x => x * 2)
.then(x => x + 3)
.then(x => { throw new Error(x) })
.catch(e => parseInt(e.message))
.then(x => console.log(x))
.finally(() => console.log('Done'));
- Первый then возвращает значение 10.
- Второй then возвращает значение 13.
- Третий then возвращает ошибку, которая переходит в catch.
- В методе catch возвращается значение 13, так как метод закончился без ошибки, то переходит дальше к then.
- Выводится значение 13
- Выводится 'Done' Вот как выглядит точный ответ:
13
Done
Задача 2
Promise.reject(new Error('Boom!'))
.finally(() => console.log('Cleanup 1'))
.catch(e => {
console.log(e.message);
throw new Error('Again!');
})
.finally(() => console.log('Cleanup 2'))
.catch(e => {
console.log(e.message);
return "Good!!!"
})
.catch(e => console.log(e.message));
Как уже говорилось ранее, finally также возвращает Promise, поэтому цепочка продолжается, а конечный результат будет иметь следующий вид:
Cleanup 1
Boom!
Cleanup 2
Мы не попадаем в последний catch, так как метод перед ним закончился успешно.
Задача 3
Promise.reject('A')
.catch((a) => {
console.log('Catch 1:', a);
return 'B';
})
.finally(() => {
console.log('Finally 1');
return 'C';
})
.then((b) => {
console.log('Then 1:', b);
throw new Error('D');
})
.catch((d) => {
console.log('Catch 2:', d.message);
return 'E';
})
.finally(() => {
console.log('Finally 2');
return 'F';
})
.then((e) => {
console.log('Then 2:', e);
});
Вспомним, что метод finally не возвращает никакие значения, поэтому данные строки можно убрать, при этом в then попадает значение из предыдущих методов then или catch
Думаю, что после пояснения результат стал вполне ожидаемым:
Catch 1: A
Finally 1
Then 1: B
Catch 2: D
Finally 2
Then 2: E
А вот с ошибками результат будет другой, рассмотрим такой пример:
Promise.reject('First Error')
.catch((err) => {
console.log('Catch 1:', err); //First Error
return 'Success';
})
.finally(() => {
console.log('Finally 1');
//Возникает ошибка, которая обрабатывается в catch
throw new Error('Error in Finally');
})
.then((value) => {
//Так как возникла ошибка, то then пропускается
console.log('Then 1:', value);
})
.catch((err) => {
console.log('Catch 2:', err.message); //Error in Finally
return 'Final Recovery';
})
.then((value) => {
console.log('Then 2:', value); //Final Recovery
});
Таким образом, finally не возвращает значение, которое может попасть в then, но при этом, может вызвать ошибку, которая в дальнейшем будет обработана.
Конечный результат:
Catch 1: First Error
Finally 1
Catch 2: Error in Finally
Then 2: Final Recovery
Думаю, на этих примерах принцип действия стал понятнее.
Статические методы класса Promise
Статические методы предназначены для работы с коллекциями Promises. С их помощью мы можем организовать параллельное выполнение асинхронных операций. Данные методы возвращают новый промис, состояние которых зависит от переданных Promises.
- all() - принимает на вход массив с Promises, а возвращаемый Promise выполняется, когда все Promises из входных данных выполнятся, отклоняется, когда любой из переданных Promise завершается с ошибкой, при этом причина отклонения поступает с первого Promise, который неудачно завершился.
Рассмотрим два примера:
const promiseFirst = Promise.all([1, 2, 3, Promise.resolve(4)]);
console.log(promiseFirst)
Данный пример вернет следующий результат:
Promise { <state>: "fulfilled", <value>: Array[4]}
То есть, все Promises, которые передавались во входные параметры закончились успешно, поэтому конечный Promise закончился с успехом и мы может увидеть результаты каждого выполненного Promise.
Теперь рассмотрим следующую ситуацию:
const promiseSecond = Promise.all([Promise.reject('bad 1'), 2, 3, Promise.reject('bad 2')]);
console.log(promiseSecond)
Данный пример вернет следующий результат:
Promise { <state>: "rejected", <reason>: Error: bad 1 }
В нашем случае сразу два Promise закончились с ошибкой, поэтому общий Promise отклоненный, но при этом в его ошибке хранится информация от первого Promise, который закончился неудачно.
- allSettled() - метод, который принимает на вход массив из Promise и возвращает один, как и в случае с методом all, но их отличие в том, что он ждем выполнения всех Promises, неважно, успешны они или нет.
_Метод allSettled() никогда не отклоняется _
Данный метод возвращает массив объектов, который имеет следующий вид:
{
status // "fulfilled" или "rejected"
value? // Значение, когда status = "fulfilled", хранит данные по успешной операции
reason? // Значение, когда status = "rejected", хранит данные по ошибке
}
- any() - метод, который принимает массив Promises, как и два предыдущих метода, но выполняется тогда, когда выполняется любой из входных Promises с первым значением выполнения, а отклоняется, когда отклоняются все входные Promises, при этом метод возвращает AggregateError ошибку с массивом причин отклонения. То есть, можно сказать, что метод any - это организатор гонок, в которых либо один победитель, либо все проигравшие.
Рассмотрим пример выполнения данного метода, который заканчивается неудачей:
const promise = Promise.any([
Promise.reject('bad 1'),
Promise.reject('bad 2'),
Promise.reject('bad 3')
]);
promise.then(result => {
console.log('Успех! Result:', result);
}).catch(error => {
console.log('Ошибка! Error name:', error.name); // AggregateError
console.log('Ошибка! Error errors:', error.errors); // Массив ошибок
});
Результат будет иметь следующий вид:
Ошибка! Error name: AggregateError
Ошибка! Error errors: [ 'bad 1', 'bad 2', 'bad 3' ]
race() - метод, который также принимает массив в качестве входных параметров, а то, разрешается он или отклоняется, зависит от первого завершённого Promise. То есть race - это, как и any тоже своего рода организатор гонок, только победителем может быть как успешно завершенный Promise, так и отклоненный с ошибкой.
reject() - метод, который возвращает отклоненный Promise по указанной причине. Мы уже использовали его ранее.
resolve() - метод, который возвращает успешный Promise с указанным значением.
try() - метод, чтобы была возможность запустить функцию, которая может быть как синхронной, так и асинхронной, и вернуть Promise.
Если callback функция выполняется синхронно и возвращает значение, то данный метод возвращает успешный Promise с этим значением, который потом можно обработать в then().
Если callback функция выполняется синхронно и возвращает ошибку, то данный метод возвращает отклоненный Promise с этой ошибкой, который потом можно обработать в catch().
Если callback функция возвращает другой Promise, то данный метод вернет этот Promise.
Рассмотрим пример:
const result = Promise.try(() => {
throw new Error('Error');
});
result.catch(error => console.error(error.message)); // Error
То есть метод try() - это универсальный контейнер, если мы в него что-то положим, то он обязательно вернет Promise, который в дальнейшем можно обработать с помощью then() или catch().
- withResolvers() - метод, который позволяет создать Promise и тут же получить функции для управления им. Это может понадобиться, например, когда решение Promise должно быть принято позже или же в другом месте.
Из метода withResolvers() мы можем получить следующие данные:
- promise - созданный Promise
- resolve - функция для успешного завершения Promise
- reject - функция для завершения Promise с ошибкой
Рассмотрим пример:
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve('Успех!'), 1000);
promise.then(data => console.log(data)); // Успех!
Связь с async/await
Ключевые слова async и await не являются полноправной заменой Promise, это так называемый синтаксический сахар, для того, чтобы упростить работу с Promise и сделать код более удобочитаемым. Данный синтаксис позволяет писать асинхронный код, который выглядит как синхронный, при этом нет потери производительности и не блокируется основной поток.
Под капотом async/await работает на основе Promise, а также функций генераторов.
Если перед функцией поставить ключевое слово async, то данная функция автоматически будет возвращать Promise.
Рассмотрим пример:
async function getNumber() {
return 42;
}
getNumber().then(number => console.log(number)); // 42
При этом, данную функцию можно записать и через Promise, эквивалентное решение будет выглядеть следующим образом:
function getNumber() {
return Promise.resolve(42);
}
Ключевое слово await можно использовать только внутри функции, перед которой стоит async, то есть внутри async-функции. Оно заставляет интерпретатор приостановить выполнении функции до тех пор, пока Promise не будет завершен.
Алгоритм работы:
- Если Promise завершился с успехом, то await возвращает его результат.
- Если Promise завершается с ошибкой, то await генерирует ошибку, которую можно обработать через try/catch.
Рассмотрим пример:
**async** function fetchDataNew() {
try {
const response = **await** fetch('...');
const data = await response.json();JSON
console.log(data);
} catch (error) {
console.error('Error:', error);
}
}
Необходимо грамотно подходить к выбору того, что лучше использовать, например, когда нам необходимо выполнить несколько операций параллельно, то лучше использовать Promise, а когда логика требует выполнения последовательных действий, то лучше использовать async/await.
Заключение
Promise - это фундаментальная блок асинхронного кода, который позволил разработчикам уйти от callback hell и сделать код более предсказуемым, удобочитаемым и элегантным.
На первый взгляд концепция Promise выглядит сложно, но изучение данной технологии дает большой толчок при работе с современными фреймворками, библиотеками и API.
Теперь спокойно можно покорять вершины асинхронности, самое главное не прибегать к callback hell :)
Top comments (0)