Протокол итератора
Многие возможности JavaScript — for...of, spread (...), деструктуризация, Array.from, Promise.all — работают на одном и том же механизме: протоколе итератора. Когда въезжаешь в эту идею, всё перечисленное начинает выглядеть как вариации одного и того же.
Итератор в javascript — это любой объект с методом next(), который возвращает { value, done }:
Вызывай next() сколько нужно. Каждый вызов возвращает очередное значение и флаг done. Когда done становится true — последовательность закончилась. Вот и весь протокол: четыре символа и булев флаг.
Iterable против Iterator
Рядом живёт ещё одно понятие. Iterable (итерируемый объект) — это всё, что умеет отдать итератор. Делает он это через метод с особым ключом: Symbol.iterator.
Массивы — это iterable-объекты. Вызов numbers[Symbol.iterator]() возвращает свежий итератор. Строки, Map, Set и arguments — тоже iterable, и именно поэтому for...of работает со всеми ними.
Важно чувствовать разницу: iterable — это сама коллекция, а iterator — это курсор по ней. У одного iterable можно запросить сколько угодно независимых курсоров.
Почему работает цикл for...of
Цикл for...of в javascript — это просто синтаксический сахар над протоколом итератора. Под капотом он вызывает Symbol.iterator, а затем дёргает next() до тех пор, пока done не станет true:
Spread и деструктуризация работают по одному и тому же принципу — они проходят по итератору до конца:
Любой объект, в котором реализован Symbol.iterator, автоматически получает доступ ко всем этим возможностям.
Свой итератор в javascript: пишем iterable-объект
Давайте сделаем объект range, который выдаёт числа от start до end:
На что здесь стоит обратить внимание:
[Symbol.iterator]()— это вычисляемое имя метода. Ключом выступает сам символ, а не строка"Symbol.iterator".- Каждый вызов
[Symbol.iterator]()возвращает новый итератор со своим собственнымcurrent. Именно поэтому поrangeможно пройтись дважды — он не «израсходуется» после первого обхода. - Итератору достаточно одного метода
next(). И всё.
Работает, но многословно. Есть способ куда приятнее.
Знакомьтесь — генераторы javascript
Функция-генератор объявляется через function* (обратите внимание на звёздочку). Вместо того чтобы выполниться целиком, она умеет приостанавливаться на выражении yield и продолжать работу позже. Её вызов не запускает тело функции — вместо этого возвращается объект-генератор, который одновременно является и итератором, и итерируемым объектом:
Каждый вызов next() выполняет тело функции до ближайшего yield, замораживает его и возвращает { value, done: false }. Когда функция отработала до конца, получаем { value: undefined, done: true }.
А раз генераторы — это iterable, они отлично дружат со всем, о чём шла речь в предыдущем разделе:
Переписываем range с помощью генератора
Сравните многословный вариант выше с этим:
Вот и всё. Звёздочка * перед [Symbol.iterator] превращает метод в генератор. А yield i заменяет весь самописный объект-итератор. Никаких next, никаких done, никаких рисков с off-by-one — просто обычный цикл, где вместо push стоит yield.
Ради этого генераторы и придуманы. Вместо «напиши итератор» получается «напиши функцию, которая yield-ит».
yield против return
yield приостанавливает выполнение, а return завершает его. Вызывать yield можно сколько угодно раз — генератор каждый раз продолжает работу с того места, где остановился:
return внутри генератора возвращается как { value: "done", done: true } в том вызове, который его завершает. Важный момент: for...of и спред игнорируют это возвращаемое значение — они забирают только элементы, у которых done равен false. Так что не пытайтесь протащить финальный элемент в цикл через return value — его просто пропустят.
Ленивые вычисления и бесконечные последовательности в JavaScript
Генераторы выдают значения по запросу, по одному за раз. Благодаря этому можно описывать такие последовательности, которые в виде массива существовать в принципе не могут:
Цикл буквально while (true), но программа всё равно завершается — генератор продвигается лишь тогда, когда у него запрашивают очередное значение. Можно взять первые N элементов и остановиться, а всё остальное просто не выполнится:
take сам по себе — тоже генератор, только обёрнутый вокруг другого. Именно в такой композиции и раскрывается сила генераторов javascript: маленькие кирпичики, каждый из которых делает что-то одно.
Делегирование через yield*
Если генератору нужно отдать наружу всё, что выдаёт другой итерируемый объект, на помощь приходит yield* — он делегирует выдачу:
yield* умеет работать с любым iterable — массивами, множествами, другими генераторами — и пробрасывает каждый элемент по одному. По сути, это тот же spread, только для итераторов.
Асинхронные генераторы, если коротко
Генератор, объявленный как async function*, может делать yield значений, которые приходят не сразу — удобно, когда нужно стримить данные из API или читать файл по кускам. Перебирать такой генератор нужно через for await...of:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
Этот пример здесь не запустить — ему нужен настоящий эндпоинт, — но полезно знать, что такая форма существует. Когда разберётесь с обычными генераторами, асинхронные — это ровно та же идея, только с вкраплениями await.
Когда стоит брать генератор
Генератор уместен, когда:
- Последовательность бесконечна или потенциально бесконечна — идентификаторы, временные метки, задержки между повторными попытками.
- Вычислить все значения дорого, а потребитель вполне может остановиться на полпути.
- Вы реализуете
Symbol.iteratorна своём объекте. Так почти всегда получается короче, чем вручную собирать объект с методомnext(). - Нужно собрать цепочку потоковых преобразований (
take,filter,map) без промежуточных массивов.
Если же данные уже лежат в памяти и их немного — берите обычный массив. Генераторы не бесплатны: механизм приостановки и возобновления функции стоит своих тактов, а стек-трейсы сквозь код генератора читаются тяжелее.
Дальше: символы
Symbol.iterator — первый символ, с которым обычно сталкиваются, но далеко не единственный. Символы — это примитивный тип, придуманный как раз для подобных точек расширения: уникальные ключи, через которые язык и ваш собственный код цепляются к объектам, не конфликтуя с обычными именами свойств. Об этом — на следующей странице.
Часто задаваемые вопросы
Чем итерируемый объект отличается от итератора в JavaScript?
Итерируемый объект (iterable) — это любой объект, у которого есть метод Symbol.iterator, возвращающий итератор. А итератор — это уже та штука, которая реально выдаёт значения: у неё есть метод next(), возвращающий { value, done }. Массивы, строки, Map и Set — это iterable; если вызвать у них Symbol.iterator, получите итератор, по которому можно шагать.
Что такое функция-генератор в JavaScript?
Это функция, объявленная через function*, которая лениво выдаёт значения с помощью yield. Важный момент: сам вызов такой функции не запускает её тело — он возвращает объект-генератор, который одновременно является и итератором, и итерируемым. Каждый вызов next() прогоняет код до следующего yield, ставит его на паузу и возвращает полученное значение.
В чём разница между yield и return внутри генератора?
yield ставит генератор на паузу и отдаёт значение наружу, но функция может продолжить работу с того же места при следующем next(). А return завершает генератор окончательно — выставляет done: true, и больше значений из него не вытянешь. yield можно делать сколько угодно раз, а осмысленный return — только один.
Когда выбирать генератор вместо обычного массива?
Когда последовательность бесконечна, её дорого вычислять целиком или вам нужна лишь часть значений. Генератор выдаёт элементы по одному по запросу — так можно описать бесконечный поток ID или постраничные ответы API, не материализуя всё сразу в памяти. Если же у вас и так небольшой фиксированный массив — просто используйте массив, не усложняйте.