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