Entender los generadores en JavaScript

El autor seleccionó el Open Internet/Free Speech Fund para recibir una donación como parte del programa Write for DOnations.

Introducción

En ECMAScript 2015, se introdujeron los generadores en el lenguaje JavaScript. Un generador es un proceso que puede ser pausado y reanudado y puede producir múltiples valores. Un generador en JavaScript consiste en una función generadora, que devuelve un objeto iterable Generator.

Los generadores pueden mantener el estado, proporcionando una forma eficiente de hacer iteradores, y son capaces de tratar con flujos de datos infinitos, que pueden ser utilizados para implementar el scroll infinito en el frontend de una aplicación web, para operar con datos de ondas sonoras, y más. Además, cuando se usan con Promises, los generadores pueden imitar la funcionalidad de async/await, lo que nos permite tratar con código asíncrono de una manera más directa y legible. Aunque async/await es una forma más frecuente de tratar con casos de uso asíncronos comunes y sencillos, como la obtención de datos de una API, los generadores tienen características más avanzadas que hacen que valga la pena aprender a utilizarlos.

En este artículo, cubriremos cómo crear funciones de generador, cómo iterar sobre objetos Generator, la diferencia entre yield y return dentro de un generador, y otros aspectos del trabajo con generadores.

Funciones generadoras

Una función generadora es una función que devuelve un objeto Generator, y se define con la palabra clave function seguida de un asterisco (*), como se muestra en lo siguiente:

// Generator function declarationfunction* generatorFunction() {}

Ocasionalmente, verá el asterisco junto al nombre de la función, en lugar de la palabra clave de la función, como function *generatorFunction(). Esto funciona igual, pero function* es una sintaxis más aceptada.

Las funciones generadoras también pueden definirse en una expresión, como las funciones regulares:

// Generator function expressionconst generatorFunction = function*() {}

Los generadores pueden incluso ser los métodos de un objeto o clase:

Los ejemplos a lo largo de este artículo utilizarán la sintaxis de declaración de funciones generadoras.

Nota: A diferencia de las funciones regulares, los generadores no pueden ser construidos con la palabra clave new, ni pueden ser utilizados junto con las funciones de flecha.

Ahora que sabes cómo declarar funciones generadoras, vamos a ver los objetos iterables Generator que devuelven.

Objetos generadores

Tradicionalmente, las funciones en JavaScript se ejecutan hasta su finalización, y la llamada a una función devolverá un valor cuando llegue a la palabra clave return. Si se omite la palabra clave return, una función devolverá implícitamente undefined.

En el siguiente código, por ejemplo, declaramos una función sum() que devuelve un valor que es la suma de dos argumentos enteros:

// A regular function that sums two valuesfunction sum(a, b) { return a + b}

La llamada a la función devuelve un valor que es la suma de los argumentos:

const value = sum(5, 6) // 11

Una función generadora, sin embargo, no devuelve un valor inmediatamente, y en su lugar devuelve un objeto iterable Generator. En el siguiente ejemplo, declaramos una función y le damos un único valor de retorno, como una función estándar:

// Declare a generator function with a single return valuefunction* generatorFunction() { return 'Hello, Generator!'}

Cuando invoquemos la función generadora, ésta devolverá el objeto Generator, que podemos asignar a una variable:

// Assign the Generator object to generatorconst generator = generatorFunction()

Si esto fuera una función normal, esperaríamos que generator nos diera la cadena devuelta en la función. Sin embargo, lo que realmente obtenemos es un objeto en un estado suspended. Por lo tanto, llamar a generator dará una salida similar a la siguiente:

El objeto Generator devuelto por la función es un iterador. Un iterador es un objeto que tiene un método next() disponible, que se utiliza para iterar a través de una secuencia de valores. El método next() devuelve un objeto con las propiedades value y done. value representa el valor devuelto, y done indica si el iterador ha recorrido todos sus valores o no.

Sabiendo esto, llamemos a next() sobre nuestro generator y obtengamos el valor actual y el estado del iterador:

// Call the next method on the Generator objectgenerator.next()

Esto dará la siguiente salida:

Output
{value: "Hello, Generator!", done: true}

El valor devuelto al llamar a next() es Hello, Generator!, y el estado de done es true, porque este valor proviene de un return que cerró el iterador. Como el iterador ha terminado, el estado de la función generadora cambiará de suspended a closed. Al llamar de nuevo a generator se obtendrá lo siguiente:

Output
generatorFunction {<closed>}

Por ahora, sólo hemos demostrado cómo una función generadora puede ser una forma más compleja de obtener el valor return de una función. Pero las funciones generadoras también tienen características únicas que las distinguen de las funciones normales. En la siguiente sección, aprenderemos sobre el operador yield y veremos cómo un generador puede pausar y reanudar la ejecución.

Operadores yield

Los generadores introducen una nueva palabra clave en JavaScript: yield. yield puede pausar una función generadora y devolver el valor que sigue a yield, proporcionando una forma ligera de iterar a través de valores.

En este ejemplo, pausaremos la función generadora tres veces con diferentes valores, y devolveremos un valor al final. Luego asignaremos nuestro objeto Generator a la variable generator.

Ahora, cuando llamemos a next() en la función generadora, ésta hará una pausa cada vez que se encuentre con yield. done se pondrá a false después de cada yield, indicando que el generador no ha terminado. Una vez que encuentra un return, o no hay más yields encontrados en la función, done cambiará a true, y el generador habrá terminado.

Utiliza el método next() cuatro veces seguidas:

// Call next four timesgenerator.next()generator.next()generator.next()generator.next()

Esto dará las siguientes cuatro líneas de salida en orden:

Output
{value: "Neo", done: false}{value: "Morpheus", done: false}{value: "Trinity", done: false}{value: "The Oracle", done: true}

Nota que un generador no requiere un return; si se omite, la última iteración devolverá {value: undefined, done: true}, al igual que cualquier llamada posterior a next() después de que un generador haya terminado.

Iterando sobre un generador

Usando el método next(), iteramos manualmente sobre el objeto Generator, recibiendo todas las propiedades value y done del objeto completo. Sin embargo, al igual que Array, Map y Set, un Generator sigue el protocolo de iteración, y puede ser iterado con for...of:

// Iterate over Generator objectfor (const value of generator) { console.log(value)}

Esto devolverá lo siguiente:

Output
NeoMorpheusTrinity

El operador de propagación también se puede utilizar para asignar los valores de un Generator a una matriz.

// Create an array from the values of a Generator objectconst values = console.log(values)

Esto dará la siguiente matriz:

Output
(3)

Tanto spread como for...of no factorizarán el return en los valores (en este caso, habría sido 'The Oracle').

Nota: Aunque ambos métodos son efectivos para trabajar con generadores finitos, si un generador está tratando con un flujo de datos infinito, no será posible utilizar spread o for...of directamente sin crear un bucle infinito.

Cerrar un Generador

Como hemos visto, un generador puede tener su propiedad done establecida a true y su estado establecido a closed iterando a través de todos sus valores. Hay dos maneras adicionales de cancelar inmediatamente un generador: con el método return(), y con el método throw().

Con return(), el generador puede ser terminado en cualquier punto, como si una declaración return hubiera estado en el cuerpo de la función. Puede pasar un argumento a return(), o dejarlo en blanco para un valor indefinido.

Para demostrar return(), crearemos un generador con algunos valores yield pero sin return en la definición de la función:

function* generatorFunction() { yield 'Neo' yield 'Morpheus' yield 'Trinity'}const generator = generatorFunction()

El primer next() nos dará 'Neo', con done ajustado a false. Si invocamos un método return() en el objeto Generator justo después de eso, ahora obtendremos el valor pasado y done establecido a true. Cualquier llamada adicional a next() dará la respuesta del generador completado por defecto con un valor indefinido.

Para demostrar esto, ejecute los siguientes tres métodos en generator:

generator.next()generator.return('There is no spoon!')generator.next()

Esto dará los tres resultados siguientes:

Output
{value: "Neo", done: false}{value: "There is no spoon!", done: true}{value: undefined, done: true}

El método return() obligó al objeto Generator a completar y a ignorar cualquier otra palabra clave yield. Esto es particularmente útil en la programación asíncrona cuando se necesita hacer que las funciones sean cancelables, como la interrupción de una solicitud web cuando un usuario quiere realizar una acción diferente, ya que no es posible cancelar directamente una Promise.

Si el cuerpo de una función generadora tiene una forma de atrapar y tratar los errores, se puede utilizar el método throw() para lanzar un error en el generador. Esto inicia el generador, lanza el error y termina el generador.

Para demostrar esto, pondremos un try...catch dentro del cuerpo de la función del generador y registraremos un error si se encuentra uno:

Ahora, ejecutaremos el método next(), seguido de throw():

generator.next()generator.throw(new Error('Agent Smith!'))

Esto dará la siguiente salida:

Output
{value: "Neo", done: false}Error: Agent Smith!{value: undefined, done: true}

Usando throw(), inyectamos un error en el generador, que fue capturado por el try...catch y registrado en la consola.

Métodos y estados del objeto generador

La siguiente tabla muestra una lista de métodos que se pueden utilizar en los objetos Generator:

Método Descripción
next() Devuelve el siguiente valor en un generador
return() Devuelve un valor en un generador y termina el generador
throw() Lanza un error y termina el generador

La siguiente tabla lista los posibles estados de un objeto Generator:

Estado Descripción
suspended El generador ha detenido la ejecución pero no ha terminado
closed El generador ha terminado al encontrar un error, retornando, o iterando a través de todos los valores

yield Delegación

Además del operador regular yield, los generadores también pueden utilizar la expresión yield* para delegar más valores a otro generador. Cuando el yield* se encuentra dentro de un generador, irá dentro del generador delegado y comenzará a iterar a través de todos los yields hasta que ese generador se cierre. Esto se puede utilizar para separar diferentes funciones del generador para organizar semánticamente su código, mientras que todavía tiene todos sus yields ser iterable en el orden correcto.

Para demostrarlo, podemos crear dos funciones generadoras, una de las cuales yield*operará sobre la otra:

A continuación, vamos a iterar sobre la función generadora begin():

// Iterate through the outer generatorconst generator = begin()for (const value of generator) { console.log(value)}

Esto dará los siguientes valores en el orden en que son generados:

Output
1234

El generador externo arrojó los valores 1 y 2, luego delegó en el otro generador con yield*, que devolvió 3 y 4.

yield* también puede delegar en cualquier objeto que sea iterable, como un Array o un Map. La delegación de rendimientos puede ser útil para organizar el código, ya que cualquier función dentro de un generador que quisiera utilizar yield también tendría que ser un generador.

Corrientes de datos infinitos

Uno de los aspectos útiles de los generadores es la capacidad de trabajar con corrientes de datos y colecciones infinitas. Esto se puede demostrar creando un bucle infinito dentro de una función generadora que incremente un número en uno.

En el siguiente bloque de código, definimos esta función generadora y luego iniciamos el generador:

Ahora, itera a través de los valores utilizando next():

// Iterate through the valuescounter.next()counter.next()counter.next()counter.next()

Esto dará la siguiente salida:

Output
{value: 0, done: false}{value: 1, done: false}{value: 2, done: false}{value: 3, done: false}

La función devuelve valores sucesivos en el bucle infinito mientras la propiedad done permanece false, asegurando que no terminará.

Con los generadores, no tienes que preocuparte de crear un bucle infinito, porque puedes detener y reanudar la ejecución a voluntad. Sin embargo, usted todavía tiene que tener cuidado con la forma de invocar el generador. Si utilizas spread o for...of en un flujo de datos infinito, seguirás iterando sobre un bucle infinito a la vez, lo que hará que el entorno se cuelgue.

Para un ejemplo más complejo de un flujo de datos infinito, podemos crear una función generadora de Fibonacci. La secuencia de Fibonacci, que suma continuamente los dos valores anteriores, se puede escribir utilizando un bucle infinito dentro de un generador de la siguiente manera:

Para probar esto, podemos hacer un bucle a través de un número finito e imprimir la secuencia de Fibonacci en la consola.

// Print the first 10 values of fibonacciconst fib = fibonacci()for (let i = 0; i < 10; i++) { console.log(fib.next().value)}

Esto dará lo siguiente:

Output
0112358132134

La capacidad de trabajar con conjuntos de datos infinitos es una parte de lo que hace que los generadores sean tan poderosos. Esto puede ser útil para ejemplos como la implementación de scroll infinito en el frontend de una aplicación web.

Pasando valores en generadores

A lo largo de este artículo, hemos utilizado generadores como iteradores, y hemos producido valores en cada iteración. Además de producir valores, los generadores también pueden consumir valores de next(). En este caso, yield contendrá un valor.

Es importante notar que el primer next() que se llame no pasará un valor, sino que sólo iniciará el generador. Para demostrar esto, podemos registrar el valor de yield y llamar a next() unas cuantas veces con algunos valores.

Esto dará la siguiente salida:

Output
100200{value: "The end", done: true}

También es posible sembrar el generador con un valor inicial. En el siguiente ejemplo, haremos un bucle for y pasaremos cada valor al método next(), pero pasaremos también un argumento a la función inicial:

Recuperaremos el valor de next() y daremos un nuevo valor a la siguiente iteración, que es el valor anterior multiplicado por diez. Esto dará lo siguiente:

Output
010203040

Otra forma de tratar con el arranque de un generador es envolver el generador en una función que siempre llamará a next() una vez antes de hacer cualquier otra cosa.

async/await with Generators

Una función asíncrona es un tipo de función disponible en ES6+ JavaScript que hace que trabajar con datos asíncronos sea más fácil de entender haciendo que parezca síncrono. Los generadores tienen una gama más amplia de capacidades que las funciones asíncronas, pero son capaces de replicar un comportamiento similar. Implementar la programación asíncrona de esta manera puede aumentar la flexibilidad de su código.

En esta sección, demostraremos un ejemplo de reproducción de async/await con generadores.

Construyamos una función asíncrona que utilice la API Fetch para obtener datos de la API JSONPlaceholder (que proporciona datos JSON de ejemplo para fines de prueba) y registre la respuesta en la consola.

Comienza definiendo una función asíncrona llamada getUsers que obtiene datos de la API y devuelve un array de objetos, luego llama a getUsers:

Esto dará datos JSON similares a los siguientes:

Usando generadores, podemos crear algo casi idéntico que no use las palabras clave async/await. En su lugar, utilizará una nueva función que creamos y valores yield en lugar de promesas await.

En el siguiente bloque de código, definimos una función llamada getUsers que utiliza nuestra nueva función asyncAlt (que escribiremos más adelante) para imitar async/await.

Como podemos ver, parece casi idéntica a la implementación de async/await, excepto que se pasa una función generadora que devuelve valores.

Ahora podemos crear una función asyncAlt que se parece a una función asíncrona. asyncAlt tiene como parámetro una función generadora, que es nuestra función que produce las promesas que devuelve fetch. asyncAlt devuelve una función en sí misma, y resuelve cada promesa que encuentra hasta la última:

Esto dará la misma salida que la versión de async/await:

Nótese que esta implementación es para demostrar cómo se pueden usar los generadores en lugar de async/await, y no es un diseño listo para producción. No tiene un manejo de errores configurado, ni tiene la capacidad de pasar parámetros a los valores producidos. Aunque este método puede añadir flexibilidad a su código, a menudo async/await será una mejor opción, ya que abstrae los detalles de implementación y le permite centrarse en escribir código productivo.

Conclusión

Los generadores son procesos que pueden detener y reanudar la ejecución. Son una característica poderosa y versátil de JavaScript, aunque no se utilizan comúnmente. En este tutorial, hemos aprendido sobre las funciones generadoras y los objetos generadores, los métodos disponibles para los generadores, los operadores yield y yield*, y los generadores utilizados con conjuntos de datos finitos e infinitos. También exploramos una forma de implementar código asíncrono sin devoluciones de llamada anidadas o largas cadenas de promesas.

Si quieres aprender más sobre la sintaxis de JavaScript, echa un vistazo a nuestros tutoriales Entender esto, enlazar, llamar y aplicar en JavaScript y Entender los objetos mapa y conjunto en JavaScript.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.