В этом небольшом посте я хочу рассказать об одном интересном предложении (англ. 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:
{
"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)
}
}На этом всё. Надеюсь эта статья была интересна и хоть в какой-то мере полезна. Спасибо за внимание.
Ссылки
Комментарии (18)

popov654
15.03.2017 17:21Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.

Mingun
15.03.2017 18:48+1Ну вот разгружаете вы вагоны с
дерьмомзерном. Подставляете спину, туда прилетает мешок, вы его быстренько-быстренько в закромаотсыпаете там, конечно, и за следующим, пока мешки летают. Но вот рабочий день закончился, вагон опустел, но хотя последний рабочий, уходя, похлопал вас по спине,аки ломовую лошадь, вы разогнуться уже не можете, так и стоите в интересной позе тылом к рельсам. А ведь могли бы домой пойти, если бы на последнем мешке была этикетка с надписью "все, баста!"...
popov654
15.03.2017 20:27Всё равно не понял)) Если без метафор, то в чём загвоздка?
Это сделано для тех случаев, когда мы изнутри синхронного генератора не можем определить есть ли следующее значение или нет. Например нужно сходить в некую удалённую очередь по сети и забрать следующее значение, если очередь опустела, то выйти из цикла.
Судя по коду, мы внутри генератора делаем await (дальнейший код исполнится когда "фоновый" Promise заресолвится, если я правильно понимаю), и потом проверяем на переменную на null как условие, что данных больше нет. Так может это условие и использовать как знак того, что "баста"?
mayorovp
15.03.2017 21:37А снаружи-то как вовремя узнать что больше элементов не будет?
Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?

asdf404
16.03.2017 03:32-1А снаружи-то как вовремя узнать что больше элементов не будет?
Снаружи "вовремя" будет тогда, когда зарезолвится промис, т.к. пока это не произойдёт ваш цикл не сможет пойти дальше. А когда промис резолвится, то он возвращает состояние итератора, где указано завершился итератор или нет.
Чему будет равно свойство done итератора во время выполнения оператора await в генераторе?
Оно будет равно
false. Станет равнымtrueлишь при выходе из функции-генератора явно (поreturn) или неявно (кончилось тело функции).mayorovp
16.03.2017 07:04Вот смотрите, проверили мы done. Оно равно false. Мы получили очередной промиз и начали его ждать.
А следующего элемента-то и нет! Как теперь закончить ожидание?
PS блин, да вы ниже сами все расписали с кодом! Зачем тут чушь пишите?

asdf404
16.03.2017 07:57Когда вызывается
returnили мы выходим из генератора, то всё равно возвращаетсяPromise, содержащийIteratorResult. Вот пример с кодом:
async function* values () { yield 1 yield 2 } function handle () { const iter = values() console.log(iter.next()) // Promise { value: 1, done: false } console.log(iter.next()) // Promise { value: 2, done: false } // следующего элемента нет console.log(iter.next()) // Promise { value: undefined, done: true } } handle()
Т.е. последний промис разрешается сразу и возвращает
done = true. Вы можете запустить этот код и проверить самостоятельно.
Зачем тут чушь пишите?
В каком месте я написал чушь?
mayorovp
16.03.2017 08:30Это вы сейчас написали как
Promise<IteratorResult>работает.
А я отвечал на вот этот вопрос:
Вы не могли бы ещё раз рассказать, почему альтернативный подход с IteratorResult не годится? Немного непонятно получилось в статье.
Альтернативный — это тот, при котором метод next() возвращает
IteratorResult<Promise>.
Large
25.03.2017 12:43Загвоздки нет, просто код будет выглядеть менее уродски. Вот аналог с обычным итератором:
for(const taskPromise of queue) { const task = await taskPromise; task ? resolve(task) : break; }
Просто итератор будет бесконечный и проверку на выход прийдется делать руками каждый раз. В случае же асинхронного итератора у вас проверка будет спрятана в сам итератор и он не будет бесконечным.

asdf404
16.03.2017 02:25+3Давайте избавимся от
for-await-ofи посмотрим как это можно обработать вручную. Допустим, у нас есть удалённая очередьqueueс несколькими элементами.
Пример дляIteratorResult<Promise>:
const queue = ... // queue это итератор while (true) { const { value, done } = queue.next() // done всегда будет false, т.к. из генератора синхронно(!) мы не можем узнать закончилась ли очередь const result = await value // здесь мы дожидаемся разрешения value if (result === null) { break } // вот здесь нужно проверить, что нам вернулось пустое значение. Но дело в том, что null может быть вполне валидным значением, а не индикатором пустой очереди // обрабатываем result; следующая итерация }
Как видите, этот подход имеет недостаток — у нас нет четкого понимания, что очередь пуста. Мы не можем трактовать null как конец, если только не приняли некое соглашение, что null — это всегда конец очереди.
В случае с
Promise<IteratorResult>всё несколько иначе:
const queue = ... while (true) { const { value, done } = await queue.next() // здесь у нас есть Promise, который возвращает текущее состояние итератора if (done) { break } // и есть четкое понимание когда стоит прекратить цикл // обрабатываем value; следующая итерация }
Т.е. при подходе
Promise<IteratorResult>у нас есть возможность без всяких соглашений четко дать понять, что очередь пуста, можно выходить из цикла.queue, например, может при каждом вызовеnext()помимо получения элемента спрашивать у очереди сколько элементов осталось и при значении0вернутьdone = true, чтобы прервать цикл и не создать последующих запросов.
popov654
16.03.2017 04:01Спасибо, почти понятно. А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор, основываясь на информации, которую вернёт Promise? Например, опять же, передавать помимо значения некий флаг конца в поле объекта.
Хотя понимаю, что это сильный костыль.

asdf404
16.03.2017 04:23А если сильно извратиться — можно ли чисто теоретически снаружи повлиять на наш генератор
Да, вы можете в
next()передать какое-нибудь значение:
function* generator () { let value = yield 'Hi!' console.log('Hello %s!', value) } const iterator = generator() console.log(iterator.next().value) // выведет "Hi!" iterator.next('World') // выведет "Hello World!"
Таким образом можно передать генератору что угодно.

0x1000000
15.03.2017 21:02А можно ли будет применять функции map reduce filter и пр. над асинхронными итераторами? Получилась бы хорошая замена observable.

asdf404
16.03.2017 02:47Сейчас и для обычных итераторов их нельзя применить. Для этого сначала нужно преобразовать в массив (с помощью
Array.fromили spread оператора[ ...iterable ]), а потом над ним уже совершать операции.
В качестве эксперимента я пишу библиотеку, которая добавляет эти методы прямо к итератору (изменяет его прототип, как делает SugarJS), но она далека от завершения, к тому же есть множество более качественных альтернатив: Wu, Lazy.JS и т.д.
Large
25.03.2017 13:05Можно написать простенький декоратор который будет это делать для синхронного или для асинхронного итератора. Пример map:
function functor(Target) { if(!Reflect.has(Target, Symbol.asycIterator)) throw new Error(`${Target} should be async iterable`); if(!Reflect.defineProperty(Target.prototype, 'map', { value: async function(transform) { const res = []; for await(const el of this) { res.push(transform(el)); } return res; } }) throw new Error(`${Target} already has a map method`); return Target; } @functor // или после объявления класса functor(Queue) если не хочется включать бабель для декоратора class Queue {...}
В данном примере map просто асинхронно выгребет коллекцию, применит к ней заданную трансформацию и вернет промис с результатом, но можно поведение усложнять и генерировать события на каждый приход элемента.
ObsSpace
А что по поддержке Chrome Canary?
asdf404
Судя по всему, поддержку недавно добавили в V8. Не использую Chrome, так что не могу проверить.