Головна / Статті

Рефакторинг ланцюгів Promise у Async/Await без зміни поведінки

Як перетворити ланцюжки .then() на зрозумілий код async/await, що саме зупиняє await, коли виконувати кроки паралельно та як try/catch замінює .catch().

Видавець: REACTAPP.TOP7 хв читання1597 слів

Джерела: Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Обіцянки врятували JavaScript від використання вкладених керувальних функцій, але ланцюжок залежних кроків .then() все ще нагадує складну систему з’єднань, а не просту послідовність інструкцій. async/await усуває цю проблему, дозволяючи асинхронному коду виглядати як звичайний код, що виконується зверху вниз, при цьому залишаючи обіцянки в основі. Цей посібник показує, як безпечно перетворювати ланцюжок обіцянок, що саме зупиняє await, коли послідовні виклики await марнують час, та як змінюється обробка помилок, щоб можна було рефакторити існуючий код, не змінюючи його функціоналу.

Від ланцюжка then до лінійної функції

Ось типовий ланцюжок, у якому кожен крок залежить від результату попереднього: спочатку завантажується користувач, потім його замовлення, а далі квитанції до цих замовлень, причому існує один обробник для будь-яких помилок.

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getReceipts(orders))
  .then(receipts => {
    console.log(receipts);
  })
  .catch(err => handleError(err));

Це правильно та набагато краще, ніж вкладені калебеки, але все одно доводиться візуально простежувати ланцюжок, щоб зрозуміти потік даних. async/await, стандартизовані у ES2017, були створені для того, щоб усунути цю останню перешкоду. Та сама логіка у переписаному вигляді виглядає так:

async function loadData(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    console.log(receipts);
  } catch (err) {
    handleError(err);
  }
}

Поведінка відповідає ланцюжку: кожен крок починається лише після завершення попереднього, а будь-яка помилка потрапляє до єдиного обробника помилок. Змінилася лише читабельність. Кожне проміжне значення тепер має назву, і потік даних простежується зверху вниз.

Пам’ятайте про одну основну річ: async/await не замінює обіцянки. Це лише синтаксичний еквівалент для них. Обіцянки все ще виконують асинхронну роботу; синтаксис лише змінює спосіб їх написання та читання.

Що змінює ключове слово async

Розміщення ключового слова async перед функцією має два наслідки. Функція нижче повертає звичайний об’єкт:

async function getUser(id) {
  return { id, name: "Aarav" };
}

Вона завжди повертає обіцянку

Хоча функція getUser повертає звичайний об’єкт, її виклик забезпечує отримання обіцянки, яка у результаті реалізується цим об’єктом. Тому користувачі можуть працювати з нею як із будь-якою іншою обіцянкою:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

Якщо функція кидає помилку, повернена обіцянка відхиляється, замість того щоб помилка поширювалася синхронно. Це легко забути, коли функція, яка раніше була синхронною, стає async: кожен користувач, який раніше використовував її значення безпосередньо, тепер отримує обіцянку та мусить бути проінформований про це.

Це дозволяє використовувати await усередині її тіла

await є дійсним всередині async функції. Сучасний JavaScript також дозволяє використання await на верхньому рівні в ES-модулях, що зручно для скриптів та ініціалізації модулів, але у звичайних функціях все одно потрібен async.

Що насправді зупиняє await

Вираз await повертає керування назад доки його обіцянка не буде вирішена так чи інакше. У разі успіху вираз повертає значення, отримане після вирішення; у разі невдачі ця строка викликає помилку, так само як це зробила б інструкція throw. У прикладі виводиться повідомлення, очікується дія користувача, а потім — результат:

async function loadUser() {
  console.log("Fetching user...");
  const user = await getUser(1); // pauses here until getUser's promise settles
  console.log("Got user:", user);
}

Результат виводиться у такому порядку:

Fetching user...
Got user: { id: 1, name: "Aarav" }

Важливою деталлю є те, що означає „пауза“. Тільки ця функція призупиняється саме у цьому рядку. Решта програми, включаючи інший код, таймери та вхідні запити на сервері, продовжує працювати. Коли обіцянка виконується, функція продовжує роботу з того місця, де зупинилась, причому значення, отримане в результаті виконання, присвоюється змінній.

Послідовні виклики awaits та їхні приховані витрати

Кілька викликів awaits поспіль виконуються один за одним:

async function loadDashboard(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const receipts = await getReceipts(orders);
  return receipts;
}

Кожен рядок чекає на завершення свого кроку, перш ніж почнеться наступний, що відтворює поведінку ланцюгових викликів .then() без самого ланцюга. Це правильно, коли кожен крок потребує результату попереднього, як у цьому випадку: неможливо отримати інформацію про замовлення, поки не відомо про користувача.

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

async function loadDashboard() {
  const user = await getUser();
  const notifications = await getNotifications();
  const recommendations = await getRecommendations();

  return { user, notifications, recommendations };
}

Загальний час стає сумою всіх трьох запитів, а не часом виконання найповільнішого з них.

Початок одночасного виконання незалежних завдань

Оскільки три виклики є незалежними, ви можете почати їх одночасно та чекати на всі з них за допомогою Promise.all():

async function loadDashboard() {
  const [user, notifications, recommendations] = await Promise.all([
    getUser(),
    getNotifications(),
    getRecommendations()
  ]);

  return { user, notifications, recommendations };
}

Усі три запити виконуються одночасно, і завдяки деструктуруванню результати отримуються у тому самому порядку, що й елементи вхідного масиву. Варто знати одну особливість: Promise.all() виконується лише тоді, коли всі обіцянки виконані, а відхиляється як тільки хоча б одна з них відхиляється, не чекаючи на решту. Якщо вам потрібні всі результати, незалежно від помилок, краще використовувати Promise.allSettled(). Варіанти використання цих засобів розглядаються у виборі між Promise.all, Promise.race та послідовними awaits.

Обробка помилок за допомогою try та catch

З використанням async/await знайомий блок try/catch замінює .catch(), тож синхронні та асинхронні помилки нарешті мають спільну структуру обробки:

async function loadUser(id) {
  try {
    const user = await getUser(id);
    console.log(user);
  } catch (err) {
    console.log("Failed to load user:", err.message);
  }
}

Якщо обіцянка, повернена функцією getUser(id), відхиляється, керування переходить безпосередньо до блоку catch, так само, як це відбувається при помилці, створеній синхронним кодом. Зауважте, що ця функція обробляє помилку та потім завершується нормально, тож її власна обіцянка виконується з значенням undefined. Ті, хто викликає цю функцію, не можуть дізнатися про невдачу завантаження. Якщо викликаючому потрібно на щось реагувати, необхідно перекинути помилку після її запису в журнал або повернути явний результат замість цього.

Один обробник для кількох кроків

Один блок try може охоплювати цілу послідовність операцій await:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    return receipts;
  } catch (err) {
    console.log("Something failed in the dashboard load:", err.message);
  }
}

Якщо якийсь крок зазнає невдачі, виконання переходить безпосередньо до catch, тому не потрібен обробник для кожного окремого кроку. Це відображає ефект централізації, який мав .catch() у кінці ланцюга. Коли різні кроки потребують різного відновлення, наприклад, лише повторної спроби виконання запиту на отримання квитанцій, необхідно обгорнути цей крок вужчим блоком try.

Порівняння обох стилів

Ось той самий завантажувач у обох формах:

// Promises
function loadDashboard(id) {
  return getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getReceipts(orders))
    .catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return await getReceipts(orders);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

Жодна з версій не змінює асинхронної логіки, яка знаходиться під спудом. Вибір залежить від того, яку версію буде легше читати та підтримувати, і у випадку багатокрокової логіки зазвичай перемагає підхід async/await. Один важливий момент у другій версії: return await getReceipts(orders) всередині try має значення. Якщо написати return getReceipts(orders) без await, обіцянка буде повернута ще до того, як вона буде задоволена, тож відмова з останнього кроку повністю пройде повз блок catch.

Поширені запитання

Чи блокує await всю програму?

Ні. Він призупиняє лише асинхронну функцію, в якій він знаходиться. Інший код, включаючи інші запити, продовжує працювати під час очікування цієї функції.

Чи можна використовувати await поза асинхронною функцією?

Це не працює у звичайних функціях. Винятком є await найвищого рівня в ES-модулях, який функціонує на рівні модуля, але не всередині звичайних неасинхронних функцій.

Чи є async/await швидшим за promises?

Ні. Він побудований на promises, тому не можна очікувати переваг у швидкості від зміни синтаксису. Переваги полягають у читабельності та зручності підтримки коду. Справжнє прискорення досягається шляхом одночасного виконання незалежних завдань, а не завдяки цьому ключовому слову.

Що відбувається без try/catch?

Якщо обіцянка, на яку очікується, відхиляється та ніщо її не ловить, обіцянка самої асинхронної функції також відхиляється. Якщо навіть викликаючий код не обробляє це відхилення, середовище виконання повідомляє про непроцесоване відхилення, що у поточних версіях Node.js за замовчуванням призводить до завершення процесу. Обробляйте помилки за допомогою try/catch всередині функції або за допомогою .catch() у місці її виклику. Щоб дізнатися більше про подібні ситуації, перегляньте поширені хибні уявлення про async/await, які спричиняють проблеми у продакшені.

Ключові моменти

  • async/await змінює спосіб написання асинхронного коду, а не його спосіб виконання; роботу все одно виконують обіцянки.
  • Функція async завжди повертає обіцянку, тому перетворення функції на async змінює її контракт для кожного викликача.
  • await призупиняє лише власну функцію, а не всю програму.
  • Очікуйте на послідовні кроки, які залежать від await, але запускайте незалежні кроки разом із Promise.all() або Promise.allSettled().
  • try/catch, які огортають кілька операцій await, централізують обробку помилок; не забувайте перекидати помилку, якщо це потрібно для тих, хто її викликає, а також використовувати return await всередині блоку try.