Генераторы – новый вид функций в современном 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..9
65..90
– дляA..Z
97..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.