Генераторы – новый вид функций в современном JavaScript. Они отличаются от обычных тем, что могут приостанавливать своё выполнение, возвращать промежуточный результат и далее возобновлять его позже, в произвольный момент времени.
Создание генератора
Для объявления генератора используется новая синтаксическая конструкция: function* (функция со звёздочкой).
Её называют «функция-генератор» (generator function).
Выглядит это так:
function* generateSequence() {
yield 1;
yield 2;
return 3;
}
При запуске generateSequence() код такой функции не выполняется. Вместо этого она возвращает специальный объект, который как раз и называют «генератором».
// generator function создаёт generator
let generator = generateSequence();
Правильнее всего будет воспринимать генератор как «замороженный вызов функции»:
При создании генератора код находится в начале своего выполнения.
Основным методом генератора является next(). При вызове он возобновляет выполнение кода до ближайшего ключевого слова yield. По достижении yield выполнение приостанавливается, а значение – возвращается во внешний код:
function* generateSequence() {
yield 1;
yield 2;
return 3;
}
let generator = generateSequence();
let one = generator.next();
alert(JSON.stringify(one)); // {value: 1, done: false}
Повторный вызов generator.next() возобновит выполнение и вернёт результат следующего yield:
let two = generator.next();
alert(JSON.stringify(two)); // {value: 2, done: false}
И, наконец, последний вызов завершит выполнение функции и вернёт результат return:
let three = generator.next();
alert(JSON.stringify(three)); // {value: 3, done: true}
Функция завершена. Внешний код должен увидеть это из свойства done:true и обработать value:3, как окончательный результат.
Новые вызовы generator.next() больше не имеют смысла. Впрочем, если они и будут, то не вызовут ошибки, но будут возвращать один и тот же объект: {done: true}.
«Открутить назад» завершившийся генератор нельзя, но можно создать новый ещё одним вызовом generateSequence() и выполнить его.
function* (…) или function *(…)?Можно ставить звёздочку как сразу после function, так и позже, перед названием. В интернете можно найти обе эти формы записи, они верны:
function* f() {
// звёздочка после function
}
function *f() {
// звёздочка перед названием
}
Технически, нет разницы, но писать то так то эдак – довольно странно, надо остановиться на чём-то одном.
Автор этого текста полагает, что правильнее использовать первый вариант function*, так как звёздочка относится к типу объявляемой сущности (function* – «функция-генератор»), а не к её названию. Конечно, это всего лишь рекомендация-мнение, не обязательное к выполнению, работать будет в любом случае.
Генератор – итератор
Как вы, наверно, уже догадались по наличию метода next(), генератор связан с итераторами. В частности, он является итерируемым объектом.
Его можно перебирать и через for..of:
function* generateSequence() {
yield 1;
yield 2;
return 3;
}
let generator = generateSequence();
for(let value of generator) {
alert(value); // 1, затем 2
}
Заметим, однако, существенную особенность такого перебора!
При запуске примера выше будет выведено значение 1, затем 2. Значение 3 выведено не будет. Это потому что стандартный перебор итератора игнорирует value на последнем значении, при done: true. Так что результат return в цикле for..of не выводится.
Соответственно, если мы хотим, чтобы все значения возвращались при переборе через for..of, то надо возвращать их через yield:
function* generateSequence() {
yield 1;
yield 2;
yield 3;
}
let generator = generateSequence();
for(let value of generator) {
alert(value); // 1, затем 2, затем 3
}
…А зачем вообще return при таком раскладе, если его результат игнорируется? Он тоже нужен, но в других ситуациях. Перебор через for..of – в некотором смысле «исключение». Как мы увидим дальше, в других контекстах return очень даже востребован.
Композиция генераторов
Один генератор может включать в себя другие. Это называется композицией.
Разберём композицию на примере.
Пусть у нас есть функция generateSequence, которая генерирует последовательность чисел:
function* generateSequence(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
// Используем оператор … для преобразования итерируемого объекта в массив
let sequence = [...generateSequence(2,5)];
alert(sequence); // 2, 3, 4, 5
Мы хотим на её основе сделать другую функцию generateAlphaNum(), которая будет генерировать коды для буквенно-цифровых символов латинского алфавита:
48..57– для0..965..90– дляA..Z97..122– дляa..z
Далее этот набор кодов можно превратить в строку и использовать, к примеру, для выбора из него случайного пароля. Только символы пунктуации ещё хорошо бы добавить для надёжности, но в этом примере мы будем без них.
Естественно, раз в нашем распоряжении есть готовый генератор generateSequence, то хорошо бы его использовать.
Конечно, можно внутри generateAlphaNum запустить несколько раз generateSequence, объединить результаты и вернуть. Так мы бы сделали с обычными функциями. Но композиция – это кое-что получше.
Она выглядит так:
function* generateSequence(start, end) {
for (let i = start; i <= end; i++) yield i;
}
function* generateAlphaNum() {
// 0..9
yield* generateSequence(48, 57);
// A..Z
yield* generateSequence(65, 90);
// a..z
yield* generateSequence(97, 122);
}
let str = '';
for(let code of generateAlphaNum()) {
str += String.fromCharCode(code);
}
alert(str); // 0..9A..Za..z
Здесь использована специальная форма yield*. Она применима только к другому генератору и делегирует ему выполнение.
То есть, при yield* интерпретатор переходит внутрь генератора-аргумента, к примеру, generateSequence(48, 57), выполняет его, и все yield, которые он делает, выходят из внешнего генератора.
Получается – как будто мы вставили код внутреннего генератора во внешний напрямую, вот так:
function* generateSequence(start, end) {
for (let i = start; i <= end; i++) yield i;
}
function* generateAlphaNum() {
// yield* generateSequence(48, 57);
for (let i = 48; i <= 57; i++) yield i;
// yield* generateSequence(65, 90);
for (let i = 65; i <= 90; i++) yield i;
// yield* generateSequence(97, 122);
for (let i = 97; i <= 122; i++) yield i;
}
let str = '';
for(let code of generateAlphaNum()) {
str += String.fromCharCode(code);
}
alert(str); // 0..9A..Za..z
Код выше по поведению полностью идентичен варианту с yield*. При этом, конечно, переменные вложенного генератора не попадают во внешний, «делегирование» только выводит результаты yield во внешний поток.
Композиция – это естественное встраивание одного генератора в поток другого. При композиции значения из вложенного генератора выдаются «по мере готовности». Поэтому она будет работать даже если поток данных из вложенного генератора оказался бесконечным или ожидает какого-либо условия для завершения.
yield – дорога в обе стороны
До этого генераторы наиболее напоминали «итераторы на стероидах». Но, как мы сейчас увидим, это не так, есть фундаментальное различие, генераторы гораздо мощнее и гибче.
Всё дело в том, что yield – дорога в обе стороны: он не только возвращает результат наружу, но и может передавать значение извне в генератор.
Вызов let result = yield value делает следующее:
- Возвращает
valueво внешний код, приостанавливая выполнение генератора. - Внешний код может обработать значение, и затем вызвать
nextс аргументом:generator.next(arg). - Генератор продолжит выполнение, аргумент
nextбудет возвращён как результатyield(и записан вresult).
Продемонстрируем это на примере:
function* gen() {
// Передать вопрос во внешний код и подождать ответа
let result = yield "2 + 2?";
alert(result);
}
let generator = gen();
let question = generator.next().value;
// "2 + 2?"
setTimeout(() => generator.next(4), 2000);
На рисунке ниже прямоугольником изображён генератор, а вокруг него – «внешний код», который с ним взаимодействует:
На этой иллюстрации показано то, что происходит в генераторе:
- Первый вызов
generator.next()– всегда без аргумента, он начинает выполнение и возвращает результат первогоyield(«2+2?»). На этой точке генератор приостанавливает выполнение. - Результат
yieldпереходит во внешний код (вquestion). Внешний код может выполнять любые асинхронные задачи, генератор стоит «на паузе». - Когда асинхронные задачи готовы, внешний код вызывает
generator.next(4)с аргументом. Выполнение генератора возобновляется, а4выходит из присваивания как результатlet result = yield ....
В примере выше – только два next.
Увеличим их количество, чтобы стал более понятен общий поток выполнения:
function* gen() {
let ask1 = yield "2 + 2?";
alert(ask1); // 4
let ask2 = yield "3 * 3?"
alert(ask2); // 9
}
let generator = gen();
alert( generator.next().value ); // "2 + 2?"
alert( generator.next(4).value ); // "3 * 3?"
alert( generator.next(9).done ); // true
Взаимодействие с внешним кодом:
- Первый
.next()начинает выполнение… Оно доходит до первогоyield. - Результат возвращается во внешний код.
- Второй
.next(4)передаёт4обратно в генератор как результат первогоyieldи возобновляет выполнение. - …Оно доходит до второго
yield, который станет результатом.next(4). - Третий
next(9)передаёт9в генератор как результат второгоyieldи возобновляет выполнение, которое завершается окончанием функции, так чтоdone: true.
Получается «пинг-понг»: каждый next(value) передаёт в генератор значение, которое становится результатом текущего yield, возобновляет выполнение и получает выражение из следующего yield. Исключением является первый вызов next, который не может передать значение в генератор, т.к. ещё не было ни одного yield.
generator.throw
Как мы видели в примерах выше, внешний код может вернуть генератору в качестве результата yield любое значение.
…Но «вернуть» можно не только результат, но и ошибку!
Для того, чтобы передать в yield ошибку, используется вызов generator.throw(err). При этом на строке с yield возникает исключение.
Например, в коде ниже обращение к внешнему коду yield "Сколько будет 2 + 2" завершится с ошибкой:
function* gen() {
try {
// в этой строке возникнет ошибка
let result = yield "Сколько будет 2 + 2?"; // (**)
alert("выше будет исключение ^^^");
} catch(e) {
alert(e); // выведет ошибку
}
}
let generator = gen();
let question = generator.next().value;
generator.throw(new Error("ответ не найден в моей базе данных")); // (*)
«Вброшенная» в строке (*) ошибка возникает в строке с yield (**). Далее она обрабатывается как обычно. В примере выше она перехватывается try..catch и выводится.
Если ошибку не перехватить, то она «выпадет» из генератора. По стеку ближайший вызов, который инициировал выполнение – это строка с .throw. Можно перехватить её там, как и продемонстрировано в примере ниже:
function* gen() {
// В этой строке возникнет ошибка
let result = yield "Сколько будет 2 + 2?";
}
let generator = gen();
let question = generator.next().value;
try {
generator.throw(new Error("ответ не найден в моей базе данных"));
} catch(e) {
alert(e); // выведет ошибку
}
Если же ошибка и там не перехвачена, то дальше – как обычно, либо try..catch снаружи, либо она «повалит» скрипт.
Плоский асинхронный код
Одна из основных областей применения генераторов – написание «плоского» асинхронного кода.
Общий принцип такой:
- Генератор
yield'итне просто значения, а промисы. - Есть специальная «функция-чернорабочий»
execute(generator)которая запускает генератор, последовательными вызовамиnextполучает из него промисы – один за другим, и, когда очередной промис выполнится, возвращает его результат в генератор следующимnext. - Последнее значение генератора (
done:true)executeуже обрабатывает как окончательный результат – например, возвращает через промис куда-то ещё, во внешний код или просто использует, как в примере ниже.
Напишем такой код для получения аватара пользователя с github и его вывода, аналогичный рассмотренному в статье про промисы.
Для AJAX-запросов будем использовать метод fetch, он как раз возвращает промисы.
// генератор для получения и показа аватара
// он yield'ит промисы
function* showUserAvatar() {
let userFetch = yield fetch('/article/generator/user.json');
let userInfo = yield userFetch.json();
let githubFetch = yield fetch(`https://api.github.com/users/${userInfo.name}`);
let githubUserInfo = yield githubFetch.json();
let img = new Image();
img.src = githubUserInfo.avatar_url;
img.className = "promise-avatar-example";
document.body.appendChild(img);
yield new Promise(resolve => setTimeout(resolve, 3000));
img.remove();
return img.src;
}
// вспомогательная функция-чернорабочий
// для выполнения промисов из generator
function execute(generator, yieldValue) {
let next = generator.next(yieldValue);
if (!next.done) {
next.value.then(
result => execute(generator, result),
err => generator.throw(err)
);
} else {
// обработаем результат return из генератора
// обычно здесь вызов callback или что-то в этом духе
alert(next.value);
}
}
execute( showUserAvatar() );
Функция execute в примере выше – универсальная, она может работать с любым генератором, который yield'ит промисы.
Вместе с тем, это – всего лишь набросок, чтобы было понятно, как такая функция в принципе работает. Есть уже готовые реализации, обладающие большим количеством возможностей.
Одна из самых известных – это библиотека co, которую мы рассмотрим далее.
Библиотека «co»
Библиотека co, как и execute в примере выше, получает генератор и выполняет его.
Начнём сразу с примера, а потом – детали и полезные возможности:
co(function*() {
let result = yield new Promise(
resolve => setTimeout(resolve, 1000, 1)
);
alert(result); // 1
})
Предполагается, что библиотека co подключена к странице , например, отсюда: https://cdnjs.com/libraries/co/. В примере выше function*() делает yield промиса с setTimeout, который через секунду возвращает 1.
Вызов co(…) возвращает промис с результатом генератора. Если в примере выше function*() что-то возвратит, то это можно будет получить через .then в результате co:
co(function*() {
let result = yield new Promise(
resolve => setTimeout(resolve, 1000, 1)
);
return result; // return 1
}).then(alert); // 1
catchЧастая ошибка начинающих – вообще забывать про обработку результата co. Даже если результата нет, ошибки нужно обработать через catch, иначе они «подвиснут» в промисе.
Такой код ничего не выведет:
co(function*() {
throw new Error("Sorry that happened");
})
Программист даже не узнает об ошибке. Особенно обидно, когда это опечатка или другая программная ошибка, которую обязательно нужно поправить.
Правильный вариант:
co(function*() {
throw new Error("Sorry that happened");
}).catch(alert); // обработать ошибку как-либо
Большинство примеров этого catch не содержат, но это лишь потому, что в примерах ошибок нет. А в реальном коде обязательно нужен catch.
Библиотека co умеет выполнять не только промисы. Есть несколько видов значений, которые можно yieldить, и их обработает co:
- Промис.
- Объект-генератор.
- Функция-генератор
function*()–coеё выполнит, затем выполнит полученный генератор. - Функция с единственным аргументом вида
function(callback)– библиотекаcoеё запустит со своей функцией-callbackи будет ожидать, что при ошибке она вызоветcallback(err), а при успешном выполнении –callback(null, result). То есть, в первом аргументе – будет ошибка (если есть), а втором – результат (если нет ошибки). После чего результат будет передан в генератор. - Массив или объект из вышеперечисленного. При этом все задачи будут выполнены параллельно, и результат, в той же структуре, будет выдан наружу.
В примере ниже происходит yield всех этих видов значений. Библиотека co обеспечивает их выполнение и возврат результата в генератор:
Object.defineProperty(window, 'result', {
// присвоение result=… будет выводить значение
set: value => alert(JSON.stringify(value))
});
co(function*() {
result = yield function*() { // генератор
return 1;
}();
result = yield function*() { // функция-генератор
return 2;
};
result = yield Promise.resolve(3); // промис
result = yield function(callback) { // function(callback)
setTimeout(() => callback(null, 4), 1000);
};
result = yield { // две задачи выполнит параллельно, как Promise.all
one: Promise.resolve(1),
two: function*() { return 2; }
};
result = yield [ // две задачи выполнит параллельно, как Promise.all
Promise.resolve(1),
function*() { return 2 }
];
});
yield function(callback)Отдельно заметим вариант с yield function(callback). Такие функции, с единственным-аргументом callback’ом, в англоязычной литературе называют «thunk».
Функция обязана выполниться и вызвать (асинхронно) либо callback(err) с ошибкой, либо callback(null, result) с результатом.
Использование таких функций в yield является устаревшим подходом, так как там, где можно использовать yield function(callback), можно использовать и промисы. При этом промисы мощнее. Но в старом коде его ещё можно встретить.
Посмотрим пример посложнее, с композицией генераторов:
co(function*() {
let result = yield* gen();
alert(result); // hello
});
function* gen() {
return yield* gen2();
}
function* gen2() {
let result = yield new Promise( // (1)
resolve => setTimeout(resolve, 1000, 'hello')
);
return result;
}
Это – отличный вариант для библиотеки co. Композиция yield* gen() вызывает gen() в потоке внешнего генератора. Аналогично делает и yield* gen2().
Поэтому yield new Promise из строки (1) в gen2() попадает напрямую в библиотеку co, как если бы он был сделан так:
co(function*() {
// gen() и затем gen2() встраиваются во внешний генератор
let result = yield new Promise(
resolve => setTimeout(resolve, 1000, 'hello')
);
alert(result); // hello
});
Пример showUserAvatar() можно переписать с использованием co вот так:
// Загрузить данные пользователя с нашего сервера
function* fetchUser(url) {
let userFetch = yield fetch(url);
let user = yield userFetch.json();
return user;
}
// Загрузить профиль пользователя с github
function* fetchGithubUser(user) {
let githubFetch = yield fetch(`https://api.github.com/users/${user.name}`);
let githubUser = yield githubFetch.json();
return githubUser;
}
// Подождать ms миллисекунд
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Использовать функции выше для получения аватара пользователя
function* fetchAvatar(url) {
let user = yield* fetchUser(url);
let githubUser = yield* fetchGithubUser(user);
return githubUser.avatar_url;
}
// Использовать функции выше для получения и показа аватара
function* showUserAvatar() {
let avatarUrl;
try {
avatarUrl = yield* fetchAvatar('/article/generator/user.json');
} catch(e) {
avatarUrl = '/article/generator/anon.png';
}
let img = new Image();
img.src = avatarUrl;
img.className = "promise-avatar-example";
document.body.appendChild(img);
yield sleep(2000);
img.remove();
return img.src;
}
co(showUserAvatar);
Заметим, что для перехвата ошибок при получении аватара используется try..catch вокруг yield* fetchAvatar:
try {
avatarUrl = yield* fetchAvatar('/article/generator/user.json');
} catch(e) {
avatarUrl = '/article/generator/anon.png';
}
Это – одно из главных удобств использования генераторов. Несмотря на то, что операции fetch – асинхронные, мы можем использовать обычный try..catch для обработки ошибок в них.
Для генераторов – только yield*
Библиотека co технически позволяет писать код так:
let user = yield fetchUser(url);
// вместо
// let user = yield* fetchUser(url);
То есть, можно сделать yield генератора, co() его выполнит и передаст значение обратно. Как мы видели выше, библиотека co – довольно всеядна. Однако, рекомендуется использовать для вызова функций-генераторов именно yield*.
Причин для этого несколько:
- Делегирование генераторов
yield*– это встроенный механизм JavaScript. Вместо возвращения значения обратно вco, выполнения кода библиотеки… Мы просто используем возможности языка. Это правильнее. - Поскольку не происходит лишних вызовов, это быстрее по производительности.
- И, наконец, пожалуй, самое приятное – делегирование генераторов сохраняет стек.
Проиллюстрируем последнее на примере:
co(function*() {
// при запуске в стеке не будет видно этой строки
yield g(); // (*)
}).catch(function(err) {
alert(err.stack);
});
function* g() {
throw new Error("my error");
}
При запуске этого кода стек может выглядеть примерно так:
at g (eval at runJS …, <anonymous>:13:9)
at GeneratorFunctionPrototype.next (native)
at onFulfilled (…/co/…/index.min.js:1:1136)
at …/co/…/index.min.js:1:1076
at co (…/co/…/index.min.js:1:1039)
at toPromise (…/co/…/index.min.js:1:1740)
at next (…/co/…/index.min.js:1:1351)
at onFulfilled (…/co/…/index.min.js:1:1172)
at …/co/…/index.min.js:1:1076
at co (…/co/…/index.min.js:1:1039)
Детали здесь не имеют значения, самое важное – почти весь стек находится внутри библиотеки co.
Из оригинального скрипта там только одна строка (первая):
at g (eval at runJS …, <anonymous>:13:9)
То есть, стек говорит, что ошибка возникла в строке 13:
// строка 13 из кода выше
throw new Error("my error");
Что ж, спасибо. Но как мы оказались на этой строке? Об этом в стеке нет ни слова!
Заменим в строке (*) вызов yield на yield*:
co(function*() {
// заменили yield на yield*
yield* g(); // (*)
}).catch(function(err) {
alert(err.stack);
});
function* g() {
throw new Error("my error");
}
Пример стека теперь:
at g (eval at runJS …, <anonymous>:13:9)
at GeneratorFunctionPrototype.next (native)
at eval (eval at runJS …, <anonymous>:6:10)
at GeneratorFunctionPrototype.next (native)
at onFulfilled (…/co/…/index.min.js:1:1136)
at …/co/…/index.min.js:1:1076
at co (…/co/…/index.min.js:1:1039)
at eval (eval at runJS …, <anonymous>:3:1)
at eval (native)
at runJS (…)
Если очистить от вспомогательных вызовов, то эти строки – как раз то, что нам надо:
at g (eval at runJS …, <anonymous>:13:9)
at eval (eval at runJS …, <anonymous>:6:10)
at eval (eval at runJS …, <anonymous>:3:1)
Теперь видно, что (читаем снизу) исходный вызов был в строке 3, далее – вложенный в строке 6, и затем произошла ошибка в строке 13.
Почему вариант с простым yield не работает – достаточно очевидно, если внимательно посмотреть на код и воспроизвести в уме, как он функционирует. Оставляем это упражнение вдумчивому читателю.
Итого, рекомендация уже достаточно обоснована – при запуске вложенных генераторов используем yield*.
Итого
- Генераторы создаются при помощи функций-генераторов
function*(…) {…}. - Внутри генераторов и только внутри них разрешён оператор
yield. Это иногда создаёт неудобства, поскольку в колбэках.map/.forEachсделатьyieldнельзя. Впрочем, можно сделатьyieldмассива (при использованииco). - Внешний код и генератор обмениваются промежуточными результатами посредством вызовов
next/yield. - Генераторы позволяют писать плоский асинхронный код, при помощи библиотеки
co.
Что касается кросс-браузерной поддержки – она стремительно приближается. Пока же можно использовать генераторы вместе с Babel.
Комментарии
<code>, для нескольких строк кода — тег<pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)