Explicación de los generadores de JavaScript: funciones pausables e iteración perezosa
Aprenda cómo las funciones generadoras de JavaScript pausan y reanudan la ejecución, implementan el protocolo de iterador y permiten un flujo de datos perezoso y eficiente en términos de memoria.
Toda función que has escrito hasta ahora obedece una regla: la llamas, ella se ejecuta de principio a fin, devuelve un valor una sola vez y eso es todo. Los generadores rompen silenciosamente esa regla, y ese incumplimiento resulta ser muy importante en la práctica: una función generadora puede suspenderse a mitad de su ejecución, pasar un valor de vuelta a quien la invocó y reanudarse más tarde desde ese mismo punto, como si no hubiera pasado tiempo entre tanto.
El mecanismo básico
Declaras un generador con function*, y en lugar de return utiliza yield para entregar valores uno por uno:
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 }
Al llamar a countUp() no se ejecuta ninguna parte del cuerpo de la función. Esta devuelve un objeto generador, una estructura inactiva que aún no ha comenzado a ejecutarse. Cada llamada posterior a .next() reanuda la ejecución justo donde se detuvo anteriormente, continúa hasta encontrar el siguiente yield y vuelve a congelarse, devolviendo el valor que se generó. Todo el estado interno de la función, sus variables, su posición dentro de un bucle, cualquier cosa en realidad, permanece intacto durante estas pausas, como si la función nunca hubiera sido interrumpida realmente.
Por qué esto es verdaderamente diferente de simplemente devolver un array
Una objeción natural es: ¿por qué no simplemente construir un array y devolverlo en su lugar? Aquí es donde los generadores demuestran su utilidad, ya que producen el resultado de forma perezosa, elemento por elemento y solo cuando se solicita, en lugar de calcular todo el conjunto de resultados con antelación.
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
}
Si esto se hubiera escrito para armar un array con todas las líneas de antemano, sería necesario leer todo un archivo de registro de varios gigabytes en la memoria antes de poder acceder a siquiera una sola línea, lo cual es el mismo problema que buscan evitar los enfoques de streaming. Un generador lo evita mediante un mecanismo diferente: cada línea solo se calcula en el momento en que algo la solicita a través de .next(), y si el bucle for...of que lo rodea termina antes, por ejemplo una vez que encuentra la coincidencia que buscaba, el generador no se molesta en calcular nada más allá de ese punto.
Los generadores implementan el protocolo de iterador, y por eso for...of funciona sin problemas
Construcciones como for...of, la desestructuración de arrays y el operador de propagación funcionan con cualquier elemento que cumpla con el protocolo de iterador, es decir, cualquier objeto que exponga un método next() que devuelva { value, done }. Un objeto generador se adapta automáticamente a esa estructura sin que sea necesario realizar ningún esfuerzo adicional, lo cual explica por qué se puede iterar sobre él directamente sin necesidad de configuraciones adicionales:
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++;
}
}
La sintaxis yield* delega en otro iterable, enviando sus valores uno por uno en secuencia. Así es como un generador puede convertir una serie de resultados paginados en un flujo continuo de elementos individuales, sin que el código que los consume necesite saber que existe una paginación en segundo plano.
Comunicación bidireccional: .next() puede enviar valores, no solo obtenerlos
Existen aspectos menos conocidos de los generadores: yield no es solo una forma de emitir valores, sino también una expresión; todo lo que se pase en la siguiente llamada a .next() se convierte en el resultado de dicha expresión. Esto significa que los datos pueden regresar a una función pausada, y no solo salir de ella.
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"
Cada vez que se llama a .next(value), el generador se activa y la línea yield en pausa se evalúa con el valor que se proporcionó. Tener una función capaz de suspenderse, esperar a recibir nueva entrada del llamante y luego continuar trabajando con esa entrada es una capacidad inusual en el diseño habitual de funciones. Es precisamente este mecanismo del que se valieron las primeras bibliotecas asíncronas de JavaScript para simular async/await antes de que el lenguaje lo soportara de forma nativa: un programador de tareas resolvía una promesa y luego introducía su resultado en el generador a través de .next(), repitiendo este proceso en cada yield hasta que se completara cada paso asíncrono.
Por qué esta es la base real que vale la pena conocer
Los generadores están lejos de ser una rareza sintáctica que se pueda ignorar sin problemas. Son el mecanismo sobre el cual se construyó eventualmente async/await, ya que “suspender la ejecución aquí y reanudarla más tarde con algún valor” es exactamente el comportamiento que requiere la espera de una promesa. También explican por qué for...of, la sintaxis de dispersión y el desestructurado se comportan de manera consistente en arrays, cadenas, instancias de Map y objetos personalizados, ya que el protocolo de iterador que implementan los generadores es idéntico al que ya utilizan esos tipos integrados. Una vez que internalizas el verdadero modelo mental —una función capaz de pausarse y reanudarse intercambiando valores en cada punto de pausa, la evaluación perezosa, la lógica de iteración personalizada y el funcionamiento interno de async/await—, dejan de parecer tres características no relacionadas. Resultan ser en realidad una sola cosa.
Lecturas relacionadas
- Node.js 26: API Temporal, Map Upserts y Undici 8 explicados — Explica los principales cambios orientados al backend en Node.js 26, incluida la API Temporal estable, los métodos nativos de Map upsert, las mejoras de rendimiento en Undici 8 y los cambios que pueden causar problemas que es necesario auditar antes de actualizar.
- Explicación del renderizado en React: Actualizaciones de estado a píxeles en la pantalla — Aprenda cómo las fases de renderizado, reconciliación y confirmación en React se conectan con el pipeline de layout, pintura y composición del navegador para generar píxeles.