Повернемося до проблеми, згаданої в розділі Введення: колбеки: у нас є послідовність асинхронних задач, які потрібно виконувати одну за одною — наприклад, завантаження скриптів. Як ми можемо написати код зручно і зрозуміло?
Проміси надають кілька способів вирішення подібних задач.
У цьому розділі ми розглянемо ланцюжок промісів.
Він виглядає наступним чином:
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
}).then(function(result) { // (**)
alert(result); // 1
return result * 2;
}).then(function(result) { // (***)
alert(result); // 2
return result * 2;
}).then(function(result) {
alert(result); // 4
return result * 2;
});
Ідея полягає в тому, що результат передається через ланцюжок .then
обробників.
Ось потік виконання:
- Початковий проміс успішно виконується через 1 секунду
(*)
, - Далі на
(**)
викликається обробник.then
, який у свою чергу, створює новий проміс (вирішується зі значенням2
). - Наступний
then
(***)
приймає результат попереднього, оброблює його (подвоює) та передає до наступного обробника. - …і так далі.
Коли результат передається по ланцюжку обробників, ми бачимо послідовність викликів alert
: 1
→ 2
→ 4
.
Усе це працює тому, що кожний виклик .then
повертає новий проміс, тому ми можемо викликати наступний .then
на ньому.
Коли обробник повертає значення, воно стає результатом його промісу, тому наступний .then
викликається з цим значенням.
Класична помилка новачка: технічно ми також можемо додати багато .then
до одного промісу. Та це не ланцюжок.
Наприклад:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
Те, що ми зробили тут – це просто додали кілька обробників до одного промісу. Вони не передають результат один одному ланцюжком. Натомість кожен по своєму обробляє результат одного і того ж проміса.
Ось малюнок (порівняйте його з ланцюжком вище):
Усі .then
на одному й тому самому промісі отримують той самий результат – результат цього промісу. Тож у коді вище усі alert
показують те саме: 1
.
На практиці нам рідко потрібні кілька обробників для одного промісу. Набагато частіше використовується ланцюжок.
Повернення промісів
Обробник, використанний в .then(handler)
може створити й повернути проміс.
У цьому випадку інші обробники чекають, поки він виконається, а потім отримують його результат.
Наприклад:
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
}).then(function(result) {
alert(result); // 1
return new Promise((resolve, reject) => { // (*)
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) { // (**)
alert(result); // 2
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) {
alert(result); // 4
});
Тут перший .then
показує 1
і повертає new Promise(…)
у рядку (*)
. Через одну секунду він завершується, а результат (аргумент resolve
, тут це result * 2
) передається обробнику другого .then
. Цей обробник знаходиться в рядку (**)
, він показує 2
і робить те ж саме.
Отже, результат такий же, як і в попередньому прикладі: 1 → 2 → 4, але тепер із затримкою в 1 секунду між викликами alert
.
Повернення промісів дозволяє нам будувати ланцюжки асинхронних дій.
Приклад: loadScript
Давайте використовувати цю можливість з промісифікацією loadScript
, описаною у попередньому розділі, щоб завантажувати скрипти один за одним, у послідовності:
loadScript("/article/promise-chaining/one.js")
.then(function(script) {
return loadScript("/article/promise-chaining/two.js");
})
.then(function(script) {
return loadScript("/article/promise-chaining/three.js");
})
.then(function(script) {
// викличемо функції, оголошені в скриптах
// щоб показати, що вони дійсно завантажені
one();
two();
three();
});
Цей код можна зробити трохи коротшим за допомогою стрілкових функцій:
loadScript("/article/promise-chaining/one.js")
.then(script => loadScript("/article/promise-chaining/two.js"))
.then(script => loadScript("/article/promise-chaining/three.js"))
.then(script => {
// скрипти завантажені, ми можемо використовувати оголошені там функції
one();
two();
three();
});
Тут кожен виклик loadScript
повертає проміс, а наступний .then
запускається, коли він виконується. Потім він ініціює завантаження наступного. Таким чином, скрипти завантажуються один за одним.
Ми можемо додати більше асинхронних дій до ланцюжка. Зверніть увагу, що код все ще “плоский” — він росте вниз, а не вправо. Немає жодних ознак “піраміди приреченості”.
Технічно ми можемо додати .then
безпосередньо до кожного loadScript
, наприклад:
loadScript("/article/promise-chaining/one.js").then(script1 => {
loadScript("/article/promise-chaining/two.js").then(script2 => {
loadScript("/article/promise-chaining/three.js").then(script3 => {
// ця функція має доступ до змінних script1, script2 і script3
one();
two();
three();
});
});
});
Цей код робить те ж саме: завантажує 3 скрипти послідовно. Але він “росте вправо”. Тож у нас та ж проблема, що й з колбеками.
Люди, які починають використовувати проміси, іноді не знають про ланцюжок, тому пишуть це так. Але зазвичай краще писати “ланцюжками”.
Іноді справді буває потреба писати .then
всередині. Для того, щоб вкладена функція мала доступ до зовнішньої області видимості. У наведеному вище прикладі найбільш вкладений колбек має доступ до всіх змінних script1
, script2
, script3
. Але така потреба це швидше виняток, ніж правило.
Якщо бути точним, обробник може повернути не зовсім проміс, а так званий об’єкт “thenable” – довільний об’єкт, який має метод .then
. Це буде розглядатися так само, як проміс.
Ідея полягає в тому, що сторонні бібліотеки можуть реалізовувати власні промісо-сумісні об’єкти. Вони можуть мати розширений набір методів, але також можуть бути сумісні з нативними промісами, оскільки вони реалізують .then
.
Ось приклад такого об’єкта:
class Thenable {
constructor(num) {
this.num = num;
}
then(resolve, reject) {
alert(resolve); // function() { native code }
// буде успішно виконано з аргументом this.num*2 через 1 секунду
setTimeout(() => resolve(this.num * 2), 1000); // (**)
}
}
new Promise(resolve => resolve(1))
.then(result => {
return new Thenable(result); // (*)
})
.then(alert); // показує 2 через 1000 мс
JavaScript перевіряє об’єкт, повернутий обробником .then
у рядку (*)
: якщо він має придатний до виклику метод з ім’ям then
, тоді він викликає цей метод, та передає йому власні функції resolve
, reject
як аргументи (подібно виконавцю) і чекає, поки один з них не буде викликаний. У наведеному вище прикладі resolve(2)
викликається через 1 секунду (**)
. Потім результат передається далі по ланцюжку.
Ця функція дозволяє нам інтегрувати власні об’єкти з ланцюжками промісів без успадкування від Promise
.
Складніший приклад: fetch
У фронтенд розробці проміси часто використовуються для мережевих запитів. Тож давайте подивимося на розширений приклад цього.
Ми будемо використовувати метод fetch, щоб завантажити інформацію про користувача з віддаленого сервера. Він має багато опціональних параметрів, які розглядаються в окремих розділах, але основний синтаксис досить простий:
let promise = fetch(url);
Зазначений код робить мережевий запит до url
і повертає проміс. Цей проміс переходить в стан “fullfilled” і його value
стає об’єкт response
(іншими словами, проміс завершується об’єктом response
) як тільки віддалений сервер присилає заголовки, але ще до завантаження повної відповіді.
Щоб прочитати повну відповідь, ми повинні викликати метод response.text()
, він в свою чергу повертає новий проміс. Цей новий проміс завершується лише тоді, коли з віддаленого сервера завантажується увесь текст, не лише заголовки. Після завершення він буде містити весь текст відповіді в якості результату.
Наведений нижче код робить запит до user.json
і завантажує його текст із сервера:
fetch('/article/promise-chaining/user.json')
// .then нижче запускається, коли віддалений сервер надіслав заголовки
.then(function(response) {
// response.text() повертає новий проміс, який завершується повним текстом відповіді,
// після того, як текст повністю завантажиться
return response.text();
})
.then(function(text) {
// ...а ось і повний вміст віддаленого файлу
alert(text); // {"name": "iliakan", "isAdmin": true}
});
Об’єкт response
, повернутий із fetch
, також має метод response.json()
, який обробляє отриманий текст і зберігає його у форматі JSON. У нашому випадку він ще зручніший за метод response.text()
, тому далі будемо використовувати саме його.
Ми також будемо використовувати стрілкові функції для стислості:
// те саме, що й вище, але response.json() зчитує віддалений контент у форматі JSON
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => alert(user.name)); // iliakan, отримали ім’я користувача
Тепер давайте зробимо щось із завантаженим користувачем.
Наприклад, ми можемо зробити ще один запит до GitHub, завантажити профіль користувача та показати аватар:
// Запитуємо user.json
fetch('/article/promise-chaining/user.json')
// Завантажуємо дані у форматі json
.then(response => response.json())
// Робимо запит до GitHub
.then(user => fetch(`https://api.github.com/users/${user.name}`))
// Завантажуємо відповідь у форматі json
.then(response => response.json())
// Показуємо аватар (githubUser.avatar_url) протягом 3 секунд (можливо, з анімацією)
.then(githubUser => {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => img.remove(), 3000); // (*)
});
Код працює; дивіться коментарі для деталей. Однак у цьому є потенційна проблема, типова помилка для тих, хто починає використовувати проміси.
Подивіться на рядок (*)
: як ми можемо щось зробити після того, як аватар закінчить відображатися і видалиться? Наприклад, ми хотіли б показати форму для редагування цього користувача чи щось інше. Поки що такої можливості немає.
Щоб ланцюжок міг розширюватися, нам потрібно повернути проміс, який розв’язується, коли аватар закінчує відображатися.
Ось так:
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`))
.then(response => response.json())
.then(githubUser => new Promise(function(resolve, reject) { // (*)
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser); // (**)
}, 3000);
}))
// спрацьовує через 3 секунди
.then(githubUser => alert(`Завершили показ ${githubUser.name}`));
Тобто обробник .then
у рядку (*)
тепер повертає new Promise
, який перейде у стан “виконаний” лише після виклику resolve(githubUser)
у setTimeout
(**)
. Наступний .then
в ланцюжку буде чекати цього.
Гарним тоном буде писати код так, щоб асинхронна дія завжди повертала проміс. Це дає можливість іншим програмістам після вас планувати наступні дії, які зможуть почекати завершення попередньої; навіть якщо ми не плануємо розширювати ланцюжок зараз, можливо, це нам знадобиться пізніше.
Ну а зараз ми можемо розділити код на функції, що можуть бути перевикористані:
function loadJson(url) {
return fetch(url)
.then(response => response.json());
}
function loadGithubUser(name) {
return loadJson(`https://api.github.com/users/${name}`);
}
function showAvatar(githubUser) {
return new Promise(function(resolve, reject) {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
});
}
// Використаємо їх:
loadJson('/article/promise-chaining/user.json')
.then(user => loadGithubUser(user.name))
.then(showAvatar)
.then(githubUser => alert(`Завершили показ ${githubUser.name}`));
// ...
Підсумки
Якщо обробник .then
(або catch/finally
, однаково) повертає проміс, решта ланцюжка чекає, доки він виконається. Коли це відбувається, його результат (або помилка) передається далі.
Ось повна картина:
Коментарі
<code>
, для кількох рядків – обгорніть їх тегом<pre>
, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)