Главная / Статьи / Объяснение генераторов в JavaScript: функции с возможностью паузы и ленивая итерация

Объяснение генераторов в JavaScript: функции с возможностью паузы и ленивая итерация

Узнайте, как функции-генераторы JavaScript приостанавливают и возобновляют выполнение, реализуют протокол итераторов и обеспечивают отложенную, энергоэффективную передачу данных.

1084 слов

Каждая функция, которую вы писали до сих пор, подчиняется одному правилу: вы её вызываете, она выполняется с начала до конца, возвращает значение один раз, и на этом всё заканчивается. Генераторы тихо нарушают это правило, и в практике это нарушение оказывается очень важным: функция-генератор может приостановить свою работу на полпути, вернуть значение тому, кто её вызвал, а затем возобновить выполнение именно с этой точки, как будто между тем ничего не произошло.

Основной механизм

Генератор объявляется с помощью 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 перестанут казаться тремя не связанными между собой функциями. Оказывается, это единая концепция.

основной паттерн, проявляющийся в трех разных контекстах.

Связанная литература