Використання промісів
Promise
– це об'єкт, що представляє завершення або невдачу асинхронної операції. Оскільки більшість людей користуються вже створеними промісами, цей посібник спершу пояснить використання повернених промісів, а потім – як їх створювати.
По суті проміс – це повернений об'єкт, до котрого приєднуються функції зворотного виклику, замість їх передачі до функцій. Уявіть функцію, createAudioFileAsync()
, котра асинхронно породжує звуковий файл згідно з заданою конфігурацією, і дві функції зворотного виклику: одна викликається, якщо файл успішно створений, а інша – якщо трапляється помилка.
Ось трохи коду, що використовує createAudioFileAsync()
:
function successCallback(result) {
console.log(`Аудіофайл готовий за URL: ${result}`);
}
function failureCallback(error) {
console.error(`Помилка породження аудіофайлу: ${error}`);
}
createAudioFileAsync(audioSettings, successCallback, failureCallback);
Якби createAudioFileAsync()
була переписана для повертання промісу, натомість до неї прикріплювали б функції зворотного виклику:
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);
Така домовленість має декілька переваг. Нижче – огляд кожної з них
Утворення ланцюжків
Поширена потреба – виконати дві чи більше асинхронних операцій одну за одною, щоб кожна наступна операція починалася, коли успішно завершується попередня, з використанням результату попереднього кроку. В старі часи виконання декількох асинхронних операцій по черзі призводило до класичної піраміди безнадії з функцій зворотного виклику:
doSomething(function (result) {
doSomethingElse(
result,
function (newResult) {
doThirdThing(
newResult,
function (finalResult) {
console.log(`Отриманий остаточний результат: ${finalResult}`);
},
failureCallback,
);
},
failureCallback,
);
}, failureCallback);
З промісами таке досягається шляхом створення ланцюжка промісів. Устрій API промісів справляється з цим чудово, бо функції зворотного виклику прикріпляються до поверненого об'єкта проміса, а не передається до функції.
Ось магія: функція then()
повертає новий проміс, відмінний від вихідного:
const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);
Цей другий проміс (promise2
) представляє завершення не лише doSomething()
, а й також переданого successCallback
або failureCallback
– що може бути іншою асинхронною функцією, котра повертає проміс. Коли це саме так, то всі функції зворотного виклику, додані до promise2
, стають у чергу за промісом, поверненим або з successCallback
, або з failureCallback
.
За допомогою цього патерну можна створювати довші ланцюжки обробки, де кожний проміс представляє завершення одного асинхронного кроку в ланцюжку. На додачу, аргументи функції then
– необов'язкові, а catch(failureCallback)
– скорочення для then(null, failureCallback)
, – тож коли код обробки помилок один і той же для всіх кроків, його можна додати в кінець ланцюжка:
doSomething()
.then(function (result) {
return doSomethingElse(result);
})
.then(function (newResult) {
return doThirdThing(newResult);
})
.then(function (finalResult) {
console.log(`Отриманий остаточний результат: ${finalResult}`);
})
.catch(failureCallback);
Можна зустріти вираження цього за допомогою стрілкових функцій:
doSomething()
.then((result) => doSomethingElse(result))
.then((newResult) => doThirdThing(newResult))
.then((finalResult) => {
console.log(`Отриманий остаточний результат: ${finalResult}`);
})
.catch(failureCallback);
Важливо: Слід завжди повертати результати, інакше функції зворотного виклику не захоплять результат попереднього промісу (в стрілкових функціях () => x
є коротким записом () => { return x; }
). Якщо попередній обробник запустив проміс, але не повернув його, то неможливо відстежити залагодження такого проміса, і про проміс кажуть, що він "повис".
doSomething()
.then((url) => {
// Я забув повернути це
fetch(url);
})
.then((result) => {
// result – undefined, тому що нічого не було повернено з
// попереднього обробника.
// Тепер неможливо дізнатися повернене значення виклику fetch(),
// як і те, чи завершився цей виклик успіхом взагалі.
});
Справи підуть куди гірше, якщо є стан перегонів: якщо проміс з попереднього обробника не був повернутий, то наступний обробник then
буде викликаний зарано, і значення, котре він отримає, може бути неповним.
const listOfIngredients = [];
doSomething()
.then((url) => {
// Я забув це повернути
fetch(url)
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
});
})
.then(() => {
console.log(listOfIngredients);
// Завжди [], бо запит на отримання іще не був завершений.
});
Таким чином, досвід підказує, що кожного разу, коли операція зустрічає проміс, його слід повернути й делегувати його обробку наступному обробникові then
.
const listOfIngredients = [];
doSomething()
.then((url) =>
fetch(url)
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
}),
)
.then(() => {
console.log(listOfIngredients);
});
// АБО
doSomething()
.then((url) => fetch(url))
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
})
.then(() => {
console.log(listOfIngredients);
});
Вкладення
У двох прикладах вище, перший має один ланцюжок промісів, вкладений у повернене значення іще одного обробника then()
, а другий – використовує цілком плаский ланцюжок. Прості ланцюжки промісів найкраще залишати пласкими, без вкладення, оскільки вкладення може бути наслідком бездумної композиції. Докладніше про це – у поширених помилках.
Вкладення є контрольною структурою для обмеження області дії інструкцій catch
. А саме, вкладений catch
перехоплює невдачі лише зі своєї області дії та нижче, але не помилки вище в ланцюжку поза вкладеною областю. Коли це застосовується правильно, то дає більшу точність при відновленні від помилок:
doSomethingCritical()
.then((result) =>
doSomethingOptional(result)
.then((optionalResult) => doSomethingExtraNice(optionalResult))
.catch((e) => {}),
) // Ігнорувати, якщо необов'язкові штуки не вдаються; продовжувати.
.then(() => moreCriticalStuff())
.catch((e) => console.error(`Critical failure: ${e.message}`));
Зверніть увагу, що необов'язкові кроки – вкладені, і вкладеність спричинена не відступами, а поміщенням навколо таких кроків зовнішніх дужок (
і )
.
Внутрішня глушилка помилок catch
перехоплює лише невдачі з doSomethingOptional()
і doSomethingExtraNice()
, після чого код продовжує роботу з moreCriticalStuff()
. Що важливіше, якщо не вдається doSomethingCritical()
, то така помилка перехоплюється лише прикінцевим (зовнішнім) catch
, і не поглинається внутрішнім обробником catch
.
Ланцюжок промісів після catch
Можна утворювати ланцюжок промісів після невдачі, тобто catch
, котрий корисний для виконання нових дій навіть тоді, коли в ланцюжку провалилось виконання однієї з операцій. Приклад:
new Promise((resolve, reject) => {
console.log("Початок");
resolve();
})
.then(() => {
throw new Error("Щось не вийшло");
console.log("Дії такі");
})
.catch(() => {
console.error("Дії сякі");
})
.then(() => {
console.log("Дії незалежно від того, що відбувалося раніше");
});
Цей код виведе наступний текст:
Початок
Дії сякі
Дії незалежно від того, що відбувалося раніше
Примітка: Текст "Дії такі" не виводиться, адже помилка "Щось не вийшло" спричинила відмову.
Поширені помилки
Тут зібрані кілька поширених помилок, котрих слід остерігатися при композиції ланцюжків промісів. Деякі з них проявляються в наступному прикладі:
// Поганий приклад! Знайдіть 3 помилки!
doSomething()
.then(function (result) {
// Забули повернути проміс зі внутрішнього ланцюжка + даремна вкладеність
doSomethingElse(result).then((newResult) => doThirdThing(newResult));
})
.then(() => doFourthThing());
// Забули завершити ланцюжок із catch!
Перша помилка – не з'єднувати все правильно в ланцюжок. Так виходить, коли створюється новий проміс, але його забувають повернути. Як наслідок, ланцюжок розривається – або радше виходять два незалежні ланцюжки, що виконуються окремо. Це означає, що doFourthThing()
не чекатиме завершення doSomethingElse()
чи doThirdThing
, а запуститься паралельно з ними – що, ймовірно, не було задумано. Окремі ланцюжки також мають окрему обробку помилок, що призводить до неперехоплених помилок.
Друга помилка – вкладення без потреби, що призводить до помилки першої. Крім цього, вкладення обмежує область дії внутрішніх обробників помилок, що – якщо не було так задумано – може призвести до неперехоплених помилок. Варіацією цієї проблеми є антипатерн конструктора промісів, котрий поєднує вкладення з даремним застосуванням конструктора промісів для огортання коду, що вже містить проміси.
Третя помилка – забути завершити ланцюжок із catch
. Незавершені ланцюжки промісів у більшості браузерів призводять до неперехоплених відхилень промісів. Дивіться обробку помилок нижче.
Добре емпіричне правило – завжди або повертати, або завершувати ланцюжки промісів, і щойно отримано новий проміс, негайно його повертати, аби все сплощити:
doSomething()
.then(function (result) {
// При застосуванні повного виразу функції: повернути проміс
return doSomethingElse(result);
})
// При застосуванні стрілкових функцій: опустити дужки й неявно повернути результат
.then((newResult) => doThirdThing(newResult))
// Навіть коли попередній проміс в ланцюжку повертає результат, то наступний
// не зобов'язаний його використовувати. Можна передати обробник, котрий не
// приймає жодних попередніх результатів.
.then((/* результат проігноровано */) => doFourthThing())
// Завжди ставте в кінець ланцюжка промісів обробник catch, аби уникнути
// будь-яких необроблених відхилень!
.catch((error) => console.error(error));
Зверніть увагу, що () => x
– це скорочення для () => { return x; }
.
Тепер маємо один детерміністичний ланцюжок з коректною обробкою помилок.
Застосування async
і await
вирішує більшість, якщо не всі ці проблеми; компроміс же полягає в тому, що легко можна забути додати ключове слово await
.
Обробка помилок
Можливо, ви пригадуєте повторення failureCallback
тричі в піраміді загибелі вище; порівняйте з лишень одним разом в кінці ланцюжка промісів:
doSomething()
.then((result) => doSomethingElse(result))
.then((newResult) => doThirdThing(newResult))
.then((finalResult) =>
console.log(`Отриманий остаточний результат: ${finalResult}`),
)
.catch(failureCallback);
Якщо стався виняток, браузер шукатиме в ланцюжку згори вниз обробники .catch()
або onRejected
. Це по суті засновано на тому, як працює синхронний код:
try {
const result = syncDoSomething();
const newResult = syncDoSomethingElse(result);
const finalResult = syncDoThirdThing(newResult);
console.log(`Отриманий остаточний результат: ${finalResult}`);
} catch (error) {
failureCallback(error);
}
Кульмінацією цієї симетрії з синхронним кодом є синтаксис async
і await
:
async function foo() {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Отриманий остаточний результат: ${finalResult}`);
} catch (error) {
failureCallback(error);
}
}
Він збудований на промісах, — наприклад, doSomething()
– та сама функція, що й раніше, тож для заміни промісів на async
і await
потрібен геть мінімальний рефакторинг. Більше про синтаксис async
і await
– на довідкових сторінках асинхронних функцій і await
.
Проміси розв'язують фундаментальну проблему з пірамідою безнадії функцій зворотного виклику, перехоплюючи всі помилки, навіть викинуті винятки й помилки програмування. Це критично для функційної композиції асинхронних операцій.
Події відхилення промісів
Якщо подія відхилення промісів не обробляється жодним обробником, то вона виринає на вершину стека викликів, і хост мусить її вивести на поверхню. У Вебі, щоразу, коли відхиляється проміс, у глобальну область видимості надсилається одна з двох подій (загалом, це або window
, або, якщо це трапилося у вебворкері, це Worker
чи інший інтерфейс, заснований на воркері). Ці дві події:
unhandledrejection
Надсилається, коли проміс відхилено, але немає обробника відхилення.
rejectionhandled
Надсилається, коли обробник прикріплений до відхиленого проміса, що вже призвів до події
unhandledrejection
.
В обох випадках подія (типу PromiseRejectionEvent
) містить властивість promise
, котра вказує, який проміс був відхилений, та властивість reason
, котра надає причину, через яку він був відхилений.
Ці події дають змогу запропонувати запасну обробку помилок у промісах, а також допомагають зневаджувати проблеми з управлінням промісами. Ці обробники є глобальними для кожного контексту, тому всі помилки підуть в одні й ті самі обробники подій, незалежно від їх джерела.
У Node.js обробка відхилення промісів дещо відрізняється. Необроблені відхилення перехоплюються шляхом додавання обробника для події Node.js unhandledRejection
(зверніть увагу на присутність великої літери в назві), отак:
process.on("unhandledRejection", (reason, promise) => {
// Сюди додати код для дослідження значень "promise" і "reason"
});
На Node.js для запобігання виведенню помилки в консоль (усталеної реакції, що відбулась би інакше) додати такого слухача process.on()
– усе що потрібно; немає потреби в чомусь рівносильному методові браузерного середовищаpreventDefault()
.
Проте якщо додати такого слухача process.on
, але без коду всередині нього, що обробляє відхилені проміси, ці проміси просто впадуть на землю й будуть тихо проігноровані. Тож в ідеалі слід додати в слухача код, що досліджує кожний відхилений проміс і пересвідчується, що відхилення не було спричинено реальною вадою коду.
Композиція
Є чотири інструменти композиції для рівночасного запуску асинхронних операцій: Promise.all()
, Promise.allSettled()
, Promise.any()
і Promise.race()
.
Запустити операції паралельно й чекати, поки всі вони виконаються, можна отак:
Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
// використання result1, result2 і result3
});
Коли один зі промісів у масиві відхиляється, то Promise.all()
негайно відхиляє повернений проміс та перериває решту операцій. Це може призвести до неочікуваних стану чи поведінки. Promise.allSettled()
– іще один інструмент композиції, котрий дочекається перед своїм вирішенням завершення всіх операцій.
Всі ці методи запускають проміси паралельно: послідовність промісів починається водночас і не чекає одне на одного. Послідовна композиція можлива за допомогою дещо хитрого JavaScript:
[func1, func2, func3]
.reduce((p, f) => p.then(f), Promise.resolve())
.then((result3) => {
/* використання result3 */
});
У цьому прикладі масив асинхронних функцій зводиться до ланцюжка промісів. Код вище – рівносильний щодо:
Promise.resolve()
.then(func1)
.then(func2)
.then(func3)
.then((result3) => {
/* використання result3 */
});
Це можна перетворити на повторно використовну функцію композиції, що поширено в функційному програмуванні:
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);
Послідовна композиція також може бути реалізована більш стисло за допомогою async/await:
let result;
for (const f of [func1, func2, func3]) {
result = await f(result);
}
/* використання останнього результату (тобто result3) */
Проте перед послідовною композицією промісів слід подумати, чи справді це необхідно: завжди краще запускати проміси паралельно, щоб вони не блокували даремно одне одного, якщо результат одного проміса не залежить від результату іншого.
Створення Promise навколо старого API з функціями зворотного виклику
Promise
можна створити з нуля за допомогою конструктора. Це повинно бути необхідно лише для загортання старих API.
В ідеальному світі усі асинхронні операції вже повинні повертати проміси. На жаль, частина API досі очікує на передачу функцій зворотного виклику для успіху чи невдачі, за старим стилем. Найочевидніший приклад цього – функція setTimeout()
:
setTimeout(() => saySomething("10 секунд минули"), 10 * 1000);
Змішування функцій зворотного виклику й промісів – проблематичне. Якщо saySomething()
зазнає невдачі або містить помилку програміста, то ніщо цього не перехопить. Така природа устрою setTimeout
.
На щастя, setTimeout
можна загорнути в проміс. Найкраща практика – загортати функції, що приймають функції зворотного виклику, на найнижчому з можливих рівнів, а потім більше ніколи не викликати їх безпосередньо:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(10 * 1000)
.then(() => saySomething("10 секунд"))
.catch(failureCallback);
Конструктор промісів приймає функцію-виконавця, котра дає змогу вручну вирішити або відхилити проміс. Оскільки setTimeout
насправді не зазнає невдач, у цьому випадку можна упустити відхилення. Більше про те, як працює функція-виконавець, – на довідковій сторінці Promise()
.
Хронометраж
І наостанок – погляньмо на більш технічні деталі, на те, коли викликаються зареєстровані функції зворотного виклику.
Гарантії
В API на основі функцій зворотного виклику те, коли і як ці функції викликаються, залежить від автора API. Наприклад, вони можуть бути викликані синхронно або асинхронно:
function doSomething(callback) {
if (Math.random() > 0.5) {
callback();
} else {
setTimeout(() => callback(), 1000);
}
}
Наведений вище підхід наполегливо не рекомендується, адже призводить до так званого "стану Зальґо". У контексті проєктування асинхронних API це означає, що функція зворотного виклику викликається синхронно в одних випадках, але асинхронно в інших, призводячи до неоднозначності для того, хто її викликає. Більше контексту на цю тему можна знайти в статті Проєктування API з асинхронністю, де цей термін уперше вбув формально введений. Такий підхід до створення API призводить до того, що побічні ефекти важко аналізувати:
let value = 1;
doSomething(() => {
value = 2;
});
console.log(value); // 1 чи 2?
З іншого боку, проміси є формою інверсії контролю: автор API не контролює того, коли викликається функція зворотного виклику. Натомість справа підтримки черги функцій зворотного виклику та прийняття рішень про те, коли викликати функції зворотного виклику, делегується реалізації промісів, тож водночас і користувач API, і його автор – автоматично отримують потужні семантичні гарантії, серед яких:
- Функції зворотного виклику, додані за допомогою
then()
, ніколи не закликаються до завершення поточного спрацювання циклу подій JavaScript. - Ці функції зворотного виклику закликаються, якщо вони були додані після успіху чи невдачі асинхронної операції, котру представляє відповідний проміс.
- Шляхом багаторазового виклику
then()
можуть бути додані кілька функцій зворотного виклику. Вони закликаються одна після одної, згідно з порядком, в якому були додані.
Для уникнення сюрпризів функції, передані в then()
, ніколи не викликаються синхронно, навіть для вже вирішеного промісу
Promise.resolve().then(() => console.log(2));
console.log(1);
// Виводить: 1, 2
Замість негайного виконання передана функція ставиться в чергу мікрозавдань, а отже – вона спрацьовує пізніше (лише після того, як відбувається вихід з функції, котра її створила, і коли стек виконання JavaScript порожній), лишень перед тим, як контроль повертається циклові подій; тобто доволі скоро:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(0).then(() => console.log(4));
Promise.resolve()
.then(() => console.log(2))
.then(() => console.log(3));
console.log(1); // 1, 2, 3, 4
Задачі й мікрозавдання
Функції зворотного виклику промісів обробляються як мікрозавдання, натомість функції зворотного виклику setTimeout()
обробляються як прості задачі.
const promise = new Promise((resolve, reject) => {
console.log("Функція зворотного виклику промісу");
resolve();
}).then((result) => {
console.log("Функція зворотного виклику промісу (.then)");
});
setTimeout(() => {
console.log("цикл подій: проміс (сповнений)", promise);
}, 0);
console.log("Проміс (в очікуванні)", promise);
Код вище виведе:
Функція зворотного виклику промісу
Проміс (в очікуванні) Promise {<pending>}
Функція зворотного виклику промісу (.then)
цикл подій: проміс (сповнений) Promise {<fulfilled>}
Подробиці доступні в Задачах і мікрозавданнях.
Коли проміси й задачі стикаються
Коли трапляється ситуація, в якій проміси й задачі (як то події чи функції зворотного виклику) спрацьовують у непередбачуваному порядку, можливо, може бути корисним використання мікрозавдання для перевірки статусу чи балансування промісів, коли проміси створюються умовно.
Якщо здається, що допомогти розв'язати проблему можуть мікрозавдання, дивіться посібник з мікрозавдань, аби дізнатися, як використати queueMicrotask()
для постановки функції в чергу як мікрозавдання.
Дивіться також
Promise
async function
await
- Специфікація Promises/A+
- Маємо проблему з промісами на pouchdb.com (2015)