22 лютого 2024 р.

Проміси

Уявіть, що ви топ співак, фани якого постійно дошкуляють запитаннями про майбутній сингл.

Щоб отримати передишку, ви обіцяєте розіслати їм сингл, коли він буде випущений. Ви даєте своїм фанатам список. В який вони можуть записати свій e-mail, на який вони отримають пісню як тільки вона вийде. І навіть якщо щось піде не так, наприклад, в студії станеться пожежа і ви не зможете випустити пісню, вони й про це отримають сповіщення.

Всі щасливі: ви, тому що вам більше не дошкуляють, а фанати, тому що можуть більше не переживати що пропустять новий сингл.

Це аналогія з реального життя для ситуацій з якими ми часто стикаємось в програмуванні:

  1. Є код–“виробник”, котрий щось робить, що займає час. Наприклад, завантажує дані з мережі. Згідно з нашою аналогією це “співак”.
  2. Є код–“споживач”, котрий очікує отримати результат від коду-“виробника”, як тільки той буде готовий. Цей результат може знадобитися багатьом функціям. Ці функції – “фанати”
  3. 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);
});

Ми можемо спостерігати за двома моментами запустивши код вище:

  1. Функція-виконавець викликається одразу ж при виклику new Promise.

  2. Виконавець отримує два аргументи: 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).

Є декілька незначних відмінностей:

  1. Обробник finally не приймає аргументів. В finally ми не знаємо як був завершений проміс, успішно чи ні. І це нормально, тому що зазвичай наше завдання заключаєтсья в тому щоб виконати “загальні” процедури доопрацювання.

    Подивіться на наведений вище приклад: як бачите, обробник finally не має аргументів, а результат промісу обробляється наступним обробником.

  2. Обробник 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 обробляє об’єкт помилки
  3. Обробник 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 у проміса стільки раз, скільки захочемо. Кожного разу коли ми додаємо нового “фаната”, нову функцію-підписку в “список-підписників”. Більше про це в наступному розділі: ланцюжок промісів. Колбек може бути тільки один.

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

Завдання

Що виведе код нижче?

let promise = new Promise(function(resolve, reject) {
  resolve(1);

  setTimeout(() => resolve(2), 1000);
});

promise.then(alert);

Результат буде: 1.

Другий виклик resolve проігнорується, оскільки враховується тільки перший виклик reject/resolve. Всі наступні їхні виклики ігноруються.

Вбудована функція setTimeout використовує колбек-функції. Створіть альтернативу яка базується на промісах.

Функція delay(ms) повинна повертати проміс, який перейде в стан resolved через ms мілісекунд, так щоб ми могли додати до нього .then:

function delay(ms) {
  // ваш код
}

delay(3000).then(() => alert('виконалось через 3 секунди'));
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(3000).then(() => alert('виконалось через 3 секунди'));

Зауважте що resolve викликається без аргументів. Ми нічого не повертаємо з delay, просто гарантуємо затримку.

Перепишіть функцію showCircle, написану в завданні [Анімація круга за допомогою колбека]Коло анімоване за допомогою функції. таким чином щоб вона повертала проміс, замість того щоб приймати в аргументи колбек-функцію.

Нове використання:

showCircle(150, 150, 100).then(div => {
  div.classList.add('message-ball');
  div.append("Привіт, світ!");
});

Візьміть за основу рішення з завдання [Анімація круга за допомогою колбека]Коло анімоване за допомогою функції..

Навчальна карта