Генераторы в JavaScript: Забытый инструмент для сложных асинхронных сценариев

В JavaScript async/await стал стандартом для асинхронного кода. Но когда задачи становятся сложнее – множественные источники событий, отмена операций, стримы данных – генераторы предлагают уникальные возможности, недоступные при стандартном подходе. Рассмотрим их потенциал за пределами создания итераторов.

Принцип работы: Прерываемые функции

Генераторная функция (function*) может приостанавливать выполнение с помощью yield и возобновляться позже. Каждый вызов next() возвращает объект { value, done }.

javascript
function* simpleGen() {
  yield 'First';
  const midValue = yield 'Second';
  yield midValue.toUpperCase();
}

const gen = simpleGen();
console.log(gen.next()); // { value: 'First', done: false }
console.log(gen.next()); // { value: 'Second', done: false }
console.log(gen.next('result')); // { value: 'RESULT', done: false }
console.log(gen.next()); // { value: undefined, done: true }

Ключевые моменты:

  • yield возвращает значение в вызывающий код
  • Метод next() может передать значение внутрь генератора (становится результатом yield)
  • Состояние функции (локальные переменные, позиция) сохраняется между вызовами

Асинхронные корутины: До async/await

Можно имитировать поведение async/await, используя генераторы для управления промисами. Рассмотрим утилиту run:

javascript
function run(generatorFn) {
  const gen = generatorFn();

  function handle(result) {
    if (result.done) return result.value;
    return result.value
      .then(data => handle(gen.next(data)))
      .catch(err => handle(gen.throw(err)));
  }

  return handle(gen.next());
}

Использование:

javascript
run(function* fetchUser() {
  try {
    const user = yield fetch('/api/user').then(r => r.json());
    const posts = yield fetch(`/api/posts/${user.id}`).then(r => r.json());
    return { user, posts };
  } catch (error) {
    console.error("Fetch failed:", error);
    throw error;
  }
}).then(data => console.log(data));

Сильные стороны генераторов: За пределами async/await

  1. Двусторонняя коммуникация В отличие от линейного await, генератор позволяет получать данные извне во время работы:
javascript
function* userInteraction() {
  const userId = yield { type: 'WAIT_INPUT' };
  const user = yield fetchUser(userId);
  yield { type: 'RENDER_USER', user };
}

// Драйвер
const gen = userInteraction();
gen.next(); // Запуск, запрос input

// Когда пользователь вводит ID:
const userId = 42;
gen.next(userId); // Продолжаем выполнение с полученным ID
  1. Комплексная отмена операций yield может передавать управление наружу для обработки прерываний:
javascript
function* cancellableTask() {
  const abortController = new AbortController();
  
  try {
    const promise = fetch(url, { signal: abortController.signal });
    yield { cancel: () => abortController.abort() };
    return yield promise;
  } finally {
    // Очистка при отмене
    abortController.abort(); 
  }
}

// Внешний код с доступом к генератору:
const task = cancellableTask();
const cancel = task.next().value.cancel; // Получаем колбэк для отмены
cancel(); // Прерываем операцию
  1. Генерация и потребление данных по требованию Идеально для работы со стримами без нагрузки на память:
javascript
async function* streamReader(response) {
  const reader = response.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) return;
    yield value;
  }
}

// Потребление порциями через цикл `for await`
for await (const chunk of streamReader(response)) {
  processChunk(chunk);
}
  1. Cостоятельные сценарии (State Machines) Управление сложной бизнес-логикой с явным состоянием:
javascript
function* authSession() {
  let user = null;
  
  while (true) {
    const action = yield { status: user ? 'loggedIn' : 'guest' };
    
    switch (action.type) {
      case 'LOGIN':
        user = yield authenticate(action.credentials);
        break;
      case 'LOGOUT':
        yield revokeToken(user.token);
        user = null;
        break;
      case 'REFRESH':
        user = yield renewSession(user.token);
        break;
    }
  }
}

const session = authSession();
console.log(session.next().value.status); // 'guest'
session.next({ type: 'LOGIN', credentials: {...} });
console.log(session.next().value.status); // 'loggedIn'

Когда это оправдано?

Генераторы добавляют сложность. Рассмотрите их для:

  • Длительных процессов с ручным управлением (загрузки файлов, WebSocket-сессии)
  • Реализации пользовательских стримов
  • Алгоритмов с необходимостью приостановки/продолжения
  • Сложных конечных автоматов в UI/серверной логике
  • Экзотических сценариев типа конкурентного программирования (Cooperative Multitasking)

Реальные нюансы

  1. Обработка ошибок: Используйте gen.throw(error) внутри драйверов для проброса исключений внутрь генератора в ближайший try/catch.
  2. Взаимодействие с Promise: Комбинируйте yield с промисами для асинхронных пауз.
  3. Отладка: Стеки вызовов прерываются на yield, используйте source maps и явные точки останова.
  4. Производительность: Не подходят для high-throughput микрозадач из-за накладных расходов на управление контекстом.

Заключение

Генераторы — мощный инструмент для сценариев, где требуется ручное управление потоком выполнения. Они не заменяют async/await для линейного кода, но предлагают контроль над процессами там, где классические подходы усложняют архитектуру. Используйте их для обработки событий с состоянием, отменяемых операций, ленивых вычислений и сложных цепочек действий, особенно в библиотеках и инфраструктурном коде. Это не ежедневный инструмент, но козырь в рукаве при столкновении с необычными асинхронными вызовами.