Пояснення генераторів JavaScript: функції з можливістю паузи та лінійна ітерація
Дізнайтеся, як функції-генератори JavaScript призупиняють та відновлюють виконання, реалізують протокол ітератора та забезпечують відкладену, ефективну з точки зору пам’яті передачу даних.
Кожна функція, яку ви написали досі, дотримується одного правила: ви її викликаєте, вона виконується від початку до кінця, повертає результат лише один раз, і на цьому все закінчується. Генератори тихо порушують це правило, і це порушення в практиці має велике значення: функція-генератор може тимчасово призупинити свою роботу, передати значення тому, хто її викликав, а потім продовжити виконання саме з того місця, ніби час узагалі не минув.
Основний механізм
Генератор оголошується за допомогою function*, і замість return він використовує yield, щоб поступово передавати значення одне за одним:
function* countUp() {
console.log("starting");
yield 1;
console.log("resumed after first yield");
yield 2;
console.log("resumed after second yield");
yield 3;
console.log("done");
}
const counter = countUp();
console.log(counter.next()); // "starting" logs, then { value: 1, done: false }
console.log(counter.next()); // "resumed after first yield" logs, then { value: 2, done: false }
console.log(counter.next()); // "resumed after second yield" logs, then { value: 3, done: false }
console.log(counter.next()); // "done" logs, then { value: undefined, done: true }
Виклик функції countUp() не запускає жодної частини її тіла. Вона повертає об’єкт-генератор – неактивну структуру, яка ще не почала виконуватися. Кожен наступний виклик .next() продовжує виконання саме з того місця, де воно було зупинено раніше, працює далі до наступного оператора yield та знову зупиняється, повертаючи значення, яке було видано. Усі внутрішні стани функції, її змінні, її позиція всередині циклу – все залишається незмінним під час цих перерв, наче функція взагалі не була перервана.
Чому це справді відрізняється від простого повернення масиву
Природний заперечення полягає у наступному: чому б просто не створити масив та не повернути його назад? Саме тут і проявляється користь генераторів, адже вони генерують результати поступово, елемент за елементом, лише за потреби, замість того щоб заздалегідь обчислювати весь набір результатів.
function* readLargeFileLineByLine(filePath) {
const fileHandle = fs.openSync(filePath, "r");
let position = 0;
let leftover = "";
while (true) {
const buffer = Buffer.alloc(1024);
const bytesRead = fs.readSync(fileHandle, buffer, 0, 1024, position);
if (bytesRead === 0) break; position += bytesRead;
const lines = (leftover + buffer.toString("utf8", 0, bytesRead)).split("\n");
leftover = lines.pop();
for (const line of lines) yield line;
}
fs.closeSync(fileHandle);
}for (const line of readLargeFileLineByLine("access.log")) {
if (line.includes("500")) console.log(line);
// only reads and holds one small chunk of the file in memory at a time
}
Якби код був написаний так, щоб заздалегідь створити масив усіх рядків, то файл журналу об’ємом у кілька гігабайт довелося б завантажити повністю в пам’ять, перш ніж можна було б ознайомитися хоча б з одним рядком, що є тією самою проблемою, яку намагаються уникнути стрімові підходи. Генератор уникає цього за допомогою іншого механізму: кожен рядок обчислюється лише тоді, коли щось запитує його через .next(), і якщо цикл for...of завершується раніше, наприклад, як тільки знаходить потрібний елемент, генератор більше не обчислює нічого після цього моменту.
Генератори реалізують протокол ітератора, тому for...of просто працює
Синтаксиси на кшталт for...of, розбирання масиву та оператор розповсюдження працюють з будь-чим, що відповідає протоколу ітератора, тобто з будь-яким об’єктом, який має метод next(), що повертає { value, done }. Об’єкт генератора автоматично відповідає цим вимогам без додаткових зусиль з вашого боку, що пояснює, чому його можна ітерувати без додаткових налаштувань:
function* pageThroughResults(fetchPage) {
let page = 1;
while (true) {
const results = fetchPageSync(fetchPage, page);
if (results.length === 0) return;
yield* results; // delegates to another iterable, yielding each of its values
page++;
}
}
Синтаксис yield* делегує обробку іншому ітерабельному об’єкту, передаючи його значення по черзі. Саме так генератор може перетворити низку результатів з пагінацією на один безперервний потік окремих елементів, при цьому код, який його обробляє, ніколи не повинен знати про наявність пагінації.
Двостороння комунікація: .next() може надсилати значення, а не лише отримувати їх
Існує менш відомий аспект генераторів: yield — це не просто спосіб виведення значень, а й вираз, і все, що ви передаєте під час наступного виклику .next(), стає результатом цього виразу. Це означає, що дані можуть повертатися назад у призупинену функцію, а не лише виходити з неї.
function* priceNegotiation() {
const offer1 = yield "What's your offer?";
const offer2 = yield `I can't do ${offer1}, how about a counter?`;
return `Final: ${offer2}`;
}
const negotiation = priceNegotiation();
console.log(negotiation.next().value); // "What's your offer?"
console.log(negotiation.next(50).value); // "I can't do 50, how about a counter?"
console.log(negotiation.next(80).value); // "Final: 80"
Щоразу, коли ви викликаєте .next(value), генератор прокидається, і зупинена рядок yield обчислює значення, яке ви надали у параметрі value. Наявність функції, яка може призупинити свою роботу, чекати на нові дані від користувача та потім продовжити роботу з цими даними, є незвичайною можливістю у звичайному проектуванні функцій. Саме на цьому механізмі ґрунтувалися ранні бібліотеки асинхронної обробки в JavaScript для імітації механізмів async/await, поки мова не почала підтримувати їх нативно: сценарій вирішував обіцянку, а потім передавав її результат у генератор через .next(), повторюючи цей процес після кожного yield, доки не буде завершено кожен асинхронний крок.
Чому це є справжньою основою, яку варто знати
Генератори зовсім не є синтаксичною дивністю, яку можна безпечно ігнорувати. Це механізм, на який зрештою було побудовано async/await, адже „зупинити виконання тут та продовжити його пізніше з певною значенням“ — саме така поведінка потрібна для очікування обіцянки. Вони також пояснюють, чому for...of, синтаксис розповсюдження та деструктуризація поводяться однаково для масивів, рядків, екземплярів Map та користувацьких об’єктів, оскільки протокол ітератора, який реалізують генератори, ідентичний тому, від якого вже залежать ці вбудовані типи. Як тільки ви осягнете справжню ментальну модель — функцію, здатну призупинятися та продовжувати роботу, обмінюючись значеннями на кожній точці призупинки, ледачу оцінку, користувацьку логіку ітерації та внутрішню роботу async/await — ці елементи більше не здадуться трьома незв’язаними функціями. Виявляється, що це єдиний механізм.
Пов’язані матеріали
- Node.js 26: Temporal API, Map Upserts та Undici 8 — пояснення — Пояснює основні зміни в Node.js 26, спрямовані на роботу з бекендом, включаючи стабільний Temporal API, вбудовані методи Map upsert, покращення продуктивності Undici 8 та зміни, які потрібно перевірити перед оновленням.
- Пояснення процесу рендерингу в React: оновлення стану до пікселів екрана — Дізнайтеся, як фази рендерингу, узгодження та збереження даних у React пов’язані з процесами лейауту, малювання та композиції в браузері для створення пікселів.