Використання промісів

Promise (з англ. - "обіцянка", далі - "проміс") - це об'єкт, що відображає кінцеве завершення або невдачу асинхронної операції. Оскільки більшість людей є споживачами раніше створенних промісів, цей посібник спочатку пояснить споживання повернених промісів, а далі пояснить, як їх створювати.

Проміс, по суті, є поверненим об'єктом, до якого ви прикріплюєте функції зворотного виклику, замість того, щоб передавати їх у функцію.

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

Ось код, який використовує createAudioFileAsync():

function successCallback(result) {
  console.log("Аудіофайл створений за адресою: " + result);
}

function failureCallback(error) {
  console.log("Під час створення аудіофайлу виникла помилка: " + error);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

Замість цього, сучасні функції повертають проміс, до якого ви можете приєднати функції зворотного виклику:

Якщо переписати функцію createAudioFileAsync(), щоб вона повертала проміс, її використання буде ось таким простим:

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

Це скорочений запис для:

const promise = createAudioFileAsync(audioSettings); 
promise.then(successCallback, failureCallback);

Ми називаємо це асинхронним викликом функції.  Ця конвенція має декілька переваг. Ми дослідимо кожну з них.

Гарантії

На відміну від старомодних колбеків, проміс постачається з певними гарантіями:

  • Функції зворотного виклику ніколи не будуть викликані до завершення поточного виконання циклу подій JavaScript.
  • Функції зворотного виклику, додані за допомогою then(), навіть після успіху або невдачі асинхронної операції, будуть викликані, як наведено вище.
  • Можна додати більше одного зворотного виклику, викликавши метод then() декілька разів. Кожен зворотний виклик виконується один за одним, у тому порядку, в якому вони були додані.

Однією з величезних переваг промісів є ланцюгування.

Ланцюгування

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

Ось вам магія: функція then() повертає новий проміс, що відрізняється від оригіналу:

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

або

const promise2 = doSomething().then(successCallback, failureCallback);

Цей другий проміс (promise2) представляє собою завершення не тільки doSomething(), але й successCallback або failureCallback, які ви передали, вони, в свою чергу, можуть бути іншими асинхронними функціями, що повертають проміс. В цьому випадку будь-які функції зворотного виклику, додані до promise2, стають в чергу за промісом, що повертається successCallback чи failureCallback.

По суті, кожен проміс відображає завершення іншого асинхроннго кроку в ланцюжку.

В старі часи виконання декількох асинхронних операцій підряд призвело б до класичної піраміди смерті з колбеків:

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Ось фінальний результат: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

З сучасними функціями замість цього ми приєднуємо наші функції зворотного виклику до промісів, що повертаються, формуючи ланцюжок промісів.

doSomething().then(function(result) {
  return doSomethingElse(result);
})
.then(function(newResult) {
  return doThirdThing(newResult);
})
.then(function(finalResult) {
  console.log('Ось фінальний результат: ' + finalResult);
})
.catch(failureCallback);

Аргументи до then є необов'язковими, а catch(failureCallback) - це скорочений запис для  then(null, failureCallback). Ви можете побачити це, виражене за допомогою стрілкових функцій:

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
  console.log(`Ось фінальний результат: ${finalResult}`);
})
.catch(failureCallback);

Важливо: Завжди повертайте результати, інакше функції зворотного виклику не перехоплять результат попереднього проміса (у стрілкових функціях () => x є скороченим записом для () => { return x; }).

Ланцюгування після catch

Ланцюгувати після невдачі можливо, наприклад, catch є корисним у разі виконання нових операцій навіть після того, як операція у ланцюжку завершилась неуспішно. Дивіться наступний приклад:

new Promise((resolve, reject) => {
    console.log('Початковий');

    resolve();
})
.then(() => {
    throw new Error('Щось пішло не так');
        
    console.log('Зробити це');
})
.catch(() => {
    console.log('Зробити те');
})
.then(() => {
    console.log('Зробити це, що б не відбувалось раніше');
});

Це виведе наступний текст:

Початковий
Зробити те
Зробити це, що б не відбувалось раніше

Зауважте: Текст "Зробити це" не був виведений, тому що помилка "Щось пішло не так" спричинила відхилення.

Спливання помилок

Ви, можливо, пригадуєте, що тричі бачили failureCallback раніше, у піраміді смерті, у порівнянні з лише одним викликом наприкінці ланцюжку промісів.

doSomething()
.then(result => doSomethingElse(value))
.then(newResult => doThirdThing(newResult))
.then(finalResult => console.log(`Ось фінальний результат: ${finalResult}`))
.catch(failureCallback);

Якщо виникає виняток, переглядач передивляється ланцюжок у пошуках обробників catch або onRejected. Це дуже схоже на модель того, як працює синхронний код:

try {
  let result = syncDoSomething();
  let newResult = syncDoSomethingElse(result);
  let finalResult = syncDoThirdThing(newResult);
  console.log(`Ось фінальний результат: ${finalResult}`);
} catch(error) {
  failureCallback(error);
}

Ця симетрія з асинхронним кодом сягає кульмінації в синтаксичному цукрі async/await в ECMAScript 2017:

async function foo() {
  try {
    let result = await doSomething();
    let newResult = await doSomethingElse(result);
    let finalResult = await doThirdThing(newResult);
    console.log(`Ось фінальний результат: ${finalResult}`);
  } catch(error) {
    failureCallback(error);
  }
}

Він будується на промісах, наприклад, doSomething() - це та сама функція, що й раніше. Ви можете прочитати більше про синтаксис тут.

Проміси виправляють фундаментальну хибу з пірамідою смерті, перехоплюючи всі помилки, навіть викинуті винятки та помилки программування. Це критично важливо для функціональної композиції асинхронних операцій.

Події відхилення промісів

Коли проміс відхиляється, одна з двох подій надсилається у глобальну область видимості (загалом, це або window, або, при використанні у веб-виконавці, це Worker або інший інтерфейс на базі виконавців). Ці дві події наступні:

rejectionhandled
Надсилається, коли проміс відхиляється, після того, як відхилення було оброблене функцією виконавця reject.
unhandledrejection
Надсилається, коли проміс відхиляється, але немає доступного обробника відхилення.

У обох випадках подія (типу PromiseRejectionEvent) має в якості полів властивість promise, яка вказує відхилений проміс, та властивість reason, яка передає надану причину відхилення проміса.

Це робить можливою резервну обробку помилок для промісів, а також допомагає відлагоджувати проблеми в управлінні промісами. Ці обробники є глобальними за контекстом, тому усі помилки потраплятимуть в однакові обробники подій, незалежно від джерела.

Один випадок особливої корисності: при написанні коду для Node.js, зазвичай, модулі, які ви включаєте у свій проект, можуть мати необроблені відхилені проміси. Вони виводяться у консоль середовищем виконання Node. Ви можете перехопити їх для аналізу та обробити у своєму коді — або просто уникнути захаращення ними виводу даних — додавши обробник події unhandledrejection, ось так:

window.addEventListener("unhandledrejection", event => {
  /* Ви можете почати тут, додавши код, щоб дослідити 
     вказаний проміс через event.promise та причину у
     event.reason */

  event.preventDefault();
}, false);

Викликавши метод події preventDefault(), ви кажете середовищу виконання JavaScript не виконувати дію за замовчуванням, коли відхилений проміс лишається необробленим. Ця дія зазвичай містить виведення помилки у консоль, а це якраз випадок для Node.

В ідеалі, звісно, ви маєте досліджувати відхилені проміси, щоб бути певними, що жоден з них не є насправді помилкою коду, перед тим, як відкидати ці події.

Створення промісу на основі старого API зі зворотним викликом

Проміс може бути створенний з нуля за допогою свого конструктора. Це необхідно лише для обгортки старих API.

В ідеальному світі всі асинхронні функції повертали б проміси. На жаль, деякі API досі очікують старомодну передачу функцій зворотного виклику для успіху та/або невдачі. Найбільш очевидним прикладом є функція setTimeout() :

setTimeout(() => saySomething("Пройшло 10 секунд"), 10000);

Змішування старомодних зворотних викликів та промісів є проблематичним. Якщо saySomething завершиться невдачею або міститиме помилку программування, ніщо її не перехопить.

На щастя, ми можемо обгорнути saySomething у проміс. Найкращою практикою вважається обгортати проблематичні функції на якомога нижчому рівні і вже ніколи не звертатись до них прямо.

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait(10000).then(() => saySomething("10 секунд")).catch(failureCallback);

По суті, конструктор промісу приймає функцію виконання, яка дозволяє вирішити або відхилити проміс вручну. Оскільки setTimeout, насправді, ніколи не завершується невдало, ми пропускаємо функцію відхилення в цьому випадку.

Композиція

Методи Promise.resolve() та Promise.reject() є скороченими записами для створення вручну вже вирішених або відхилених промісів відповідно. Інколи це може бути корисно.

Методи Promise.all() та Promise.race() є двома інструментами композиції для паралельного виконання асинхронних операції.

Ми можемо почати операції паралельно та чекати, доки вони усі не завершаться ось так:

Promise.all([func1(), func2(), func3()])
.then(([result1, result2, result3]) => { /* використати result1, result2 та result3 */ });

Послідовна композиція можлива з використанням певного розумного JavaScript:

[func1, func2, func3].reduce((p, f) => p.then(f), Promise.resolve())
.then(result3 => { /* використати result3 */ });

По суті, ми зменшуємо масив асинхронних функцій до ланцюжка промісів, еквівалентного: Promise.resolve().then(func1).then(func2).then(func3);

Це можна перетворити на композиційну функцію багаторазового використання, що є типовим у функціональному програмуванні:

const applyAsync = (acc,val) => acc.then(val);
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));

Функція composeAsync() прийме будь-яку кількість функцій в якості аргументів і поверне нову функцію, яка приймає початкове значення, що має пройти крізь конвеєр композиції:

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

В ECMAScript 2017 послідовну композицію можна виконати простіше, за допомогою async/await:

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* використати останній результат (тобто, result3) */

Хронометраж

Щоб уникнути сюрпризів, функції, передані до then(), ніколи не викликатимуться синхронно, навіть для вже вирішеного проміса:

Promise.resolve().then(() => console.log(2));
console.log(1); // 1, 2

Замість негайного виконання, передані функції ставляться у чергу мікрозадач. Це означає, що вони виконуються пізніше, коли черга стає порожньою в кінці поточного виконання циклу подій JavaScript, тобто, досить скоро:

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait().then(() => console.log(4));
Promise.resolve().then(() => console.log(2)).then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

Вкладеність

Прості ланцюжки промісів найкраще тримати рівними, без вкладень, оскільки вкладення можуть бути результатом недбалої композиції. Дивіться типові помилки.

Вкладеність є контролюючою структурою для обмеження області видимості блоків catch. Зокрема, вкладений catch перехоплює лише помилки своєї області видимості та нижче, але не ті помилки, що знаходяться вище у ланцюжку поза вкладеною областю видимості. При правильному використанні це надає більшу точність у виявленні помилок:

doSomethingCritical()
.then(result => doSomethingOptional(result)
  .then(optionalResult => doSomethingExtraNice(optionalResult))
  .catch(e => {})) // Ігнорувати, якщо не працює щось другорядне; продовжити.
.then(() => moreCriticalStuff())
.catch(e => console.error("Критична помилка: " + e.message));

Зауважте, що необов'язкові кроки тут вкладені, не для відступів, але для передбачливого розташування зовнішніх дужок ( та ) навколо.

Внутрішній нейтралізуючий блок catch перехоплює помилки тільки від doSomethingOptional() та doSomethingExtraNice(), після чого виконання коду продовжується у moreCriticalStuff(). Що важливо, якщо функція doSomethingCritical() завершується невдало, її помилка перехоплюється тільки кінцевим (зовнішнім) блоком catch.

Типові помилки

Ось деякі типові помилки, яких варто остерігатися при складанні ланцюжків промісів. Декілька з цих помилок проявляються у наступному прикладі:

// Поганий приклад! Помічено 3 помилки!

doSomething().then(function(result) {
  doSomethingElse(result) // Забули повернути проміс з внутрішнього ланцюжка + непотрібне вкладення
  .then(newResult => doThirdThing(newResult));
}).then(() => doFourthThing());
// Забули завершити ланцюжок блоком catch!
</pre>

Перша помилка - не завершувати ланцюжки як слід. Це відбувається, коли ми створюємо новий проміс, але забуваємо його повернути. Як наслідок, ланцюг переривається, чи, скоріше, ми отримуємо два конкуруючі ланцюжки. Це означає, що doFourthThing() не чекатиме на завершення doSomethingElse() чи doThirdThing() і запуститься паралельно з ними, скоріше за все, ненавмисно. В окремих ланцюжках також окремо обробляються помилки, що призводить до неперехоплених помилок.

Друга помилка - непотрібна вкладеність, що уможливлює першу помилку. Вкладеність також обмежує область видимості внутрішніх обробників помилок, а це — якщо зроблене ненавмисно — може призвести до неперехоплених помилок. Варіантом цього є антишаблон конструювання промісів, який поєднує вкладення з надлишковим використанням конструктора промісів для загортання коду, який вже використовує проміси.

Третя помилка - забувати завершувати ланцюжки блоком catch. Незавершені ланцюжки промісів призводять до неперехоплених відхилень промісів у більшості переглядачів.

Гарний практичний підхід - завжди або повертати, або завершувати ланцюжки промісів, і, як тільки ви отримуєте новий проміс, повертати його негайно, щоб вирівняти ланцюжок:

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(newResult => doThirdThing(newResult))
.then(() => doFourthThing())
.catch(error => console.error(error));

Зауважте, що () => x є скороченням для () => { return x; }.

Тепер ми маємо єдиний, детермінований ланцюжок з правильною обробкою помилок.

Використання async/await вирішує більшість, якщо не всі ці проблеми — натомість, найпоширенішою помилкою при використанні цього синтаксису є забуте ключове слово await.

Коли зустрічаються задачі та проміси

Якщо ви стикаєтеся з ситуаціями, коли у вас є проміси та задачі (такі, як події або зворотні виклики), які запускаються у непередбачуваному порядку, можливо, вам буде корисно скористатись мікрозадачами, щоб перевірити статус або збалансувати проміси, коли створення промісів залежить від певних умов.

Якщо ви вважаєте, що мікрозадачі могли б вирішити цю проблему, дивіться посібник з мікрозадач, щоб дізнатись, як використовувати метод queueMicrotask(), щоб поставити функцію у чергу як мікрозадачу.

Дивіться також