Від джуніора про Promise в JavaScript: на прикладі роботи піцерії
Всім вітання. Мене звуть Алла і я Backend developer в компанії Weblium. Минулого літа отримала можливість пройти публічну співбесіду у пана Сергія Бабіча. Це дійсно крутий досвід, який дав розуміння, де потрібно вдосконалити свої знання та навички. Для мене особисто кращим форматом засвоєння знань є написання статті, оскільки в процесі її підготовки я більш глибоко занурююсь в тему. З різних причин вихід статті затягнувся🤷♀️, але то деталі.
В цій публікації вирішила пропрацювати Promise
в JavaScript
. Це питання, на мою суб’єктивну думку, в інтерв’ю я розкрила слабко. Тому тут не буде якихось унікальних відкриттів для обізнаної аудиторії. Загальна мета — структурувати власні знання про Promise. Можливо, комусь зможу допомогти зрозуміти чи пригадати якісь аспекти або ваша критика поглибить мої знання. Але одне знаю напевно — ця стаття допомогла мені не на одному інтервʼю.
Запрошую познайомитись з обіцянкою в JavaScript разом на прикладі замовлення піци онлайн в піцерії. Обіцяю, буде цікаво 😏.
Однопотоковий JavaScript — причина появи обіцянок
Почнемо з того, що JavaScript — це однопотокова мова програмування. Це коли код виконується послідовно — кожен крок має свою чергу на виконання і відсутні паралельні процеси, як результат =>
одночасно може бути активною лише одна операція або функція. Всі операції виконуються по черзі або у запланований час.
Проведемо аналогію: уявімо собі піцерію, де працює лише один кухар. Він готує піцу від початку до кінця самостійно: розкатує тісто, додає начинку, випікає, нарізає та подає клієнтам. Але є проблема — цей кухар працює лише в одному потоці. Це означає, що він може робити тільки одну дію за раз: якщо він замішує тісто, то не може одночасно випікати піцу чи приймати нові замовлення.
Так само працює JavaScript. Всі операції виконуються послідовно, і поки одна триває — інші змушені чекати.
Це може стати проблемою, коли потрібно:
- готувати кілька піц одночасно (обробка кількох серверних запитів);
- нарізати інгредієнти паралельно з випіканням (реакція на дії користувачів без блокування основних процесів);
- приймати нові замовлення, поки старі ще в роботі (обробка великих обсягів даних);
- своєчасно подавати страви, щоб клієнти не чекали вічність (завантаження та відображення ресурсів).
Без асинхронного підходу кухар змушений чекати, поки тісто підніметься, перш ніж перейти до наступного кроку, а клієнти залишаються голодними. Бо якщо ваш потік зайнятий зворотнім відліком, то він не може виконувати нічого іншого, поки не завершиться цей відлік. Саме тому потрібно розумне управління процесами — так з’являються асинхронні операції.
Mи не можемо просто наказати JavaScript зупинитися та зачекати перед виконанням наступного рядка коду, оскільки це заблокує потік. Нам знадобиться якийсь спосіб поділу роботи на асинхронні фрагменти. Уникнути перевантаження, тривалого очікування та блокування операцій можна кількома шляхами. Щоб розв’язати цю проблему, в програмуванні є три основні інструменти:
- Callback-функції — якби кухар кликав помічника кожного разу, коли потрібно щось зробити.
- Promises — якби кухар міг доручити частину роботи й отримати результат пізніше.
- Async/Await — спосіб працювати з обіцянками так, щоб усе виглядало послідовним.
Почала з цього, бо без розуміння того, як працює JavaScript
, неможливо зрозуміти його інструменти. І саме Promise
стали тим рішенням, яке дозволяє нашому кухарю ефективніше управляти процесами. Тож давайте розбиратися, як вони працюють!
Що ж це за звір такий — Promise?
Для початку цей звір буквально означає — обіцянка, що може бути виконана чи відхилена, а в контексті JavaScript є інструментом асинхронного програмування. При пошуку інформації користувалася величезною кількістю джерел, де визначення інструменту трохи різняться або немає чіткого формулювання. Тож я виділила основне, що є спільним:
Promise — це об’єкт, який є результатом асинхронної операції (яка ще незавершена, але буде завершена в майбутньому) та містить відповідь про успішне виконання операції або про помилку в процесі її виконання. Проте замість повернути одразу кінцеве значення метод повертає promise — зобов’язання надати це значення в якийсь момент у майбутньому.
Повернемось до аналогії: уявімо, що в нашій піцерії з’явився новий сервіс доставки. Клієнт телефонує та замовляє піцу, а кухар замість того, щоб чекати, поки вона спечеться, просто дає обіцянку: «Ваша піца буде готова через 30 хвилин!». Тепер клієнт може спокійно займатися своїми справами, знаючи, що йому повідомлять, коли замовлення буде готове.
Так само працює Promise у JavaScript. Він дозволяє виконувати асинхронні операції, не блокуючи основний потік виконання коду. Обіцянки потрібні для керованішого та чистішого коду під час виконання асинхронних завдань, таких як:
- Виклики до API (наприклад, запит на сервер щодо списку замовлень піцерії).
- Взаємодія з користувачем (натискання кнопки «замовити» не повинно блокувати всю програму).
- Анімації (наприклад, плавне з’явлення повідомлення про статус замовлення).
Конструктор Promise
У JavaScript Promise створюється за допомогою конструктора Promise
, який приймає функцію-виконавець (executor). Вона запускається автоматично і отримує два параметри:
resolve
— викликається при успішному виконанні операції.reject
— викликається у разі помилки.
Синтаксис промісу:
const pizzaOrder = new Promise((resolve, reject) => { setTimeout(() => { let success = Math.random() > 0.2; // 80% шанс, що піца буде готова if (success) { resolve("Ваша піца готова!"); } else { reject("Вибачте, ми не встигли приготувати піцу."); } }, 3000); });
Властивості обіцянок
Об’єкт, який повертається конструктором new Promise
, має свої внутрішні властивості — результат (result) та стани (state). Важливою особливістю обіцянки є стан. Всього їх три і в одному з яких завжди обов’язково перебуває об’єкт:
- «pending» (очікування) — стартовий стан, незалежно від результату виконання асинхронної операції;
- «fulfilled» (виконано) — коли в результаті виконання операції викликається метод resolve;
- «rejected» (відхилено) — коли в результаті асинхронної операції викликається метод reject.
Важливо також враховувати властивість result, залежно від стану змінюється і її значення:
Number | State | Result |
---|---|---|
1 | «pending» | undefined |
2 | «fulfilled» | value (data) |
3 | «rejected» | error |
Приклад виконання успішного замовлення:
pizzaOrder.then((message) => { console.log(message); // "Ваша піца готова!" });
Приклад обробки помилки:
pizzaOrder.catch((error) => { console.log(error); // "Вибачте, ми не встигли приготувати піцу." });
Promise виконує свою задачу, що в більшості випадків потребує часу для завершення всіх операцій. Після чого викликається один із методів resolve
або reject
, що залежить від результату роботи функції-виконавця. Ці методи змінюють стани, які надає конструктор new Promise
. Тому знання властивостей є необхідним для розуміння роботи обіцянки. Ще важливо те, що Promise вважається виконаним в стані fulfilled
або rejected
, але не в стані очікування.
Обробники — Promise Handlers
Функції, що дозволяють керувати результатами виконання промісів (тобто керувати виконанням асинхронних операцій) — це Promise handlers або обробники.
Вони використовуються для виконання певних дій залежно від того, завершилася операція успішно чи виникла помилка. Методами обробки обіцянок є:
- .then() — викликається у разі успішного виконання промісу. Це буває у випадку, коли проміс перейшов у стан «виконано» (fulfilled). Крім того, .then() повертає новий проміс, завдяки чому можна створювати ланцюжки з кількох .then();
- .catch() — буквально обробник та відловлювач помилок. Викликається у разі, якщо проміс був відхилений (rejected). Його основним завданням є обробка помилок, що виникли під час виконання асинхронної операції;
- .finally() — викликається незалежно від результату виконання промісу і є його завершенням. Обробник .finally() не приймає аргументів. Призначений для виконання дій, які мають відбутися в будь-якому випадку (наприклад для очищення ресурсів). Також обробник .finally() не повинен нічого повертати. В тих випадках, коли він щось повертає — це значення ігнорується.
pizzaOrder .then((message) => { console.log(message); // "Ваша піца готова!" }) .catch((error) => { console.log(error); // "Вибачте, ми не встигли приготувати піцу." }) .finally(() => { console.log("Дякуємо за ваше замовлення!"); });
Виключення: коли обробник .finally() видає помилку. В такому випадку помилка переходить до наступного обробника замість будь-якого попереднього результату проміса.
Обробники ще називають прикутими промісами, оскільки вони використовуються для пов’язування подальших дій з обіцянкою, яка виконується. Вони дозволяють ефективно управляти асинхронними операціями та забезпечувати чистий код.
Переваги Promise над callback
Раніше для асинхронного програмування використовували callback-функції, але вони створювали проблему callback hell — вкладеність у вкладеності, яка ускладнює читання та підтримку коду.
pizzaOrder((order) => { prepareDough(order, (dough) => { addStuffing(dough, (піца) => { bake(піца, (readyPizza) => { submit(readyPizza); }); }); }); });
У цьому прикладі кілька асинхронних операцій вкладені одна в одну, що ускладнює розуміння та підтримку коду.
Promise допомагає вирішити цю проблему, дозволяючи обробляти асинхронні операції в більш чистому і зрозумілому вигляді:
pizzaOrder() .then(prerareDough) .then(addStuffing) .then(bake) .then(serve) .catch(handleError);
Проміс дозволяє керувати асинхронними операціями так само, як у нашій піцерії система доставки — клієнт отримує обіцянку, що його піца буде готова, і може займатися своїми справами. Це робить код чистішим, керованішим і зрозумілішим!
На відміну від колбеків Promise вже має вбудовані зворотні виклики resolve і reject. Всі дії відбуваються «під капотом» і завдяки цьому ваш код стає чистішим, та і не потрібно писати все руками.
Ланцюжки Promise
Уявіть, що ви власник піцерії, і кожне замовлення проходить кілька етапів: прийом замовлення, приготування, випікання, пакування та доставка. Кожен етап виконується асинхронно, і наступний може початися лише після завершення попереднього. Це ідеальна аналогія для розуміння ланцюжків промісів.
Promise Chaining — це техніка в JavaScript, яка дозволяє виконувати послідовність асинхронних операцій одну за одною. Це досягається шляхом використання методів .then(), які дозволяють обробляти результати кожної асинхронної операції та передавати дані до наступної операції. Розглянемо процес приготування піци на прикладі:
// Перший проміс – замішування тіста const makeDough = () => { // Оголошуємо makeDough як функцію return new Promise((resolve, reject) => { setTimeout(() => { resolve("Тісто готове"); }, 1000); }); }; // Другий проміс – додавання інгредієнтів const addIngredients = (data) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(data + " -> Інгредієнти додані"); }, 1000); }); }; // Третій проміс – випікання піци const bakePizza = (data) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(data + " -> Піца готова!"); }, 2000); }); }; // Ланцюжок промісів makeDough() // Викликаємо функцію makeDough() .then((result) => { console.log(result); return addIngredients(result); }) .then((result) => { console.log(result); return bakePizza(result); }) .then((result) => { console.log(result); console.log("Смачного!"); }) .catch((error) => { console.error("Помилка приготування піци:", error); }) .finally(() => { console.log("Процес завершено."); });
Основні принципи ланцюжків промісів:
- послідовність виконання — кожен проміс у ланцюжку виконується після успішного виконання попереднього;
- передача даних — результат виконання попереднього промісу передається до наступного через метод .then();
- обробка помилок — помилки, що виникають в будь-якому промісі, можуть бути оброблені за допомогою методу .catch();
- завершення ланцюжка — метод .finally() виконується незалежно від того, чи був проміс виконаний успішно, чи ні.
Де застосовуються ланцюжки промісів
Використання ланцюжків дозволяє розширити можливості для застосування обіцянок для вирішення більшого спектра задач. Вони корисні у випадках, коли необхідно виконувати послідовність асинхронних операцій. Найбільш поширеними задачами є:
- Робота з API (отримання інформації про клієнта і його замовлення).
fetch('https://api.example.com/user') .then(response => response.json()) .then(user => { console.log('Користувач:', user); return fetch(`https://api.example.com/posts?userId=${user.id}`); }) .then(response => response.json()) .then(posts => { console.log('Пости користувача:', posts); }) .catch(error => { console.error('Помилка:', error); });
- Обробка послідовних дій з базою даних (оновлення статусу замовлення).
db.insert(data) .then(result => db.update(result.id, newData)) .then(updatedResult => db.delete(updatedResult.id)) .catch(error => console.error('DB operation failed', error));
- Завантаження ресурсів (отримання фото меню).
function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.src = url; img.onload = () => resolve(`Зображення ${url} завантажено`); img.onerror = () => reject(`Помилка завантаження зображення ${url}`); }); } // Ланцюжок промісів для послідовного завантаження зображень loadImage('https://example.com/image1.jpg') .then((message) => { console.log(message); return loadImage('https://example.com/image2.jpg'); }) .then((message) => { console.log(message); return loadImage('https://example.com/image3.jpg'); }) .then((message) => { console.log(message); console.log('Усі зображення успішно завантажені'); }) .catch((error) => { console.error(error); });
- Серіалізація асинхронних операцій (етапи доставки піци).
const step1 = () => { return new Promise((resolve, reject) => { setTimeout(() => { resolve('Крок 1 виконано'); }, 1000); }); }; const step2 = (data) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(`${data} -> Крок 2 виконано`); }, 1000); }); }; const step3 = (data) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(`${data} -> Крок 3 виконано`); }, 1000); }); }; step1() .then(result => { console.log(result); return step2(result); }) .then(result => { console.log(result); return step3(result); }) .then(result => { console.log(result); }) .catch(error => { console.error('Помилка:', error); });
Загалом, ланцюжки промісів дозволяють зробити код більш читабельним, структурованим та зручним у використанні. Вони особливо корисні в ситуаціях, коли необхідно виконати серію взаємозалежних асинхронних дій, наприклад, у нашій піцерії!
Статичні методи обіцянок з прикладами
Крім властивостей, станів та обробників Promises передбачають також вбудовані статичні методи — Static methods. Це методи, які надаються безпосередньо об’єкту Promise і дозволяють працювати з ним на більш високому рівні. Ключовим призначенням є: вирішення типових завдань, пов’язаних з обробкою кількох промісів або створенням нових промісів з певними властивостями.
Раніше ми вже згадували Promise.resolve(value)
та Promise.reject(error)
в контексті конструктора. Їх використання є найбільш поширеним, але розглянемо все попорядку:
-
Promise.resolve() — піца вже готова
Уявімо, що одна з піц вже була приготована заздалегідь і просто очікує на видачу. Метод Promise.resolve() створює проміс, який виконується негайно з переданим значенням. Це зручно, коли потрібно обгорнути значення у проміс або імітувати вже готовий результат.
const readyPizza = Promise.resolve("Піцца Маргарита готова"); readyPizza.then((result) => console.log(result)); // Виведе: Піцца Маргарита готова
-
Promise.reject() — піцу зіпсували
Трапляється й таке, що піцу зіпсували ще до того, як почали готувати — наприклад, закінчилися інгредієнти. Promise.reject() створює проміс, який відхиляється негайно з вказаною причиною. Зручно для тестування помилок або миттєвого повернення відмови.
const spoiledPizza = Promise.reject("Інгредієнти закінчилися"); spoiledPizza.catch((error) => console.error(error)); // Виведе: Інгредієнти закінчилися
-
Promise.all() — всі замовлення виконані
Уявімо, що у нашій піцерії клієнт замовив кілька піц, і кухарі готують їх одночасно. Ми хочемо видати замовлення тільки тоді, коли всі піци готові. Саме так працює Promise.all(). Цей метод приймає масив обіцянок і повертає новий проміс, який виконується, коли всі проміси завершені. Якщо хоча б один проміс відхилено, весь ланцюжок теж буде відхилений.
const promise1 = Promise.resolve("Зображення 1 завантажено"); const promise2 = Promise.resolve("Зображення 2 завантажено"); Promise.all([promise1, promise2]) .then((results) => console.log(results)) // Виведе: ['Зображення 1 завантажено', 'Зображення 2 завантажено'] .catch((error) => console.error(error));
-
Promise.any() — хто перший принесе піцу
Уявімо ситуацію: у піцерії працює кілька кур’єрів, і наше завдання — отримати першу доставлену піцу, незалежно від того, хто її приніс. Promise.any() виконується при першому успішному промісі. Якщо всі проміси відхилені, буде повернена помилка AggregateError.
const promise1 = Promise.reject("Помилка 1"); const promise2 = Promise.resolve("Успіх"); const promise3 = Promise.reject("Помилка 2"); Promise.any([promise1, promise2, promise3]) .then((result) => console.log(result)) // Виведе: Успіх .catch((error) => console.error(error));
-
Promise.allSettled() — всі кур’єри відзвітували
Уявімо, що ми відправили кілька кур’єрів, і хочемо дізнатися, хто з них виконав доставку, а хто — ні. Promise.allSettled() повертає масив об’єктів, які містять статус кожного промісу.
const promise1 = Promise.resolve("Успіх"); const promise2 = Promise.reject("Помилка"); Promise.allSettled([promise1, promise2]) .then((results) => console.log(results)); // Виведе: [{status: 'fulfilled', value: 'Успіх'}, {status: 'rejected', reason: 'Помилка'}]
-
Promise.race() — хто швидше приготує піцу
Цей метод схожий на змагання серед кухарів: перша піца, що буде готова, негайно відправляється клієнту. Promise.race() повертає результат першого виконаного промісу, незалежно від його статусу.
const promise1 = new Promise((resolve) => setTimeout(() => resolve("Швидший"), 1000)); const promise2 = new Promise((resolve) => setTimeout(() => resolve("Повільніший"), 2000)); Promise.race([promise1, promise2]) .then((result) => console.log(result)) // Виведе: Швидший .catch((error) => console.error(error));
-
Promise.try() — спроба приготувати новий рецепт
Уявімо, що шеф-кухар вирішив експериментувати з новим рецептом піци, але не знає, чи вона вийде смачною. Promise.try() (нестандартний метод, але часто використовується в бібліотеках) дозволяє обробляти винятки в асинхронних функціях без додаткових try/catch блоків. Я знайшла обмежені дані на порталі mdn web docs. Однак ще зустрічала інформацію, що він користується популярністю у деяких бібліотеках, таких як Bluebird. Основна мета цього методу — спростити роботу з функціями, які можуть кидати винятки, і автоматично обробляти їх як відхилені проміси.
const fetchData = () => { if (Math.random() > 0.5) { return "Успіх!"; } else { throw new Error("Помилка під час виконання!"); } }; Promise.try(() => fetchData()) .then((result) => console.log(result)) .catch((error) => console.error(error.message));
Статичні методи промісів, такі як .all()
, .any()
, .allSettled()
, .race()
, .try()
, корисні для вирішення конкретних завдань. Хоча вони використовуються рідше, у правильних ситуаціях вони можуть значно спростити асинхронну логіку. У піцерії, як і в програмуванні, важливо вибрати правильний підхід для ефективної роботи! 🍕
Скасування Promise: старі та сучасні підходи
Уявімо, що ви керуєте піцерією, і клієнти постійно роблять замовлення. Але інколи буває так, що хтось раптово передумав або вирішив змінити своє замовлення. Що робити в такому випадку? Саме для таких ситуацій у програмуванні існує механізм Promise Cancellation — спосіб скасовувати асинхронні операції, якщо вони більше не потрібні.
Promise Cancellation є важливим аспектом управління асинхронними операціями. Завдяки йому можливо економити ресурси та підвищувати ефективність програми.
За замовчуванням в JavaScript обіцянки не підтримують скасування. Якщо проміс був запущений (наприклад, приготування піци) вже розпочався, то його не можна скасувати ззовні.
Але уявімо, що клієнт телефонує та каже: «Я передумав, не потрібно готувати мою піцу!» — у такому випадку варто мати можливість скасувати замовлення, щоб не витрачати інгредієнти та ресурси кухні даремно. Є декілька варіантів, які дозволяють виконувати операцію скасування. Більшість джерел рекомендує використовувати вже готові інструменти в різних бібліотеках (наприклад bluebird або p-cancelable).
Один із варіантів реалізації — використання AbortController, який дозволяє зупиняти запити fetch, що дуже схоже на ситуацію скасування замовлення в піцерії:
const fetchData = (url) => { const controller = new AbortController(); const signal = controller.signal; const promise = new Promise((resolve, reject) => { fetch(url, { signal }) .then(response => response.json()) .then(data => resolve(data)) .catch(error => reject(error)); }); promise.cancel = () => { controller.abort(); }; return promise; }; const dataPromise = fetchData('https://api.example.com/data'); // Скасування промісу через 1 секунду setTimeout(() => { dataPromise.cancel(); }, 1000); dataPromise .then(data => { console.log('Дані отримано:', data); }) .catch(error => { console.error('Помилка:', error); });
Сучасні підходи: Promise.withResolvers()
Новим та більш зручним способом скасування обіцянок є Promise.withResolvers(), доданий у 2024 році. Він дозволяє більш явно керувати промісами та їх станами.
Уявімо, що шеф-кухар отримав замовлення, але офіціант раптово каже: «Скасуй піцу для цього столика!». Саме тут у гру вступає Promise.withResolvers()
: за відгуками розробників (оцінка на основі прочитаних публікацій, наприклад стаття «Mastering promise cancellation in JavaScript» або «Новий метод Promise.withResolvers тепер доступний у браузерах») — це зручний та корисний інструмент.
const { promise, resolve, reject } = Promise.withResolvers(); const cookPizza = (назва) => { console.log(`Готуємо піцу: ${назва}...`); setTimeout(() => resolve(`${назва} готова!`), 3000); }; cookPizza("Маргарита"); // Скасовуємо замовлення через 1 секунду setTimeout(() => { console.log("Замовлення скасовано, піца не потрібна!"); reject("Скасування замовлення."); }, 1000); promise .then((result) => console.log(result)) .catch((error) => console.error("Помилка: ", error));
Promise.withResolvers() є статичним методом, який повертає об’єкт, що містить новий Promise об’єкт і дві функції для його вирішення (resolve) або відхилення (reject).
Альтернативний підхід: Observables
Інший спосіб керування потоками запитів — це Observables, які працюють як стрічка подій. Уявіть, що кур’єр отримує одразу кілька замовлень і може приймати або скасовувати їх на льоту. Це чудово підходить для сценаріїв, коли треба реагувати на безперервний потік даних, наприклад, оновлення статусу доставки піци в реальному часі.
Скасування асинхронних операцій — важливий інструмент оптимізації ресурсів та покращення користувацького досвіду. У піцерії, як і в коді, завжди корисно мати можливість швидко зупинити непотрібні процеси, щоб уникнути марнування ресурсів! 🍕😃
Висновок
Як і в справжній піццерії, де важливо керувати замовленнями, оптимізувати процеси та швидко реагувати на зміни, Promise допомагають нам ефективно працювати з асинхронними операціями. Ми навчилися:
- правильно обробляти кілька одночасних запитів;
- чекати на завершення всіх процесів;
- скасовувати непотрібні запити;
- чекати на завершення всіх процесів, а також скасовувати непотрібні запити, коли клієнт передумав або з’явився більш терміновий пріоритет.
У світі розробки, як і в бізнесі, важливо не лише створювати, але й вміти зупиняти зайві процеси, щоб зберегти ресурси та підвищити ефективність. І тепер ти знаєш, як це зробити! Тож нехай твій код буде таким же гарячим і смачним, як щойно приготована піца! 🍕🔥
56 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів