Полное руководство по генераторам JavaScript
Генераторы JavaScript позволяют вам легко определять итераторы и писать код, который можно приостанавливать и возобновлять, позволяя вам контролировать процесс выполнения. В этой статье мы рассмотрим, как генераторы позволяют вам "использовать" функцию для управления состоянием, сообщать о ходе выполнения длительных операций и делать ваш код более читабельным.
В то время как многие разработчики сразу же обращаются к таким инструментам, как RxJS или другие observables, для решения асинхронных задач, генераторы часто упускаются из виду - и они могут быть удивительно мощными.
Мы сравним генераторы с популярными решениями, такими как RxJS, и покажем вам, в чем преимущества генераторов и где более традиционные подходы могут подойти лучше. Без лишних слов, давайте начнем!
Понимание генераторов JavaScript
Проще говоря, генераторы - это специальные функции в JavaScript, которые можно приостанавливать и возобновлять, позволяя вам контролировать процесс выполнения более точно, чем при использовании обычных функций.
Функция-генератор - это функция особого типа, которая возвращает объект-генератор и соответствует протоколу iterable и протоколу iterator.
Генераторы были впервые представлены в ES6 и с тех пор стали фундаментальной частью языка. Они определяются с помощью ключевого слова function, к которому добавляется звездочка, например: function*. Вот пример:
function* generatorFunction() { return "Hello World"; //generator body }
Иногда вы можете встретить звездочки с префиксом к названию функции, например, so *function. Хотя этот синтаксис менее распространен, он по-прежнему действителен.
Чем генераторы отличаются от стандартных функций
На первый взгляд, генератор может выглядеть как обычная функция (за вычетом звездочки), но некоторые важные отличия делают их уникально мощными.
В стандартной функции, как только вы ее вызываете, она выполняется от начала до конца. Нет возможности приостановить выполнение на полпути, а затем вернуться к работе снова. Генераторы, с другой стороны, позволяют приостанавливать выполнение в любой момент времени.
Такая возможность приостановки также позволяет сохранять состояние между каждой паузой, что делает генераторы идеальными для сценариев, в которых вам нужно отслеживать, что произошло на данный момент, например, при обработке большого набора данных по частям. Кроме того, при вызове обычной функции она выполняется до завершения и возвращает значение. Однако, когда вы вызываете функции-генераторы, они возвращают объект-генератор. Этот объект является итератором, используемым для циклического просмотра последовательности значений.
Когда вы работаете с генератором, вы не можете просто вызвать его один раз и забыть о нем. Вместо этого вы используете такие методы, как next(), throw() и return(), чтобы управлять его состоянием извне:
- next(значение): Возобновляет работу генератора и может передать обратно в генератор значение, полученное с помощью последнего выражения yield. Метод next() возвращает объект со значением и свойствами done. value представляет возвращаемое значение, а done указывает, выполнил ли итератор все свои значения
- throw(ошибка): Выдает ошибку внутри генератора, эффективно позволяя вам обрабатывать исключения более контролируемым образом.
- return(значение): завершает работу генератора досрочно, возвращая указанное значение
Такая двусторонняя связь является большим шагом вперед по сравнению с обычными функциями и может быть использована для построения более сложных рабочих процессов, включая обработку ошибок и частичную обработку данных.
Пример используемых генераторов
Для начала давайте инициализируем функцию генератора Hello World, которую мы показывали ранее, и получим ее значение:
const generator = generatorFunction();
Когда вы вызываете generatorFunction() и сохраняете его в переменной, вы не сразу увидите строку "Hello World". Вместо этого вы получаете то, что называется объектом generator, и изначально оно находится в состоянии "приостановлено", что означает, что оно приостановлено и еще не запустило какой-либо код.
Если вы попробуете logging generator, то увидите, что это не простая строка. Это объект, представляющий приостановленный генератор. Чтобы получить значение функции generator, нам нужно вызвать метод next() для объекта generator:
const result = generator.next();
Это даст нам следующий результат:
{ value: 'Hello World', done: true }
Он вернул нашу строку "Hello World" в качестве значения object key, а состояние done равно true, потому что больше не было кода для выполнения. В результате статус функции генератора изменился с приостановленного на закрытое.
До сих пор мы видели только, как вернуть одно значение из функции-генератора. Но что, если мы хотим вернуть несколько значений? Вот тут-то и пригодится оператор yield.
Оператор доходности
Генераторы JavaScript позволяют приостанавливать и возобновлять выполнение функций с помощью ключевого слова yield. Например, представьте, что у вас есть функция-генератор, подобная этой:
function* generatorFunction() { yield "first value"; yield "second value"; yield "third value"; yield "last value"; }
Каждый раз, когда вы вызываете next() для генератора, функция выполняется до тех пор, пока не достигнет инструкции yield, а затем приостанавливается. В этот момент генератор возвращает объект с двумя свойствами:
- ценность: Фактическая ценность, которую вы получаете
- готово: логическое значение, указывающее, завершен ли генератор.
До тех пор, пока не будет получен другой результат (или пока он не достигнет значения return), значение done будет равно false. Как только у генератора больше не останется операторов yield, значение done станет true.
Расширяя приведенный выше пример, если мы вызовем метод next() четыре раза, мы получим следующий результат:
const generator = generatorFunction(); generator.next(); // { value: 'first value', done: false } generator.next(); // { value: 'second value', done: false } generator.next(); // { value: 'third value', done: false } generator.next(); // { value: 'last value', done: true }
Обратите внимание, что каждый из первых трех вызовов next() возвращает новое значение с параметром done: false. При четвертом вызове у генератора закончились инструкции yield, поэтому он возвращает значение done: true.
Передача значений генераторам
Что действительно здорово, так это то, что yield не просто возвращает значения - как улица с двусторонним движением, он также может получать их из любого места, где вызывается генератор, обеспечивая двустороннюю связь между генератором и вызывающим его пользователем.
Чтобы передать значение функции-генератору, мы можем вызвать метод next() с аргументом. Вот простой пример:
function* generatorFunction() { console.log(yield); console.log(yield); } const generator = generatorFunction(); generator.next(); // First call â no yield has been paused yet, so nothing to pass in generator.next("first input"); generator.next("second input");
Это позволило бы последовательно регистрировать следующее:
first input second input
Видите, как при первом вызове generator.next() ничего не выводится? Это потому, что в этот момент нет отложенного результата, готового принять значение. К тому времени, когда мы вызываем generator.next ("первый ввод"), ожидается отложенный результат, поэтому "первый ввод" регистрируется. Для третьего вызова используется та же схема.
Именно так генераторы позволяют вам передавать данные туда и обратно между вызывающим устройством и самим генератором.
Обработка длительных асинхронных операций и потоков
С появлением ECMAScript 2017 появились асинхронные генераторы, особый вид генераторных функций, которые работают с promises. Благодаря асинхронным генераторам вы больше не ограничены синхронным кодом в своих генераторах. Теперь вы можете выполнять такие задачи, как извлечение данных из API, чтение файлов или что-либо еще, что связано с ожиданием выполнения обещания.
Вот простой пример функции асинхронного генератора:
async function* asyncGenerator() { yield await Promise.resolve("1"); yield await Promise.resolve("2"); yield await Promise.resolve("3"); } const generator = asyncGenerator(); await generator.next(); // { value: '1', done: false } await generator.next(); // { value: '2', done: false } await generator.next(); // { value: '3', done: true }
Основное отличие заключается в том, что вы должны использовать await для каждого вызова generator.next() для получения значения, потому что все происходит асинхронно.
Далее мы можем продемонстрировать, как использовать асинхронные генераторы для просмотра разбитых на страницы наборов данных из удаленного API. Это идеальный вариант использования асинхронных генераторов, поскольку мы можем инкапсулировать нашу логику последовательных итераций в одну функцию. В этом примере мы будем использовать бесплатный API DummyJSON для получения списка товаров с разбивкой по страницам.
Чтобы получить данные из этого API, мы можем отправить запрос GET к следующей конечной точке. Мы передадим limit и пропустим параметры, чтобы ограничить и пропустить результаты разбивки на страницы:
https://dummyjson.com/products?limit=10&skip=0
Пример ответа от этой конечной точки может выглядеть следующим образом:
{ "products": [ { "id": 1, "title": "Annibale Colombo Bed", "price": 1899.99 }, {...}, // 10 items ], "total": 194, "skip": 0, "limit": 10 }
Чтобы загрузить следующую партию продуктов, вы просто увеличиваете пропуск на тот же предел, пока не загрузите все.
Имея это в виду, вот как мы можем реализовать пользовательскую функцию генератора для получения всех продуктов из API:
async function* fetchProducts(skip = 0, limit = 10) { let total = 0; do { const response = await fetch( `https://dummyjson.com/products?limit=${limit}&skip=${skip}`, ); const { products, total: totalProducts } = await response.json(); total = totalProducts; skip += limit; yield products; } while (skip < total); }
Теперь мы можем повторить это, чтобы получить все продукты, используя цикл for await...of:
for await (const products of fetchProducts()) { for (const product of products) { console.log(product.title); } }
Он будет регистрировать продукты до тех пор, пока больше не будет нужных данных:
Essence Mascara Lash Princess Eyeshadow Palette with Mirror Powder Canister Red Lipstick Red Nail Polish ... // 15 more items
Благодаря тому, что вся логика разбивки на страницы реализована в асинхронном генераторе, ваш основной код остается чистым и сфокусированным. Всякий раз, когда вам требуется больше данных, генератор выполняет прозрачную выборку и выдает следующий набор результатов, благодаря чему разбивка на страницы становится похожа на простой непрерывный поток данных.
Генераторы как конечные автоматы
Хотя генераторы можно использовать как простые автоматы состояний (они помнят, на чем остановились каждый раз), они не всегда являются наиболее практичным выбором - особенно если учесть надежные инструменты управления состоянием, предлагаемые большинством современных фреймворков JavaScript.
Во многих случаях дополнительный код и сложность реализации конечного автомата с генераторами могут перевесить преимущества.
Если вы все еще хотите изучить этот подход, вы можете обратиться к модели Actor, которая основана на языке программирования Erlang. Хотя подробности выходят за рамки этой статьи, модель Actor часто более эффективна для управления состоянием.
В этой модели "акторы" являются независимыми объектами, которые инкапсулируют свое собственное состояние и поведение и взаимодействуют исключительно посредством передачи сообщений. Такой дизайн гарантирует, что изменения состояния происходят только внутри самого актора, что делает систему более модульной и простой в управлении.
RxJS против генераторов для обработки веб-потоков
Когда дело доходит до обработки потоков данных, и генераторы JavaScript, и RxJS - отличные инструменты, но у каждого из них есть свои сильные и слабые стороны. К счастью для нас, они не являются взаимоисключающими, поэтому мы можем использовать оба.
Чтобы продемонстрировать это, давайте представим, что у нас есть конечная точка, которая возвращает множество рандомизированных 8-символьных строк в виде потока. Для первого шага мы можем использовать функцию генератора, которая будет лениво выдавать фрагменты данных по мере их извлечения из потока:
// Fetch data from HTTP stream async function* fetchStream() { const response = await fetch("https://example/api/stream"); const reader = response.body?.getReader(); if (!reader) throw new Error(); try { while (true) { const { done, value } = await reader.read(); if (done) break; yield value; } } catch (error) { throw error; } finally { reader.releaseLock(); } }
Вызов функции fetchStream() возвращает асинхронный генератор. Затем вы можете выполнить итерацию по этим фрагментам с помощью цикла - или, как мы увидим далее, использовать RxJS для добавления некоторых сверхспособностей потоковой обработки.
RxJS предоставляет богатый набор операторов, таких как map, filter и take, которые помогают вам преобразовывать и фильтровать асинхронные потоки данных. Чтобы использовать их с вашим асинхронным генератором, вы можете преобразовать генератор в наблюдаемый с помощью оператора from в RxJS.
Теперь мы будем использовать оператор take для фильтрации первых пяти фрагментов данных:
import { from, take } from "rxjs"; // Consume HTTP stream using RxJS async () => { from(fetchStream()) .pipe(take(5)) .subscribe({ next: (chunk) => { const decoder = new TextDecoder(); console.log("Chunk:", decoder.decode(chunk)); }, complete: () => { console.log("Stream complete"); }, }); };
Если вы новичок в RxJS, оператор from преобразует асинхронный генератор в observable. Это позволяет нам подписываться на полученные данные и получать к ним доступ, как если бы они были синхронными. Просматривая выходные данные нашего журнала после декодирования, мы должны увидеть первые пять фрагментов потока:
Chunk: ky^p1egh Chunk: 1q)zIz43 Chunk: xm5aJGSX Chunk: GSx6a2UQ Chunk: GFlwWPu^ Stream complete
В качестве альтернативы, вы могли бы использовать поток, используя цикл for await...of:
// Consume the HTTP stream using for-await-of for await (const chunk of fetchStream()) { const decoder = new TextDecoder(); console.log("Chunk:", decoder.decode(chunk)); }
Но при таком подходе нам не хватает операторов RxJS, которые упрощают управление потоком более гибкими способами. Например, мы не можем использовать оператор take для ограничения количества фрагментов, которые мы хотим использовать.
Однако это ограничение не будет длиться вечно. С помощью помощников по итерации, предложенных для следующей версии ECMAScript (в настоящее время - этап 4), вы в конечном итоге сможете выполнять такие действия, как ограничение или преобразование выходных данных генератора изначально - аналогично тому, что RxJS уже делает для наблюдаемых объектов.
Для более сложных асинхронных потоков RxJS по-прежнему предлагает надежный инструментарий, который в ближайшее время будет нелегко заменить собственными помощниками по итерации:
Вывод
Генераторы JavaScript предлагают мощный и часто упускаемый из виду способ обработки асинхронных операций, управления состоянием и обработки потоков данных. Позволяя вам приостанавливать и возобновлять выполнение, они обеспечивают более точное управление по сравнению с обычными функциями, особенно когда вам нужно решать длительные задачи или выполнять итерации по большим наборам данных.
В то время как генераторы превосходны во многих сценариях, такие инструменты, как RxJS, обеспечивают мощную экосистему операторов, которая может оптимизировать сложные потоки, управляемые событиями.
Но выбирать не приходится: вы можете сочетать элегантность генераторов с мощными преобразованиями RxJS или даже просто придерживаться простого цикла for await...of, если это соответствует вашим потребностям.
Забегая вперед, можно сказать, что новые помощники по итерации могут приблизить возможности генератора к возможностям RxJS, но в обозримом будущем RxJS останется основным инструментом для обработки сложных реактивных шаблонов.