Уявіть, що ви топ співак, фани якого постійно дошкуляють запитаннями про майбутній сингл.
Щоб отримати передишку, ви обіцяєте розіслати їм сингл, коли він буде випущений. Ви даєте своїм фанатам список. В який вони можуть записати свій e-mail, на який вони отримають пісню як тільки вона вийде. І навіть якщо щось піде не так, наприклад, в студії станеться пожежа і ви не зможете випустити пісню, вони й про це отримають сповіщення.
Всі щасливі: ви, тому що вам більше не дошкуляють, а фанати, тому що можуть більше не переживати що пропустять новий сингл.
Це аналогія з реального життя для ситуацій з якими ми часто стикаємось в програмуванні:
- Є код–“виробник”, котрий щось робить, що займає час. Наприклад, завантажує дані з мережі. Згідно з нашою аналогією це “співак”.
- Є код–“споживач”, котрий очікує отримати результат від коду-“виробника”, як тільки той буде готовий. Цей результат може знадобитися багатьом функціям. Ці функції – “фанати”
- Promise (надалі будемо називати такий об’єкт промісом) – це спеціальний об’єкт в JavaScript, котрий зв’язує код-“виробника” і “споживача” разом. В контексті нашої аналогії – це “список підписки”. Код-“виробник” виконується стільки часу, скільки потрібно щоб отримати результат, а проміс – як тільки результат готовий, робить його доступним для коду який підписався на конкретний проміс.
Аналогія не зовсім точна, оскільки реалізація промісів в JavaScript набагато складніша ніж простий список підписок: вони володіють додатковими можливостями й обмеженнями. Але для початку варто почати з простішого.
Синтаксис створення проміса:
let promise = new Promise(function(resolve, reject) {
// код, котрий поверне результат (код–"виробник", "співак")
});
Функція передана в new Promise
називається виконавцем. Коли створюється new Promise
вона виконується автоматично. В ній знаходиться код “виробник” котрий зрештою поверне результат. В контексті нашої аналогії: виконавець – це “співак”.
Його аргументи resolve
і reject
– це колбеки які надає нам сам JavaScript. Наш код – тільки всередині виконавця.
Коли функція-виконавець завершить свою роботу, неважливо – зараз чи пізніше, вона повинна викликати один з цих колбеків:
resolve(value)
– якщо код успішно виконався, з результатомvalue
.reject(error)
– якщо виникла помилка,error
– об’єкт помилки.
Отже, виконавець запускається автоматично і намагається виконати роботу. А потім викликає метод resolve
якщо спроба була успішною або reject
якщо виникла помилка.
В об’єкта promise
, що повертається конструктором new Promise
є внутрішні властивості:
state
(стан) — спочатку"pending"
(очікування), в результаті виконання функції він може змінюватися на:"fulfilled"
коли викликається методresolve
і на"rejected"
колиreject
.result
(результат) — спочаткуundefined
, далі змінюється наvalue
коли викликається методresolve(value)
абоerror
колиreject(error)
.
Отже, виконавець зрештою переводить promise
в один з наступних станів:
Пізніше ми розглянемо, як “фанати” можуть підписуватись на ці зміни.
Нижче приклад конструктора проміса і простої функції-виконавця з кодом-“виробником”, що видає результат з затримкою (через setTimeout
):
let promise = new Promise(function(resolve, reject) {
// функція-виробник викликається автоматично, при виклику new Promise
// через 1 секундну повідомляється що задача виконання з результатом "завершено"
setTimeout(() => resolve("завершено"), 1000);
});
Ми можемо спостерігати за двома моментами запустивши код вище:
-
Функція-виконавець викликається одразу ж при виклику
new Promise
. -
Виконавець отримує два аргументи:
resolve
іreject
– ці функції вбудовані в JavaScript, тому нам непотрібно їх створювати. Нам слід всього лиш викликати одну з них по готовності.Через одну секунду “обробки” виконавець викличе
resolve("done")
, щоб передати результат. Ця дія змінить стан об’єктаpromise
(що повертається конструкторомnew Promise
) з"pending"
на"fulfilled"
:
Це був приклад успішно виконаної задачі, в результаті ми отримали “виконаний (fulfilled)” проміс.
А тепер приклад коли функція-виконавець повідомить нам що задача виконана з помилкою:
let promise = new Promise(function(resolve, reject) {
// через 1 секунду повідомляється що задача виконана з помилкою
setTimeout(() => reject(new Error("Ооооой!")), 1000);
});
Виклик методу reject(...)
переводить стан об’єкта promise
в "rejected"
:
Підіб’ємо проміжні підсумки: функція-виконавець виконує задачу (щось, що як правило потребує часу), потім викликається один з методів resolve
чи reject
, в залежності від успішності виконання коду. Які своєю чергою змінюють стан об’єкта який повертає конструктор new Promise
.
Проміс – в стані resolve
чи reject
будемо називати “завершеним (settled)”, на відміну від початкового стану проміса “в очікуванні (pending
)”.
Функція-виконавець може викликати тільки щось одне: resolve
або reject
. Стан проміса може змінитись лише один раз.
Всі наступні виклики resolve
чи reject
будуть ігноруватись:
let promise = new Promise(function(resolve, reject) {
resolve("завершено");
reject(new Error("…")); // ігнорується
setTimeout(() => resolve("…")); // ігнорується
});
Ідея в тому, що функція виконавець може мати тільки один результат чи помилку.
Зверніть увагу, що методи resolve
/reject
можуть прийняти тільки один аргумент (або жодного), а всі додаткові аргументи будуть проігноровані.
reject
з об’єктом Error
У випадку якщо щось пішло не так, функції-виконавцю слід викликати метод reject
. В reject
можна передати аргумент будь-якого типу (як і в resolve
), але рекомендується використовувати об’єкт Error
(чи успадкований від нього об’єкт). Чому так? Скоро вам стане зрозуміло.
resolve
/reject
На практиці функція-виконавець робить щось асинхронне і через якийсь час викликає resolve
/reject
. Проте це необов’язково. resolve
чи reject
можуть викликатись одразу:
let promise = new Promise(function(resolve, reject) {
// задача що не потребує часу
resolve(123); // моментально видасть результат: 123
});
Для прикладу, таке може статись коли ми почали виконувати певну задачу, проте одразу ж побачили що раніше її виконували й результат закешований.
Така ситуація нормальна. Ми одразу ж отримаємо успішно завершений проміс.
state
і result
– внутрішніВластивості state
and result
– внутрішні властивості об’єкта Promise
, тому ми не маємо до них прямого доступу. Для обробки результату слід використовувати методи: .then
/.catch
/.finally
. Про них далі піде мова.
Споживачі: then, catch
Об’єкт Promise
служить зв’язною ланкою між функцією виконавцем (код “виробник” чи “співак”) і функціями-споживачами (“фанатами”), котрі отримають або результат, або помилку. Функції споживачі можуть зареєструватись (підписатись) за допомогою методів .then
та .catch
.
then
Найважливіший і фундаментальний метод – .then
.
Синтаксис:
promise.then(
function(result) { /* обробляє успішне виконання */ },
function(error) { /* обробляє помилку */ }
);
Перший аргумент метода .then
– функція що викликається коли проміс успішно виконується, тобто переходить зі стану "pending"
в "resolved"
і отримує результат.
Другим аргументом метод .then
приймає функцію, що викликається коли проміс переходить в стан "rejected"
і отримує помилку.
Для прикладу, наступним чином виглядає реакція на успішно виконаний проміс:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve("завершено!"), 1000);
});
// метод resolve запустить першу функцію передану в .then
promise.then(
result => alert(result), // виведе "завершено!" через 1 секунду
error => alert(error) // не запуститься
);
Функція передана першим аргументом виконалась.
А в випадку помилки в промісі – виконається друга:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error("Ооооой!")), 1000);
});
// метод reject запустить другу функцію передану в .then
promise.then(
result => alert(result), // не запуститься
error => alert(error) // виведе "Error: Ооооой!" через 1 секунду
);
Якщо ми зацікавлені тільки в успішному виконанні задачі, тоді в .then
можна передати тільки одну функцію:
let promise = new Promise(resolve => {
setTimeout(() => resolve("завершено!"), 1000);
});
promise.then(alert); // виведе "завершено!" через 1 секунду
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: Ооооой!" через 1 секунду
Виклик .catch(f)
– це скорочений варіант .then(null, f)
.
Cleanup: finally
По аналогії з блоком finally
зі звичайного try {...} catch {...}
, у промісів також є свій метод finally
Виклик .finally(f)
подібний до .then(f, f)
, в тому сенсі, що f
виконається в будь-якому випадку, коли проміс перейде в стан "виконано (settled)"
не залежно від того став він resolved
чи rejected
.
finally
добре підходить для чистки, наприклад зупинки індикатора завантаження, тому, що його потрібно зупинити незалежно від результату.
Наприклад зупинка завантаження індикаторів, закриття непотрібних підключень тощо.
Подумайте про це як про завершення вечірки. Незалежно від того, була вечірка хорошою чи поганою, скільки друзів на ній було, ми все одно повинні (або принаймні повинні) прибрати після неї.
Код може виглядати так:
new Promise((resolve, reject) => {
/* зробити щось, що займе час і потім викликати resolve/reject */
})
// виконається коли проміс завершиться (стане "settled"), незалежно від того, успішно чи ні
.finally(() => зупинити індикатор завантаження)
// отож індикатор завантаження завжди зупинятиметься перед тим як ми будемо обробляти результат/помилку
.then(result => вивести результат, err => вивести помилку)
Проте finally(f)
точно не є псевдонімом then(f,f)
.
Є декілька незначних відмінностей:
-
Обробник
finally
не приймає аргументів. Вfinally
ми не знаємо як був завершений проміс, успішно чи ні. І це нормально, тому що зазвичай наше завдання заключаєтсья в тому щоб виконати “загальні” процедури доопрацювання.Подивіться на наведений вище приклад: як бачите, обробник
finally
не має аргументів, а результат промісу обробляється наступним обробником. -
Обробник
finally
пропускає результат чи помилку до наступних обробників.Наприклад, тут результат проходить через
finally
доthen
:new Promise((resolve, reject) => { setTimeout(() => resolve("результат"), 2000) }) .finally(() => alert("Проміс завершений")) // запускається першим .then(result => alert(result)); // <-- .then показує "резульат"
Як бачите, “результат”, який повертає перший проміс, передається через
finally
наступномуthen
.Це дуже зручно, оскільки
finally
не призначено для обробки результату промісу. Як було сказано, це місце для загального очищення, незалежно від результату.А ось приклад помилки, щоб ми могли побачити, як вона передається через
finally
доcatch
:new Promise((resolve, reject) => { throw new Error("помилка"); }) .finally(() => alert("Проміс завершений")) // запускається першим .catch(err => alert(err)); // <-- .catch обробляє об’єкт помилки
-
Обробник
finally
також не повинен нічого повертати. Якщо все ж таки він щось повертає, це значення ігнорується.Єдиним винятком із цього правила є випадки, коли обробник
finally
видає помилку. А потім ця помилка переходить до наступного обробника замість будь-якого попереднього результату проміса.
У підсумку:
- Обробник
finally
не отримує результат попереднього обробника (у нього немає аргументів). Замість цього цей результат передається наступному відповідному обробнику. - Якщо обробник
finally
щось повертає, це буде ігноруватися. - Якщо виникає помилка в
finally
, виконання переходить до найближчого обробника помилок.
Ці особливості є корисними й забезпечують правильну роботу, якщо ми використовуємо finally
так, як це передбачено: для загальних процедур очищення.
Якщо проміс в стані очікування, .then/catch/finally
будуть на нього чекати.
Іноді може бути так, що проміс уже виконано, коли ми додаємо до нього обробник.
У такому випадку ці обробники просто запускаються негайно:
// при створенні проміс одразу ж перейде в стан успішно завершений (`"resolved"`)
let promise = new Promise(resolve => resolve("завершено!"));
promise.then(alert); // виведе "завершено!"
Зауважте, що це робить проміси більш потужними, ніж життєвий приклад “списку підписок”. Якщо співак вже випустив свою пісню, а потім людина підписалась, вона імовірно не отримає цю пісню. Передоплата в реальному житті повинна бути здійсненою до початку події.
Проміси своєю чергою більш гнучкі. Ми можемо додати обробник в будь-який час: якщо результат вже є, вони просто виконуються.
Приклад: loadScript
Тепер, розгляньмо декілька прикладів з тим як проміси можуть облегшити нам написання асинхронного коду.
В нас є функція loadScript
для завантаження скрипту з попереднього розділу.
Згадаймо як виглядає варіант з колбеком:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Помилка завантаження скрипта ${src}`));
document.head.append(script);
}
Тепер перепишемо її використавши проміс.
Новій функції loadScript
більше не потрібен колбек. Замість цього вона буде створювати й повертати об’єкт проміса, котрий перейде в стан “успішно завершений”, коли завантаження завершиться. Зовнішній код також може додавати обробників (“підписників”) використовуючи .then
:
function loadScript(src) {
return new Promise(function(resolve, reject) {
let script = document.createElement('script');
script.src = src;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Помилка завантаження скрипта ${src}`));
document.head.append(script);
});
}
Застосування:
let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");
promise.then(
script => alert(`${script.src} завантажений!`),
error => alert(`Помилка: ${error.message}`)
);
promise.then(script => alert('Ще один обробник...'));
Одразу ж помітно декілька переваг над використання підходу з використанням колбеків:
Проміси | Колбеки |
---|---|
Проміси дозволяють нам виконувати речі в природному порядку. Спочатку ми запускаємо loadScript(script) , і потім ми записуємо в .then що робити з результатом. |
У нас повинна бути функція callback на момент виклику loadScript(script, callback) . Іншими словами нам потрібно знати що робити з результатом до того як викличеться loadScript . |
Ми можемо викликати .then у проміса стільки раз, скільки захочемо. Кожного разу коли ми додаємо нового “фаната”, нову функцію-підписку в “список-підписників”. Більше про це в наступному розділі: ланцюжок промісів. |
Колбек може бути тільки один. |
Таким чином проміси покращують порядок коду і дають нам гнучкість. Але це далеко не все. Ми дізнаємось ще багато корисного в наступних розділах.
Коментарі
<code>
, для кількох рядків – обгорніть їх тегом<pre>
, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)