Почему for..of работает с массивом, строкой, Map и Set, но не с обычным объектом? Почему spread раскладывает строку по буквам? За всем этим стоит один контракт — протокол итерации. Он маленький, и зная его, вы понимаете сразу пласт языка: от деструктуризации до генераторов.
Протокол: next() и {value, done}
Итератор — любой объект с методом next(), возвращающим { value, done }. Итерируемое — любой объект, умеющий выдать итератор через метод с ключом Symbol.iterator. Всё.
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next: () => current <= last
? { value: current++, done: false }
: { value: undefined, done: true },
};
},
};
for (const n of range) console.log(n); // 1 2 3
[...range]; // [1, 2, 3]
const [first] = range; // 1
Math.max(...range); // 3
Одна реализация Symbol.iterator — и объект автоматически дружит со всем семейством потребителей: for..of, spread, деструктуризация, Array.from, new Set(...), Promise.all. Именно поэтому они одинаково работают с массивами, строками, Map, Set, NodeList — все они итерируемые. А обычный {} — нет: у него нет Symbol.iterator, для перебора объектов есть Object.keys/values/entries (которые возвращают массив — уже итерируемый).
Генераторы: итератор без бухгалтерии
Писать next() руками с ручным хранением состояния громоздко. Генератор делает то же самое синтаксисом обычной функции:
function* rangeGen(from, to) {
for (let i = from; i <= to; i++) yield i;
}
[...rangeGen(1, 3)]; // [1, 2, 3]
Звёздочка объявляет генератор, yield «выдаёт» значение и замораживает функцию на этом месте. Каждый вызов next() размораживает её до следующего yield. Это единственная конструкция языка, где функция может остановиться посередине и продолжить позже — со всеми живыми локальными переменными.
Отсюда суперсила — ленивость. Генератор не строит всю коллекцию заранее, а выдаёт по значению по требованию:
function* ids() {
let i = 1;
while (true) yield i++; // бесконечный — и это нормально!
}
const gen = ids();
gen.next().value; // 1
gen.next().value; // 2 — никакого бесконечного цикла: считаем по запросу
Практичные применения: генерация идентификаторов, пагинация («выдай следующую страницу»), обход деревьев (yield внутри рекурсии через yield*), конвейеры обработки, где невыгодно материализовывать промежуточные массивы.
function* walk(node) {
yield node;
for (const child of node.children ?? []) yield* walk(child); // делегирование
}
yield* — «передай слово другому генератору»: рекурсивный обход дерева без ручного стека читается как обычная рекурсия.
for await..of: перебор асинхронного
Скрестим генераторы с промисами: асинхронный итератор выдаёт промисы, а for await..of их дожидается. Каноничный пример — выкачать все страницы API:
async function* fetchPages(url) {
while (url) {
const res = await fetch(url);
const page = await res.json();
yield page.items;
url = page.next; // ссылка на следующую страницу или null
}
}
for await (const items of fetchPages("/api/orders?page=1")) {
render(items); // страницы обрабатываются по мере прибытия
}
Потребитель не знает ни про пагинацию, ни про количество страниц — просто перебирает. Тот же контракт «next → value/done», только асинхронный: так же устроены чтение файлов чанками в Node и потоковые ответы.
Коротко
- Протокол итерации: итератор =
next()→{value, done}; итерируемое = объект сSymbol.iterator. Один контракт питает for..of, spread, деструктуризацию, Array.from. - Обычный объект не итерируем — перебирается через
Object.keys/values/entries. - Генератор (
function*+yield) — функция с паузами: пишется как обычный код, работает как итератор.yield*делегирует другому генератору. - Ленивость: значения по требованию, бесконечные последовательности, обход деревьев без материализации.
for await..of+ async-генераторы — перебор асинхронных источников: пагинация, стримы, чанки.
Что почитать дальше
- Промисы изнутри — асинхронная половина for await..of.
- Замыкания — состояние генератора между вызовами живёт по тем же законам.
- Основы JavaScript для TypeScript — мостик из цикла обратно к TypeScript-фазе программы.