Використання промісів

Promise – це об'єкт, що представляє завершення або невдачу асинхронної операції. Оскільки більшість людей користуються вже створеними промісами, цей посібник спершу пояснить використання повернених промісів, а потім – як їх створювати.

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

Ось трохи коду, що використовує createAudioFileAsync():

function successCallback(result) {
  console.log(`Аудіофайл готовий за URL: ${result}`);
}

function failureCallback(error) {
  console.error(`Помилка породження аудіофайлу: ${error}`);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

Якби createAudioFileAsync() була переписана для повертання промісу, натомість до неї прикріплювали б функції зворотного виклику:

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

Така домовленість має декілька переваг. Нижче – огляд кожної з них

Утворення ланцюжків

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

doSomething(function (result) {
  doSomethingElse(
    result,
    function (newResult) {
      doThirdThing(
        newResult,
        function (finalResult) {
          console.log(`Отриманий остаточний результат: ${finalResult}`);
        },
        failureCallback,
      );
    },
    failureCallback,
  );
}, failureCallback);

З промісами таке досягається шляхом створення ланцюжка промісів. Устрій API промісів справляється з цим чудово, бо функції зворотного виклику прикріпляються до поверненого об'єкта проміса, а не передається до функції.

Ось магія: функція then() повертає новий проміс, відмінний від вихідного:

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

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

[!NOTE] Якщо хочете приклад, з яким можна погратися, то можете використати наступний шаблон для створення будь-якої функції, що повертає проміс:

function doSomething() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // Інші справи – до завершення проміса
      console.log("Виконані якісь дії");
      // Значення сповнення проміса
      resolve("https://example.com/");
    }, 200);
  });
}

Про цю реалізацію – в розділі Створення Promise навколо старого API з функціями зворотного виклику нижче.

За допомогою цього патерну можна створювати довші ланцюжки обробки, де кожний проміс представляє завершення одного асинхронного кроку в ланцюжку. На додачу, аргументи функції then – необов'язкові, а catch(failureCallback) – скорочення для then(null, failureCallback), – тож коли код обробки помилок один і той же для всіх кроків, його можна додати в кінець ланцюжка:

doSomething()
  .then(function (result) {
    return doSomethingElse(result);
  })
  .then(function (newResult) {
    return doThirdThing(newResult);
  })
  .then(function (finalResult) {
    console.log(`Отриманий остаточний результат: ${finalResult}`);
  })
  .catch(failureCallback);

Можна зустріти вираження цього за допомогою стрілкових функцій:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log(`Отриманий остаточний результат: ${finalResult}`);
  })
  .catch(failureCallback);

[!NOTE] Вирази стрілкових функцій можуть мати неявне повернення; таким чином, () => x – це скорочення для () => { return x; }. Функції doSomethingElse і doThirdThing можуть повертати будь-які значення – якщо вони повертають проміси, то відбувається очікування вирішення цих промісів, і наступний обробник отримує значення сповнення, а не сам проміс. Важливо завжди повертати з обробників then проміси, навіть якщо проміс завжди вирішується в undefined. Якщо попередній обробник запустив проміс, але не повернув його, то неможливо відстежити його залагодження, і про проміс кажуть, що він "повис".

doSomething()
  .then((url) => {
    // Пропущено ключове слово `return` перед fetch(url).
    fetch(url);
  })
  .then((result) => {
    // result – undefined, тому що нічого не було повернено з попереднього
    // обробника. Тепер неможливо дізнатися повернене значення виклику
    // fetch(), як і те, чи завершився цей виклик успіхом взагалі.
  });

Повернувши результат виклику fetch (який є промісом), можна і відстежувати його завершення, і отримати його значення, коли воно буде готове.

doSomething()
  .then((url) => {
    // додано ключове слово `return`
    return fetch(url);
  })
  .then((result) => {
    // result – це об'єкт Response
  });

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

const listOfIngredients = [];

doSomething()
  .then((url) => {
    // Пропущено ключове слово `return` перед fetch(url).
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients завжди буде [], бо запит на отримання іще не був завершений.
  });

Таким чином, досвід підказує, що кожного разу, коли операція зустрічає проміс, його слід повернути й делегувати його обробку наступному обробникові then.

const listOfIngredients = [];

doSomething()
  .then((url) => {
    // тепер перед викликом fetch додано ключове слово `return`
    return fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // тепер listOfIngredients містить дані з виклику fetch.
  });

Іще кращий варіант: можна сплющити вкладений ланцюжок в один ланцюжок, котрий буде простішим і зручнішим для обробки помилок. Подробиці – в розділі Вкладення нижче.

doSomething()
  .then((url) => fetch(url))
  .then((res) => res.json())
  .then((data) => {
    listOfIngredients.push(data);
  })
  .then(() => {
    console.log(listOfIngredients);
  });

Застосування async та await допомагає писати код, що більш інтуїтивно зрозумілий і нагадує синхронний код. Нижче – той самий приклад, але з використанням async та await:

async function logIngredients() {
  const url = await doSomething();
  const res = await fetch(url);
  const data = await res.json();
  listOfIngredients.push(data);
  console.log(listOfIngredients);
}

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

Синтаксис async та await заснований на промісах: наприклад, doSomething() – це та сама функція, що й раніше, тож для переходу від промісів до async та await необхідний мінімум рефакторингу коду. Більше про синтаксис async та await – на довідкових сторінках асинхронних функцій та await.

[!NOTE] Синтаксис async та await має таку ж семантику конкурентності, що й звичайні ланцюжки промісів. Ключове слово await всередині однієї асинхронної функції зупиняє не всю програму, а лише ті її частини, що залежать від його значення, тож інші асинхронні задачі можуть виконуватися, поки await перебуває в стані очікування.

Обробка помилок

Ви можете пригадати, що в піраміді безнадії вище функція failureCallback зустрічається тричі, а в ланцюжку промісів – лише один раз, в кінці:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) =>
    console.log(`Отримано остаточний результат: ${finalResult}`),
  )
  .catch(failureCallback);

Якщо виник виняток, то браузер шукає в ланцюжку згори донизу обробники .catch() або onRejected. Це дуже схоже на те, як працює синхронний код:

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Отримано остаточний результат: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

Кульмінація цієї симетрії з асинхронним кодом – синтаксис async та await:

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Отримано остаточний результат: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

Проміси розв'язують фундаментальний недолік піраміди безнадії функцій зворотного виклику, перехоплюючи всі помилки, навіть викинуті винятки та помилки програмування. Це важливо для функціонального компонування асинхронних операцій. Всі помилки тепер обробляються методом catch() в кінці ланцюжка, і майже ніколи не варто використовувати try і catch без async та await.

Вкладення

Серед прикладів вище, де є listOfIngredients, перший приклад має один ланцюжок промісів, вкладений у повернене значення іще одного обробника then(), а другий – використовує цілком плаский ланцюжок. Прості ланцюжки промісів найкраще залишати пласкими, без вкладення, оскільки вкладення може бути наслідком бездумної композиції.

Вкладення є контрольною структурою для обмеження області дії інструкцій catch. А саме, вкладений catch перехоплює невдачі лише зі своєї області дії та нижче, але не помилки вище в ланцюжку поза вкладеною областю. Коли це застосовується правильно, то дає більшу точність при відновленні від помилок:

doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // Ігнорувати, якщо необов'язкові штуки не вдаються; продовжувати.
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

Зверніть увагу, що необов'язкові кроки – вкладені, і вкладеність спричинена не відступами, а поміщенням навколо таких кроків зовнішніх дужок ( і ).

Внутрішня глушилка помилок catch перехоплює лише невдачі з doSomethingOptional() і doSomethingExtraNice(), після чого код продовжує роботу з moreCriticalStuff(). Що важливіше, якщо не вдається doSomethingCritical(), то така помилка перехоплюється лише прикінцевим (зовнішнім) catch, і не поглинається внутрішнім обробником catch.

З async та await цей код виглядає так:

async function main() {
  try {
    const result = await doSomethingCritical();
    try {
      const optionalResult = await doSomethingOptional(result);
      await doSomethingExtraNice(optionalResult);
    } catch (e) {
      // Ігнорувати невдачі необов'язкових кроків і продовжувати.
    }
    await moreCriticalStuff();
  } catch (e) {
    console.error(`Критичний провал: ${e.message}`);
  }
}

[!NOTE] Якщо у вас немає складної обробки помилок, то вам, ймовірно, не потрібні вкладені обробники then. Замість цього використовуйте плаский ланцюжок і помістіть логіку обробки помилок в кінець.

Ланцюжок промісів після catch

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

doSomething()
  .then(() => {
    throw new Error("Щось не вийшло");

    console.log("Дії такі");
  })
  .catch(() => {
    console.error("Дії сякі");
  })
  .then(() => {
    console.log("Ще одні дії, незалежно від того, що сталося раніше");
  });

Цей код виведе наступний текст:

Початок
Дії сякі
Дії незалежно від того, що відбувалося раніше

[!NOTE] Текст "Дії такі" не виводиться, адже помилка "Щось не вийшло" спричинила відмову. З async та await цей код має такий вигляд:

async function main() {
  try {
    await doSomething();
    throw new Error("Щось не вийшло");
    console.log("Дії такі");
  } catch (e) {
    console.error("Дії сякі");
  }
  console.log("Ще одні дії, незалежно від того, що сталося раніше");
}

Події відхилення промісів

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

unhandledrejection

Надсилається, коли проміс відхилено, але немає обробника відхилення.

rejectionhandled

Надсилається, коли обробник прикріплений до відхиленого проміса, що вже призвів до події unhandledrejection.

В обох випадках подія (типу PromiseRejectionEvent) містить властивість promise, котра вказує, який проміс був відхилений, та властивість reason, котра надає причину, через яку він був відхилений.

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

У Node.js обробка відхилення промісів дещо відрізняється. Необроблені відхилення перехоплюються шляхом додавання обробника для події Node.js unhandledRejection (зверніть увагу на присутність великої літери в назві), отак:

process.on("unhandledRejection", (reason, promise) => {
  // Сюди додати код для дослідження значень "promise" і "reason"
});

На Node.js для запобігання виведенню помилки в консоль (усталеної реакції, що відбулась би інакше) додати такого слухача process.on() – усе що потрібно; немає потреби в чомусь рівносильному методові браузерного середовища preventDefault().

Проте якщо додати такого слухача process.on, але без коду всередині нього, що обробляє відхилені проміси, ці проміси просто впадуть на землю й будуть тихо проігноровані. Тож в ідеалі слід додати в слухача код, що досліджує кожний відхилений проміс і пересвідчується, що відхилення не було спричинено реальною вадою коду.

Композиція

Є чотири інструменти композиції для рівночасного запуску асинхронних операцій: Promise.all(), Promise.allSettled(), Promise.any() і Promise.race().

Запустити операції паралельно й чекати, поки всі вони виконаються, можна отак:

Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // використання result1, result2 і result3
});

Коли один зі промісів у масиві відхиляється, то Promise.all() негайно відхиляє повернений проміс та перериває решту операцій. Це може призвести до неочікуваних стану чи поведінки. Promise.allSettled() – іще один інструмент композиції, котрий дочекається перед своїм вирішенням завершення всіх операцій.

Всі ці методи запускають проміси паралельно: послідовність промісів починається водночас і не чекає одне на одного. Послідовна композиція можлива за допомогою дещо хитрого JavaScript:

[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* використання result3 */
  });

У цьому прикладі масив асинхронних функцій зводиться до ланцюжка промісів. Код вище – рівносильний щодо:

Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* використання result3 */
  });

Це можна перетворити на повторно використовну функцію композиції, що поширено в функційному програмуванні:

const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
  (...funcs) =>
  (x) =>
    funcs.reduce(applyAsync, Promise.resolve(x));

Функція composeAsync() приймає як аргументи будь-яку кількість функцій, а повертає – нову функцію, котра приймає початкове значення, котре буде пропущено через увесь конвеєр композиції:

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

Послідовна композиція також може бути реалізована більш стисло за допомогою async/await:

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* використання останнього результату (тобто result3) */

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

Скасування

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

Створення Promise навколо старого API з функціями зворотного виклику

Promise можна створити з нуля за допомогою конструктора. Це повинно бути необхідно лише для загортання старих API.

В ідеальному світі усі асинхронні операції вже повинні повертати проміси. На жаль, частина API досі очікує на передачу функцій зворотного виклику для успіху чи невдачі, за старим стилем. Найочевидніший приклад цього – функція setTimeout():

setTimeout(() => saySomething("10 секунд минули"), 10 * 1000);

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

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

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(10 * 1000)
  .then(() => saySomething("10 секунд"))
  .catch(failureCallback);

Конструктор промісів приймає функцію-виконавця, котра дає змогу вручну вирішити або відхилити проміс. Оскільки setTimeout насправді не зазнає невдач, у цьому випадку можна упустити відхилення. Більше про те, як працює функція-виконавець, – на довідковій сторінці Promise().

Хронометраж

І наостанок – погляньмо на більш технічні деталі, на те, коли викликаються зареєстровані функції зворотного виклику.

Гарантії

В API на основі функцій зворотного виклику те, коли і як ці функції викликаються, залежить від автора API. Наприклад, вони можуть бути викликані синхронно або асинхронно:

function doSomething(callback) {
  if (Math.random() > 0.5) {
    callback();
  } else {
    setTimeout(() => callback(), 1000);
  }
}

Наведений вище підхід наполегливо не рекомендується, адже призводить до так званого "стану Зальґо". У контексті проєктування асинхронних API це означає, що функція зворотного виклику викликається синхронно в одних випадках, але асинхронно в інших, призводячи до неоднозначності для того, хто її викликає. Більше контексту на цю тему можна знайти в статті Проєктування API з асинхронністю, де цей термін уперше вбув формально введений. Такий підхід до створення API призводить до того, що побічні ефекти важко аналізувати:

let value = 1;
doSomething(() => {
  value = 2;
});
console.log(value); // 1 чи 2?

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

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

Для уникнення сюрпризів функції, передані в then(), ніколи не викликаються синхронно, навіть для вже вирішеного промісу

Promise.resolve().then(() => console.log(2));
console.log(1);
// Виводить: 1, 2

Замість негайного виконання передана функція ставиться в чергу мікрозавдань, а отже – вона спрацьовує пізніше (лише після того, як відбувається вихід з функції, котра її створила, і коли стек виконання JavaScript порожній), лишень перед тим, як контроль повертається циклові подій; тобто доволі скоро:

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log(4));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

Задачі й мікрозавдання

Функції зворотного виклику промісів обробляються як мікрозавдання, натомість функції зворотного виклику setTimeout() обробляються як прості задачі.

const promise = new Promise((resolve, reject) => {
  console.log("Функція зворотного виклику промісу");
  resolve();
}).then((result) => {
  console.log("Функція зворотного виклику промісу (.then)");
});
setTimeout(() => {
  console.log("цикл подій: проміс (сповнений)", promise);
}, 0);

console.log("Проміс (в очікуванні)", promise);

Код вище виведе:

Функція зворотного виклику промісу
Проміс (в очікуванні) Promise {<pending>}
Функція зворотного виклику промісу (.then)
цикл подій: проміс (сповнений) Promise {<fulfilled>}

Подробиці доступні в Задачах і мікрозавданнях.

Коли проміси й задачі стикаються

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

Якщо здається, що допомогти розв'язати проблему можуть мікрозавдання, дивіться посібник з мікрозавдань, аби дізнатися, як використати queueMicrotask() для постановки функції в чергу як мікрозавдання.

Дивіться також