24 квітня 2022 р.

Async/await

Існує спеціальний синтаксис для більш зручної роботи з промісами, який називається “async/await”. Його напрочуд легко зрозуміти та використовувати.

Асинхронні функції

Почнемо з ключового слова async. Його можна розмістити перед функцією, наприклад:

async function f() {
  return 1;
}

Слово async перед функцією означає одну просту річ: функція завжди повертає проміс. Інші значення автоматично загортаються в успішно виконаний проміс.

Наприклад, ця функція повертає успішно виконаний проміс з результатом 1; протестуймо:

async function f() {
  return 1;
}

f().then(alert); // 1

…Ми могли б явно повернути проміс, результат буде таким самим:

async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1

Отже, async гарантує, що функція повертає проміс і обгортає в нього не-проміси. Досить просто, правда? Але це ще не все. Є ще одне ключове слово, await, яке працює лише всередині async-функцій, і воно досить круте.

Await

Синтаксис:

// працює лише всередині async-функцій
let value = await promise;

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

Ось приклад з промісом, який виконується за 1 секунду:

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("готово!"), 1000)
  });

  let result = await promise; // чекатиме, поки проміс не виконається (*)

  alert(result); // "готово!"
}

f();

Виконання функції “призупиняється” у рядку (*) і відновлюється, коли проміс виконається, а результатом стає result. Отже, код вище показує “готово!” через одну секунду.

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

Це просто більш елегантний синтаксис отримання результату проміса, ніж promise.then. Зокрема, так це легше читати й писати.

Не можна використовувати await у звичайних функціях

Якщо ми спробуємо використати await у не-асинхронній функції, виникне синтаксична помилка:

function f() {
  let promise = Promise.resolve(1);
  let result = await promise; // Syntax error
}

Ми можемо отримати цю помилку, якщо забудемо поставити async перед функцією. Як було сказано раніше, await працює лише всередині async-функцій.

Давайте візьмемо за приклад showAvatar() з розділу Ланцюжок промісів і перепишемо його за допомогою async/await:

  1. Нам потрібно замінити виклики .then на await.
  2. Також ми повинні оголосити функцію як async, щоб вони працювали.
async function showAvatar() {

  // зчитуємо наш JSON
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();

  // зчитуємо користувача github
  let githubResponse = await fetch(`https://api.github.com/users/${user.name}`);
  let githubUser = await githubResponse.json();

  // показуємо аватар
  let img = document.createElement('img');
  img.src = githubUser.avatar_url;
  img.className = "promise-avatar-example";
  document.body.append(img);

  // очікуємо 3 секунди
  await new Promise((resolve, reject) => setTimeout(resolve, 3000));

  img.remove();

  return githubUser;
}

showAvatar();

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

Сучасні браузери допускають await верхнього рівня в модулях

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

Наприклад:

// ми припускаємо, що цей код виконується на верхньому рівні вкладеності всередині модуля
let response = await fetch('/article/promise-chaining/user.json');
let user = await response.json();

console.log(user);

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

Ось так:

(async () => {
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();
  ...
})();
await працює з “thenable”-об’єктами

Як і promise.then, await дозволяє нам використовувати thenable-об’єкти (їх можна викликати методом then). Ідея полягає в тому, що сторонній об’єкт може не бути промісом, але бути сумісним з промісом: якщо він підтримує .then, цього достатньо, щоб використовувати його з await.

Ось приклад класу Thenable; нижче await приймає його екземпляри:

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve);
    // виконається з this.num*2 через 1000мс
    setTimeout(() => resolve(this.num * 2), 1000); // (*)
  }
}

async function f() {
  // чекатиме 1 секунду, після чого результат стане 2
  let result = await new Thenable(1);
  alert(result);
}

f();

Якщо await отримує об’єкт не-проміс із .then, він викликає цей метод, що надає як аргументи вбудовані функції resolve та reject (так само як це робиться для звичайного виконання Promise). Потім await чекає, поки не буде викликано один з них (у вищенаведеному прикладі це відбувається в рядку (*)), а потім переходить до результату.

Асинхронні методи класів

Щоб оголосити асинхронний метод класу, просто додайте перед ним async:

class Waiter {
  async wait() {
    return await Promise.resolve(1);
  }
}

new Waiter()
  .wait()
  .then(alert); // 1 (це те ж саме, що й (result => alert(result)))

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

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

Якщо проміс виконується нормально, то await promise повертає результат. Але у випадку завершення з помилкою він генерує помилку, ніби в цьому рядку був оператор throw.

Цей код:

async function f() {
  await Promise.reject(new Error("Упс!"));
}

…робить те ж саме, що й цей:

async function f() {
  throw new Error("Упс!");
}

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

Ми можемо зловити цю помилку за допомогою try..catch, так само як і звичайний throw:

async function f() {

  try {
    let response = await fetch('http://no-such-url');
  } catch(err) {
    alert(err); // TypeError: failed to fetch
  }
}

f();

У разі помилки керування переходить до блоку catch. Ми також можемо обгорнути таким чином кілька рядків:

async function f() {

  try {
    let response = await fetch('/no-user-here');
    let user = await response.json();
  } catch(err) {
    // перехоплює помилки як у fetch, так і в response.json
    alert(err);
  }
}

f();

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

async function f() {
  let response = await fetch('http://no-such-url');
}

// f() поверне проміс, що завершився з помилкою
f().catch(alert); // TypeError: failed to fetch // (*)

Якщо ми забудемо додати .catch, то отримаємо необроблену помилку проміса (можна переглянути в консолі). Ми можемо відловити такі помилки за допомогою глобального обробника події unhandledrejection, як описано в розділі Проміси: обробка помилок.

async/await та promise.then/catch

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

Але на верхньому рівні вкладеності коду, коли ми знаходимося за межами будь-якої функції async, ми синтаксично не можемо використовувати await, тому звичайна практика – додати .then/catch для обробки кінцевого результату або помилки, що була повернута, як у рядку “(*)” у прикладі вище.

async/await чудово працює з Promise.all

Коли нам потрібно дочекатися кількох промісів, ми можемо загорнути їх у Promise.all, а потім додати await:

// чекаємо масив результатів
let results = await Promise.all([
  fetch(url1),
  fetch(url2),
  ...
]);

У разі помилки вона передаватиметься як зазвичай: від невдалого проміса до Promise.all, а потім стає винятком, який ми можемо зловити за допомогою try..catch навколо виклику.

Підсумки

Ключове слово async перед функцією має два ефекти:

  1. Змушує її завжди повертати проміс.
  2. Дозволяє використовувати в ній await.

Ключове слово await перед промісом змушує JavaScript чекати, поки цей проміс не виконається, а потім:

  1. Якщо це помилка, генерується виняток — так само, ніби throw error було викликано саме в цьому місці.
  2. В іншому випадку він повертає результат.

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

За допомогою async/await нам рідко потрібно писати promise.then/catch, але ми все одно не повинні забувати, що вони засновані на промісах, тому що іноді (наприклад, на верхньому рівні вкладеності) нам доводиться використовувати ці методи. Також Promise.all стає в нагоді, коли ми чекаємо на виконання багатьох завдань одночасно.

Завдання

Перепишіть цей приклад коду з розділу Ланцюжок промісів, використовуючи async/await замість .then/catch:

function loadJson(url) {
  return fetch(url)
    .then(response => {
      if (response.status == 200) {
        return response.json();
      } else {
        throw new Error(response.status);
      }
    });
}

loadJson('https://javascript.info/no-such-user.json')
  .catch(alert); // Error: 404

Примітки наведено під кодом:

async function loadJson(url) { // (1)
  let response = await fetch(url); // (2)

  if (response.status == 200) {
    let json = await response.json(); // (3)
    return json;
  }

  throw new Error(response.status);
}

loadJson('https://javascript.info/no-such-user.json')
  .catch(alert); // Error: 404 (4)

Примітки:

  1. Функція loadJson стає async-функцією.

  2. Усі .then всередині замінюються на await.

  3. Ми можемо зробити return response.json() замість того, щоб чекати його, наприклад:

    if (response.status == 200) {
      return response.json(); // (3)
    }

    Тоді зовнішній код повинен був би чекати await, поки цей проміс буде виконано. У нашому випадку це не має значення.

  4. Помилка, викликана loadJson, обробляється .catch. Ми не можемо використовувати там await loadJson(…), тому що ми не використовуємо async-функцію.

Нижче ви можете знайти приклад “rethrow”. Перепишіть його, використовуючи async/await замість .then/catch.

І позбудьтеся від рекурсії на користь циклу в demoGithubUser: за допомогою async/await це буде легко зробити.

class HttpError extends Error {
  constructor(response) {
    super(`${response.status} for ${response.url}`);
    this.name = 'HttpError';
    this.response = response;
  }
}

function loadJson(url) {
  return fetch(url)
    .then(response => {
      if (response.status == 200) {
        return response.json();
      } else {
        throw new HttpError(response);
      }
    });
}

// Запитуйте ім’я користувача, поки github не поверне дійсного користувача
function demoGithubUser() {
  let name = prompt("Введіть ім’я?", "iliakan");

  return loadJson(`https://api.github.com/users/${name}`)
    .then(user => {
      alert(`Ім’я та прізвище: ${user.name}.`);
      return user;
    })
    .catch(err => {
      if (err instanceof HttpError && err.response.status == 404) {
        alert("Такого користувача не існує, будь ласка, введіть ще раз.");
        return demoGithubUser();
      } else {
        throw err;
      }
    });
}

demoGithubUser();

Тут немає ніяких хитрощів. Просто замініть .catch на try..catch всередині demoGithubUser і додайте async/await, де це потрібно:

class HttpError extends Error {
  constructor(response) {
    super(`${response.status} for ${response.url}`);
    this.name = 'HttpError';
    this.response = response;
  }
}

async function loadJson(url) {
  let response = await fetch(url);
  if (response.status == 200) {
    return response.json();
  } else {
    throw new HttpError(response);
  }
}

// Запитуйте ім’я користувача, поки github не поверне дійсного користувача
async function demoGithubUser() {

  let user;
  while(true) {
    let name = prompt("Введіть ім’я?", "iliakan");

    try {
      user = await loadJson(`https://api.github.com/users/${name}`);
      break; // помилки немає, виходимо з циклу
    } catch(err) {
      if (err instanceof HttpError && err.response.status == 404) {
        // цикл продовжиться після сповіщення
        alert("Такого користувача не існує, будь ласка, введіть ще раз.");
      } else {
        // невідома помилка, перепрокидуємо її
        throw err;
      }
    }
  }


  alert(`Ім’я та прізвище: ${user.name}.`);
  return user;
}

demoGithubUser();

У нас є “звичайна” функція під назвою f. Як ви можете викликати async-функцію wait() і використовувати її результат всередині f?

async function wait() {
  await new Promise(resolve => setTimeout(resolve, 1000));

  return 10;
}

function f() {
  // ...що тут варто написати?
  // нам потрібно викликати async-функцію wait() і почекати, щоб отримати 10
  // пам’ятайте, ми не можемо використовувати "await"
}

P.S. Технічно завдання дуже просте, але дане питання досить поширеним серед розробників, які тільки починають працювати з async/await.

Це той випадок, коли корисно знати, як воно працює всередині.

Просто трактуйте виклик async як проміс та додайте до нього .then:

async function wait() {
  await new Promise(resolve => setTimeout(resolve, 1000));

  return 10;
}

function f() {
  // покаже 10 через 1 секунду
  wait().then(result => alert(result));
}

f();
Навчальна карта