17 грудня 2021 р.

Асинхронні ітератори та генератори

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

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

Пригадування ітераторів

Згадаймо тему про ітератори.

Ідея полягає в тому, що ми маємо об’єкт, наприклад, range:

let range = {
  from: 1,
  to: 5
};

…І ми хотіли б використовувати для нього цикл for..of, наприклад, for(value of range), щоб отримати значення від 1 до 5.

Інакше кажучи, ми хочемо додати до об’єкта можливість перебирання (ітерації).

Це можна реалізувати за допомогою спеціального методу з назвою Symbol.iterator:

  • Цей метод викликається конструкцією for..of, коли запускається цикл, і він повинен повернути об’єкт із методом next.
  • На кожній ітерації викликається метод next(), щоб отримати наступне значення.
  • next() має повертати значення у формі {done: true/false, value:<loop value>}, де done:true означає кінець циклу.

Ось реалізація об’єкта range, що перебирається:

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() { // викликається один раз, на початку for..of
    return {
      current: this.from,
      last: this.to,

      next() { // викликається кожну ітерацію, щоб отримати наступне значення
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for(let value of range) {
  alert(value); // 1, потім 2, потім 3, потім 4, потім 5
}

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

Асинхронні ітератори

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

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

Для того, щоб перебирати об’єкт асинхронно:

  1. Використовуйте Symbol.asyncIterator замість Symbol.iterator.
  2. Метод next() повинен повертати проміс (який має містити наступне значення).
    • Це робиться за допомогою ключового слова async. Ось так: async next().
  3. Щоб перебрати такий об’єкт, ми повинні використовувати цикл for await (let item of iterable).
    • Зверніть увагу на слово await.

Як початковий приклад, створімо об’єкт range, що перебирається. Він буде подібний до попереднього, проте повертатиме значення асинхронно – по одному за секунду.

Все, що нам потрібно зробити – це внести кілька замін у коді вище:

let range = {
  from: 1,
  to: 5,

  [Symbol.asyncIterator]() { // (1)
    return {
      current: this.from,
      last: this.to,

      async next() { // (2)

        // примітка: ми можемо використати "await" всередині асинхронного next:
        await new Promise(resolve => setTimeout(resolve, 1000)); // (3)

        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

(async () => {

  for await (let value of range) { // (4)
    alert(value); // 1,2,3,4,5
  }

})()

Як бачимо, структура схожа на звичайні ітератори:

  1. Щоб об’єкт перебирався асинхронно, він повинен мати метод Symbol.asyncIterator (1).
  2. Цей метод повинен повертати об’єкт із методом next(), який повертає проміс (2).
  3. Метод next() не повинен бути async, це може бути звичайний метод, що повертає проміс, але async дозволяє нам використовувати await, тому це зручно. Тут ми просто затримуємося на секунду (3).
  4. Для ітерації ми використовуємо for await(let value of range) (4), а саме додаємо “await” після “for”. Він викликає range[Symbol.asyncIterator]() один раз, а потім його next() для значень.

Ось невелика таблиця з відмінностями:

Ітератори Асинхронні ітератори
Метод для забезпечення ітератора Symbol.iterator Symbol.asyncIterator
next() повертає будь-яке значення Promise
для циклу використовуйте for..of for await..of
Синтаксис оператора розширення ... не працює асинхронно

Функції, що вимагають звичайні синхронні ітератори, не працюють з асинхронними.

Наприклад, не буде працювати синтаксис розширення:

alert( [...range] ); // Помилка, немає Symbol.iterator

Це природно, оскільки він очікує знайти Symbol.iterator, а не Symbol.asyncIterator.

Це також стосується for..of: синтаксис без await потребує Symbol.iterator.

Пригадування генераторів

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

Для простоти, опускаючи деякі важливі речі, це “функції, які генерують (yield) значення”. Вони детально описані в розділі Генератори.

Генератори позначаються міткою function* (зверніть увагу на зірочку) і використовують yield, щоб генерувати значення, тоді ми можемо використовувати for..of, щоб перейти до них.

Цей приклад генерує послідовність значень від start до end:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

for(let value of generateSequence(1, 5)) {
  alert(value); // 1, потім 2, потім 3, потім 4, потім 5
}

Як ми вже знаємо, щоб об’єкт міг перебиратися, ми повинні додати до нього Symbol.iterator.

let range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    return <об’єкт з next, щоб зробити діапазон ітерабельним>
  }
}

Звичайною практикою для Symbol.iterator є повернення генератора. Як ви можете бачити, це робить код коротшим:

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // скорочення для [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

for(let value of range) {
  alert(value); // 1, потім 2, потім 3, потім 4, потім 5
}

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

У звичайних генераторах ми не можемо використовувати await. Усі значення мають надходити синхронно, як того вимагає конструкція for..of.

Що, якщо ми хочемо генерувати значення асинхронно? З мережевих запитів, наприклад.

Перейдімо до асинхронних генераторів, щоб зробити це можливим.

Асинхронні генератори (нарешті)

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

Синтаксис простий: додайте async перед function*. Це зробить генератор асинхронним.

А потім скористайтеся for await (...), щоб перебрати його, наприклад:

async function* generateSequence(start, end) {

  for (let i = start; i <= end; i++) {

    // Ого, можемо використовувати await!
    await new Promise(resolve => setTimeout(resolve, 1000));

    yield i;
  }

}

(async () => {

  let generator = generateSequence(1, 5);
  for await (let value of generator) {
    alert(value); // 1, потім 2, потім 3, потім 4, потім 5 (із затримкою між ними)
  }

})();

Оскільки генератор є асинхронним, ми можемо використовувати всередині нього await, проміси, виконувати мережеві запити тощо.

Різниця під капотом

Якщо ви досвідчений читач і пам’ятаєте деталі про генератори, то знаєте, що технічно в них є внутрішня різниця.

Для асинхронних генераторів метод generator.next() є асинхронним, він повертає проміси.

У звичайному генераторі ми використовуємо result = generator.next(), щоб отримати значення. В асинхронному генераторі ми повинні додати await, наприклад:

result = await generator.next(); // result = {value: ..., done: true/false}

Ось чому асинхронні генератори працюють з for await...of.

Асинхронно ітераційний range

Звичайні генератори можна використовувати як Symbol.iterator, щоб зробити код ітерації коротшим.

Подібно до цього, асинхронні генератори можна використовувати як Symbol.asyncIterator для реалізації асинхронної ітерації.

Наприклад, ми можемо змусити об’єкт range генерувати значення асинхронно, раз за секунду, замінивши синхронний Symbol.iterator на асинхронний Symbol.asyncIterator:

let range = {
  from: 1,
  to: 5,

  // цей рядок такий самий, як [Symbol.asyncIterator]: async function*() {
  async *[Symbol.asyncIterator]() {
    for(let value = this.from; value <= this.to; value++) {

      // робимо паузу між значеннями, чекаємо на щось
      await new Promise(resolve => setTimeout(resolve, 1000));

      yield value;
    }
  }
};

(async () => {

  for await (let value of range) {
    alert(value); // 1, потім 2, потім 3, потім 4, потім 5
  }

})();

Тепер значення надходять із затримкою в 1 секунду між ними.

Будь ласка, зверніть увагу:

Технічно ми можемо додати до об’єкта як Symbol.iterator, так і Symbol.asyncIterator, тому він може перебиратися як синхронно (for..of), так і асинхронно (for await..of).

Але на практиці це було б дивно.

Приклад із реальної практики: посторінкові дані

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

Існує багато онлайн-сервісів, які надають дані посторінково. Наприклад, коли нам потрібен список користувачів, запит повертає попередньо визначену кількість (наприклад, 100 користувачів) — “одну сторінку” та надає URL-адресу наступної сторінки.

Цей підхід дуже поширений. Мова йде не про користувачів, а про що завгодно.

Наприклад, GitHub дозволяє нам отримувати коміти таким же чином, розбитими на сторінки:

  • Ми повинні зробити запит за допомогою fetch у вигляді https://api.github.com/repos/<repo>/commits.
  • У відповіді прийде JSON з 30 комітами, а також з посиланням на наступну сторінку в заголовку Link.
  • Тоді ми зможемо використати це посилання для наступного запиту, щоб отримати більше комітів і так далі.

Для нашого коду ми хотіли б мати простіший спосіб отримати коміти.

Створімо функцію fetchCommits(repo), яка отримує коміти для нас, надсилаючи запити, коли це необхідно. І нехай вона піклується про всі речі щодо розбиття на сторінки. Для нас це буде проста асинхронна ітерація for await..of.

Отже, використання буде таким:

for await (let commit of fetchCommits("username/repository")) {
  // опрацювання комітів
}

Ось така функція, реалізована як асинхронний генератор:

async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url, { // (1)
      headers: {'User-Agent': 'Our script'}, // GitHub потребує будь-який заголовок user-agent
    });

    const body = await response.json(); // (2) відповідь у форматі JSON (масив комітів)

    // (3) URL-адреса наступної сторінки знаходиться в заголовках, витягуємо її
    let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for(let commit of body) { // (4) повертає коміти один за одним, поки сторінка не закінчиться
      yield commit;
    }
  }
}

Більше пояснень про те, як це працює:

  1. Ми використовуємо метод браузера fetch для завантаження комітів.

    • Початкова URL-адреса — https://api.github.com/repos/<repo>/commits, а наступна сторінка буде в заголовку Link відповіді.
    • Метод fetch дозволяє нам надати авторизацію та інші заголовки, якщо це необхідно – тут для GitHub потрібен User-Agent.
  2. Коміти повертаються у форматі JSON.

  3. Ми повинні отримати URL-адресу наступної сторінки із заголовка Link відповіді. Він має спеціальний формат, тому ми використовуємо для цього регулярний вираз (про цю функцію ми дізнаємося в Регулярні вирази).

    • URL-адреса наступної сторінки може виглядати так: https://api.github.com/repositories/93253246/commits?page=2. Вона створюється самим GitHub.
  4. Потім ми видаємо отримані коміти по черзі, і коли вони закінчаться, запуститься наступна ітерація while(url), створюючи ще один запит.

Приклад використання (показує авторів комітів в консолі):

(async () => {

  let count = 0;

  for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) {

    console.log(commit.author.login);

    if (++count == 100) { // зупинимося на 100 комітах
      break;
    }
  }

})();

// Примітка: Якщо ви запускаєте це у зовнішній пісочниці, вам потрібно буде вставити сюди функцію fetchCommits, описану вище

Це саме те, чого ми хотіли.

Внутрішня механіка посторінкових запитів невидима ззовні. Для нас це просто асинхронний генератор, який повертає коміти.

Підсумки

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

Коли ми очікуємо, що дані будуть надходити асинхронно із затримками, можна використовувати їх асинхронні аналоги і for await..of замість for..of.

Синтаксичні відмінності між асинхронними та звичайними ітераторами:

Ітератори Асинхронні ітератори
Метод для забезпечення ітератора Symbol.iterator Symbol.asyncIterator
next() повертає {value:…, done: true/false} Promise, який завершується з {value:…, done: true/false}

Синтаксичні відмінності між асинхронними та звичайними генераторами:

Генератори Асинхронні генератори
Оголошення function* async function*
next() повертає {value:…, done: true/false} Promise, який завершується з {value:…, done: true/false}

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

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

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