Новинки JavaScript: Асинхронные итераторы

в 10:40, , рубрики: ECMAScript, javascript, node.js, генераторы, итераторы, предложения

В этом небольшом посте я хочу рассказать об одном интересном предложении (англ. proposal) в стандарт EcmaScript. Речь пойдёт об асинхронных итераторах, о том, что это такое, как ими пользоваться и зачем они вообще нужны простому разработчику.

Асинхронные итераторы, это расширение возможностей обычных итераторов, которые с помощью цикла for-of/for-await-of позволяют пробежать по всем элементам коллекции.

Для начала стоит объяснить, что я подразумеваю под генераторами, а что под итераторами, т.к. я часто буду использовать эти термины. Генератор — функция, которая возвращает итератор, а итератор — объект, содержащий метод next(), который в свою очередь возвращает следующее значение.

Пример

function* generator () { // функция генератор
  yield 1
}
const iterator = generator() // при вызове возвращается итератор
console.log(iterator.next()) /// значение { value: 1, done: false }

Хотелось бы несколько подробнее остановиться на итераторах и объяснить их смысл в настоящее время. Современный JavaScript (стандарт ES6/ES7) позволяет перебрать значения коллекции (например Array, Set, Map и т.д.) поочерёдно, без лишней возни с индексами. Для этого был принят протокол итераторов, определяемый в прототипе коллекции с помощью символа (Symbol) Symbol.iterator:

// как пример, генератор диапазонов чисел

// конструктор типа Range
function Range (start, stop) {
  this.start = start
  this.stop = stop
}

// объявляем метод, который будет возвращать генератор
// мы не будем вызывать его явно, он будет вызван автоматически в цикле for-of
Range.prototype[Symbol.iterator] = function *values () {
  for (let i = this.start; i < this.stop; i++) {
    yield i
  }
}

// создаём новый диапазон
const range = new Range(1, 5)

// а вот здесь уже из диапазона вызывается [Symbol.iterator]()
// и итерируется по созданному генератору
for (let number of range) {
  console.log(number) // 1, 2, 3, 4
}

Каждый итератор (в нашем случае это range[Symbol.iterator]()) имеет метод next(), который возвращает объект, содержащий 2 поля: value и done, содержащие текущее значение и флаг, обозначающий конец генератора, соответственно. Этот объект можно описать таким интерфейсом:

interface IteratorResult<T> {
  value: T;
  done: Boolean;
}

Более подробно о генераторах можно почитать на MDN.

Небольшое пояснение

К слову, если у нас уже есть итератор и мы хотим пройтись по нему с помощью for-of, то нам не нужно приводить его обратно к нашему (или любому другому итерируемому) типу, т.к. каждый итератор имеет такой же метод [Symbol.iterator], который возвращает this:

const iter = range[Symbol.iterator]()
assert.strictEqual(iter, iter[Symbol.iterator]())

Надеюсь, здесь всё понятно. Теперь ещё немного нужно сказать про асинхронные функции.

В ES7 был предложен async/await синтаксис. По сути, это сахар позволяющий в псевдосинхронном стиле работать с промисами (Promise):

async function request (url) {
  const response = await fetch(url)
  return await response.json()
}

// против

function request (url) {
  return fetch(url)
    .then(response => response.json())
}

Отличие от обычной функции в том, что async-функция всегда возвращает Promise, даже, если мы делаем обычный return 1, то получим Promise, который при разрешении вернёт 1.

Отлично, теперь наконец-то переходим к асинхронным итераторам.

Вслед за асинхронными фнкциями (async function () { ... }) были предложены асинхронные итераторы, которые можно использовать внутри этих самых функций:

async function* createQueue () {
  yield 1
  yield 2
  // ...
}

async function handle (queue) {
  for await (let value of queue) {
    console.log(value) // 1, 2, ...
  }
}

В данный момент асинхронные итераторы находятся в предложениях, в 3-й стадии (кандидат), что означает, что синтаксис стабилизирован и ожидает включения в стандарт. Это предложение пока не реализовано ни в одном JavaScript-движке, но попробовать и поиграть с ним всё же можно — с помощью Babel плагина babel-plugin-transform-async-generator-functions:

package.json

{
  "dependencies": {
    "babel-preset-es2015-node": "···",
    "babel-preset-es2016": "···",
    "babel-preset-es2017": "···",
    "babel-plugin-transform-async-generator-functions": "···"
    // ···
  },
  "babel": {
    "presets": [
      "es2015-node",
      "es2016",
      "es2017"
    ],
    "plugins": [
      "transform-async-generator-functions"
    ]
  },
  // ···
}

взято из блога 2ality, полный код с примерами использования можно посмотреть в rauschma/async-iter-demo

Итак, чем же асинхронные итераторы отличаются от обычных? Как говорилось выше, итератор возвращает значение IteratorResult. Асинхронный же итератор всегда возвращает Promise<IteratorResult>. Это значит, что для того, чтобы получить значение и понять нужно продолжать выполнение цикла или нет, нужно дождаться разрешения (resolve) промиса, который вернёт IteratorResult. Именно поэтому был введён новый синтаксис for-await-of, который и делает всю эту работу.

Возникает закономерный вопрос: зачем было вводить новый синтаксис, почему нельзя вернуть IteratorResult<Promise>, а не Promise<IteratorResult> и подождать (await ...) его руками (прошу прощения за это странное выражение)? Это сделано для тех случаев, когда мы изнутри синхронного генератора не можем определить есть ли следующее значение или нет. Например нужно сходить в некую удалённую очередь по сети и забрать следующее значение, если очередь опустела, то выйти из цикла.

Хорошо, с этим разобрались, остался последний вопрос — использование асинхронных генераторов и итераторов. Здесь всё достаточно просто: добавляем к генератору ключевое слово async и у нас получается асинхронный генератор:

// некая очередь задач
async function* queue () {
  // бесконечно выбираем новые задачи из очереди
  while (true) {
    // дожидаемся результат
    const task = await redis.lpop('tasks-queue')
    if (task === null) {
      // если задачи кончились, то прекращаем выполнение и выходим
      // как раз тот случай, когда нужен именно Promise<IteratorResult>
      return
    } else {
      // возвращаем задачу
      yield task
    }
  }
}

// обработчик задач из очереди
async function handle () {
  // получаем итератор по задачам
  const tasks = queue()
  // дожидаемся каждую задачу из очереди
  for await (const task of tasks) {
    // обрабатываем её
    console.log(task)
  }
}

Если мы хотим чтобы наша собственная структура могла быть асинхронно проитерирована с помощью for-await-of, то нужно реализовать метод [Symbol.asyncIterator]:

function MyQueue (name) {
  this.name = name
}
MyQueue.prototype[Symbol.asyncIterator] = async function* values () {
  // тот же код, что и в примере выше
  while (true) {
    const task = await redis.lpop(this.name)
    if (task === null) {
      return
    } else {
      yield task
    }
  }
}

async function handle () {
  const tasks = new MyQueue('tasks-queue')
  for await (const task of tasks) {
    console.log(task)
  }
}

На этом всё. Надеюсь эта статья была интересна и хоть в какой-то мере полезна. Спасибо за внимание.

Ссылки

Автор: asdf404

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js