DEV Community

Anna
Anna

Posted on

Promise полный гид для понятного кода

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

Callback функция в JavaScript - функция, которая передается в другую функцию в качестве аргумента.

Приведем пример функции callback:

function first ( callback ) {
   callback();
   console.log("1");
}

function second () {
   console.log("2");
}

first(second)
Enter fullscreen mode Exit fullscreen mode

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

1
2
Enter fullscreen mode Exit fullscreen mode

Кажется, что может быть проще, но данный паттерн имеет проблемы при масштабировании сложных приложений. Данная ситуация называется 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");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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

Из-за роста сложности приложений на JavaScript паттерн использования функций обратного вызова себя исчерпал, поэтому была придумана новая и более мощная абстракция, которая была сформирована в спецификации ES6 и носила название Promise.

Promise в JavaScript - специализированный объект, который предоставляет удобный способ организации асинхронного кода.

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


Жизненный цикл Promise

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

  1. pending(ожидание) - начальное состояние объекта, после его создания, на данном этапе promise не исполнен и не отклонен.
  2. fulfilled(исполнено) - состояние объекта после успешного завершения операции. Переход в данное состояние происходит в момент вызова функции resolve внутри promise.
  3. rejected(отклонено) - состояние объекта после завершения операции с ошибкой. Переход в данное состояние происходит в момент вызова функции rejected внутри promise.

_Важно запомнить, что переход promise из состояния pending в fulfilled или rejected является необратимым, то есть, мы можем получить только успешное завершение операции или завершение с ошибкой, изменить это состояние не получится. Это гарантирует предсказуемость операции. _

Завершенный promise - это тот, который перешел в состояние fulfilled или rejected(данный термин мы еще будем использовать).


Создание Promise

Для создания Promise используется конструктор new Promise(), а внутрь передается специальная функция-исполнитель, именно в этой функции заключается вся логика операции, которую должен обернуть Promise.

Функция исполнитель вызывается синхронно, когда создается Promise. Она принимает два аргумент:

  1. resolve - функция, которую необходимо вызвать, чтобы promise успешно завершился. Аргументом данной функции является значение выполняемого promise, то есть то, что мы получили в ходе выполнения асинхронной операции.
  2. 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);
});
Enter fullscreen mode Exit fullscreen mode

Функция исполнитель НЕ является асинхронной сама по себе, асинхронную операцию мы инициализируем внутри данной функции, это может быть обращение к серверу, работа с файлами и т.д.

Если внутри Promise не вызвать resolve или reject, она так и останется в состоянии pending и никогда не будет завершена.


Методы .then(), .catch(), .finally()

Мы написали Promise, теперь нам необходимо извлечь и обработать данные, которые мы отправили через resolve или reject. Для того, чтобы это сделать существуют методы then(), .catch(), .finally(). Эти методы регистрируют функции-обработчики, которые будут вызваны автоматически, когда Promise перейдет в соответствующее состояние.

  1. then() - метод, который является основным для обработки результата, он подходит как для обработки успешного выполнения, так и для завершения с ошибкой. Принимает два аргумента:
  • onFulfilled - функция, которая будет вызвана, когда Promise завершится с успехом.
  • onRejected - функция, которая будет вызвана, когда Promise завершится с ошибкой.

Рассмотрим пример обработки функции, которую мы писали выше:

getData.then(
   (data) => {
      console.log('Полученные данные по пользователю:', data);
   },
   (error) => {
      console.log('Ошибка:', error);
   },
);
Enter fullscreen mode Exit fullscreen mode

Чаще всего в функцию передается первый аргумент, а ошибка обрабатывается в методе catch()

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

Рассмотрим пример обработки ошибок функции getData:

getData.catch(
   (error) => {
      console.log('Ошибка:', error);
   },
);

//Или второй вариант написания
getData.then(null ,
   (error) => {
      console.log('Ошибка:', error);
   },
);
Enter fullscreen mode Exit fullscreen mode
  1. finally() - метод, который выполняется в любом случае, независимо от того завершился Promise успешно или с ошибкой. Этот метод позволяет избежать дублирования в методах then() и catch(). Данный метод не принимает никаких аргументов, а также не возвращает никакие значения.

Рассмотрим пример:

getData.then((data) => {
      console.log('Успех');
   }).catch((error) => {
      console.log('Ошибка');
   }).finally(()=>{
      console.log("Завершено");
   })

Enter fullscreen mode Exit fullscreen mode

Так в случае успешного завершения Promise мы увидим:

Успех
Завершено
Enter fullscreen mode Exit fullscreen mode

А в случае завершения с ошибкой:

Ошибка
Завершено
Enter fullscreen mode Exit fullscreen mode

Но и это еще не все, каждый из методов 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);
  });
Enter fullscreen mode Exit fullscreen mode

В результате мы получим:

Успех
Успех 2
Успех 2 3
Enter fullscreen mode Exit fullscreen mode

Рассмотрим выполнение кода поэтапно:

  1. Мы создали Promise getData, который с помощью resolve переводит состояние Promise в fulfilled со значением "Успех".
  2. Обрабатываем с помощью then(), в качестве аргумента в функцию будет передаваться значение "Успех".
  3. При обработке снова возвращаем значение, которое переходит в следующий метод then()
  4. Далее алгоритм повторяется.

То же самое происходит с методами 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'));
Enter fullscreen mode Exit fullscreen mode
  1. Первый then возвращает значение 10.
  2. Второй then возвращает значение 13.
  3. Третий then возвращает ошибку, которая переходит в catch.
  4. В методе catch возвращается значение 13, так как метод закончился без ошибки, то переходит дальше к then.
  5. Выводится значение 13
  6. Выводится 'Done' Вот как выглядит точный ответ:
13
Done
Enter fullscreen mode Exit fullscreen mode

Задача 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));
Enter fullscreen mode Exit fullscreen mode

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

Cleanup 1
Boom!
Cleanup 2
Enter fullscreen mode Exit fullscreen mode

Мы не попадаем в последний 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);
  });
Enter fullscreen mode Exit fullscreen mode

Вспомним, что метод finally не возвращает никакие значения, поэтому данные строки можно убрать, при этом в then попадает значение из предыдущих методов then или catch

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

Catch 1: A
Finally 1
Then 1: B
Catch 2: D
Finally 2
Then 2: E
Enter fullscreen mode Exit fullscreen mode

А вот с ошибками результат будет другой, рассмотрим такой пример:

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
  });
Enter fullscreen mode Exit fullscreen mode

Таким образом, finally не возвращает значение, которое может попасть в then, но при этом, может вызвать ошибку, которая в дальнейшем будет обработана.
Конечный результат:

Catch 1: First Error
Finally 1
Catch 2: Error in Finally
Then 2: Final Recovery
Enter fullscreen mode Exit fullscreen mode

Думаю, на этих примерах принцип действия стал понятнее.

Статические методы класса Promise

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

  1. all() - принимает на вход массив с Promises, а возвращаемый Promise выполняется, когда все Promises из входных данных выполнятся, отклоняется, когда любой из переданных Promise завершается с ошибкой, при этом причина отклонения поступает с первого Promise, который неудачно завершился.

Рассмотрим два примера:

const promiseFirst = Promise.all([1, 2, 3, Promise.resolve(4)]);
console.log(promiseFirst)
Enter fullscreen mode Exit fullscreen mode

Данный пример вернет следующий результат:

Promise { <state>: "fulfilled", <value>: Array[4]}
Enter fullscreen mode Exit fullscreen mode

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

Теперь рассмотрим следующую ситуацию:

const promiseSecond = Promise.all([Promise.reject('bad 1'), 2, 3, Promise.reject('bad 2')]);
console.log(promiseSecond)
Enter fullscreen mode Exit fullscreen mode

Данный пример вернет следующий результат:

Promise { <state>: "rejected", <reason>: Error: bad 1 }
Enter fullscreen mode Exit fullscreen mode

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

  1. allSettled() - метод, который принимает на вход массив из Promise и возвращает один, как и в случае с методом all, но их отличие в том, что он ждем выполнения всех Promises, неважно, успешны они или нет.

_Метод allSettled() никогда не отклоняется _

Данный метод возвращает массив объектов, который имеет следующий вид:

{
   status // "fulfilled" или "rejected"
   value? // Значение, когда status = "fulfilled", хранит данные по успешной операции
   reason? // Значение, когда status = "rejected", хранит данные по ошибке
}
Enter fullscreen mode Exit fullscreen mode
  1. 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); // Массив ошибок
});
Enter fullscreen mode Exit fullscreen mode

Результат будет иметь следующий вид:

Ошибка! Error name: AggregateError
Ошибка! Error errors: [ 'bad 1', 'bad 2', 'bad 3' ]
Enter fullscreen mode Exit fullscreen mode
  1. race() - метод, который также принимает массив в качестве входных параметров, а то, разрешается он или отклоняется, зависит от первого завершённого Promise. То есть race - это, как и any тоже своего рода организатор гонок, только победителем может быть как успешно завершенный Promise, так и отклоненный с ошибкой.

  2. reject() - метод, который возвращает отклоненный Promise по указанной причине. Мы уже использовали его ранее.

  3. resolve() - метод, который возвращает успешный Promise с указанным значением.

  4. try() - метод, чтобы была возможность запустить функцию, которая может быть как синхронной, так и асинхронной, и вернуть Promise.

  5. Если callback функция выполняется синхронно и возвращает значение, то данный метод возвращает успешный Promise с этим значением, который потом можно обработать в then().

  6. Если callback функция выполняется синхронно и возвращает ошибку, то данный метод возвращает отклоненный Promise с этой ошибкой, который потом можно обработать в catch().

  7. Если callback функция возвращает другой Promise, то данный метод вернет этот Promise.

Рассмотрим пример:

const result = Promise.try(() => {
  throw new Error('Error');
});

result.catch(error => console.error(error.message)); // Error
Enter fullscreen mode Exit fullscreen mode

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

  1. 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)); // Успех!
Enter fullscreen mode Exit fullscreen mode

Связь с async/await

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

Под капотом async/await работает на основе Promise, а также функций генераторов.

Если перед функцией поставить ключевое слово async, то данная функция автоматически будет возвращать Promise.

Рассмотрим пример:

async function getNumber() {
  return 42;
}

getNumber().then(number => console.log(number)); // 42
Enter fullscreen mode Exit fullscreen mode

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

function getNumber() {
  return Promise.resolve(42);
}
Enter fullscreen mode Exit fullscreen mode

Ключевое слово await можно использовать только внутри функции, перед которой стоит async, то есть внутри async-функции. Оно заставляет интерпретатор приостановить выполнении функции до тех пор, пока Promise не будет завершен.

Алгоритм работы:

  1. Если Promise завершился с успехом, то await возвращает его результат.
  2. Если 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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


Заключение

Promise - это фундаментальная блок асинхронного кода, который позволил разработчикам уйти от callback hell и сделать код более предсказуемым, удобочитаемым и элегантным.

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

Теперь спокойно можно покорять вершины асинхронности, самое главное не прибегать к callback hell :)

Top comments (0)