Tutorial

Información sobre generadores en JavaScript

DevelopmentJavaScript

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

Introducción

En ECMAScript 2015, se introdujeron generadores en el lenguaje de JavaScript. Un generador es un proceso que puede pausarse, reanudarse y producir varios valores. Un generador en JavaScript consta de una función generadora que muestra un objeto iterable Generator.

Los generadores pueden mantener el estado, y proporcionar con ello una forma eficiente de crear iteradores, y encargarse de flujos de datos infinitos que se pueden emplear para implementar desplazamiento infinito en una página en el frontend de una aplicación web, para operar con datos de ondas de sonido, y más. Además, cuando se usan con promesas, los generadores pueden imitar la funcionalidad de async/await, lo que nos permite abordar el código asíncrono de una manera más sencilla y legible. Aunque async/await es una alternativa más frecuente para abordar los casos de uso asíncrono más comunes y sencillos, como la obtención de datos de una API, en los generadores se incluyen funciones más avanzadas que hacen que valga la pena aprender a usarlos.

En este artículo, veremos la forma de crear funciones generadoras e iterar objetos Generator, la diferencia entre yield y return dentro de un generador y otros aspectos vinculados al trabajo con generadores.

Funciones generadoras

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

// Generator function declaration
function* generatorFunction() {}

De vez en cuando, verá el asterisco junto al nombre de la función, a diferencia de la palabra clave de la función; por ejemplo, function *generatorFunction(). Esto funciona igual, aunque la de function* es una sintaxis más aceptada.

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

// Generator function expression
const generatorFunction = function*() {}

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

// Generator as the method of an object
const generatorObj = {
  *generatorMethod() {},
}

// Generator as the method of a class
class GeneratorClass {
  *generatorMethod() {}
}

En los ejemplos de este artículo se usará la sintaxis de declaración de las funciones generadoras.

Nota: A diferencia de las funciones regulares, los generadores no se pueden construir a partir de la palabra clave new ni pueden ser utilizarse junto con las funciones de flecha.

Ahora que sabe declarar funciones generadoras, veremos los objetos Generator iterables que estas muestran.

Objetos Generator

Tradicionalmente, las funciones en JavaScript se ejecutan hasta completarse y la invocación de una muestra un valor cuando se alcanza la palabra clave return. Si se omite la palabra clave return, en una función se mostrará implícitamente undefined.

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

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

Al invocar la función se muestra un valor que es la suma de los argumentos:

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

Sin embargo, una función generadora no muestra un valor de inmediato y como alternativa se muestra un objeto Generator iterable. En el siguiente ejemplo, se declara una función y se le asigna un valor de devolución único, como en una función estándar:

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

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

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

Si esta fuera una función regular, esperaríamos que generator nos proporcionara la cadena mostrada en la función. Sin embargo, lo que realmente obtenemos es un objeto en estado suspended. Al invocar generator, por lo tanto, obtendremos un resultado similar al siguiente:

Output
generatorFunction {<suspended>} __proto__: Generator [[GeneratorLocation]]: VM272:1 [[GeneratorStatus]]: "suspended" [[GeneratorFunction]]: ƒ* generatorFunction() [[GeneratorReceiver]]: Window [[Scopes]]: Scopes[3]

El objeto Generator mostrado por la función es un iterador. Un iterador es un objeto que tiene un método next() disponible, el cual se utiliza para iterar una secuencia de valores. El método next() muestra un objeto con propiedades value y done. value representa el valor mostrado y done indica si el iterador recorrió o no todos sus valores.

Conociendo esto, invocaremos next() en nuestro generator y obtendremos el valor y el estado actual del iterador:

// Call the next method on the Generator object
generator.next()

Esto generará el siguiente resultado:

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

El valor mostrado después de invocar next() es Hello, Generator!, y el estado de done es true, ya que este valor provino de un return que cerró el iterador. Debido a que la operación del iterador se completó, el estado de la función generadora pasará de suspended a closed. Invocar generator de nuevo dará el siguiente resultado:

Output
generatorFunction {<closed>}

Hasta ahora, solo hemos demostrado que una función generadora puede ser una alternativa más compleja para 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, aprenderá sobre el operador yield y veremos cómo se puede pausar y reanudar la ejecución de un generador.

Operadores yield

Con los generadores se introdujo una nueva palabra clave en JavaScript: yield. Con yield se puede pausar una función generadora y mostrar el valor que le sigue a yield, y así proporcionar una opción ligera para iterar valores.

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

// Create a generator function with multiple yields
function* generatorFunction() {
  yield 'Neo'
  yield 'Morpheus'
  yield 'Trinity'

  return 'The Oracle'
}

const generator = generatorFunction()

Ahora, cuando invoquemos next() en la función generadora, se pausará cada vez que encuentre yield. Se fijará el valor false para done después de cada yield, lo cual indicará que el generador no ha terminado. Una vez que encuentre un return, o ya no se encuentren más yield en la función, done pasará a tener el valor true y el generador habrá terminado.

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

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

Estos darán las siguientes cuatro líneas de resultados en orden:

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

Tenga en cuenta que un generador no requiere un return; si se omite, la última iteración mostrará {value: undefined, done: true}, al igual que cualquier invocación posterior de next() después de que un generador haya finalizado.

Iterar un generador

Usando el método next(), iteramos manualmente el objeto Generator y recibimos todas las propiedades de 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 iterarse con for...of:

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

Con esto, se mostrará lo siguiente:

Output
Neo Morpheus Trinity

El operador de propagación también puede usarse para asignar los valores de un Generator en una matriz.

// Create an array from the values of a Generator object
const values = [...generator]

console.log(values)

Esto proporcionará la siguiente matriz:

Output
(3) ["Neo", "Morpheus", "Trinity"]

Tanto con la propagación como con for...of, no se tomará en cuenta return en los valores (en este caso, habría sido 'The Oracle').

Nota: Si bien ambos métodos son eficaces para trabajar con generadores finitos, si un generador se encarga de un flujo de datos infinito, no será posible usar directamente la propagación ni for...of sin crear un bucle infinito.

Cerrar un generador

Como vimos, en el caso de un generador se puede fijar el valor true para la propiedad done y el valor closed para su estado mediante la iteración de todos sus valores. Existen dos alternativas adicionales para cancelar inmediatamente un generador: el método return() y el método throw().

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

Para demostrar return(), se creará 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 proporcionará 'Neo', con el valor false fijado para done. Si invocamos un método return() en el objeto Generator justo después de esto, obtendremos el valor pasado y se cambiará done a true. Cualquier invocación adicional de next() nos proporcionará la respuesta del generador completada predeterminada 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 proporcionará los tres resultados siguientes:

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

El método return() forzó al objeto Generator a completarse e ignorar cualquier otra palabra clave de yield. Esto es particularmente útil en la programación asíncrona cuando es necesario hacer que las funciones puedan cancelarse; por ejemplo, mediante la interrupción de una solicitud web cuando un usuario quiere realizar una acción diferente, ya que no es posible cancelar directamente una promesa.

Si en el cuerpo de una función generadora se incluye una alternativa para detectar y manejar los errores, puede usar el método throw() para generar un error en el generador. Con esto se inicia el generador, se genera un error y se finaliza el generador.

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

// Define a generator function with a try...catch
function* generatorFunction() {
  try {
    yield 'Neo'
    yield 'Morpheus'
  } catch (error) {
    console.log(error)
  }
}

// Invoke the generator and throw an error
const generator = generatorFunction()

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

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

Esto generará el siguiente resultado:

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

Mediante throw(), se introdujo en el generador un error detectado por try...catch y registrado en la consola.

Métodos y estados del objeto generador

En la siguiente tabla, se muestra una lista de métodos que pueden utilizarse en los objetos Generator:

Método Descripción
next() Muestra el valor siguiente en un generador.
return() Muestra un valor en un generador y finaliza el generador.
throw() Genera un error y finaliza el generador.

En la siguiente tabla, se enumeran los posibles estados de un objeto Generator:

Estado Descripción
suspended La ejecución del generador se detuvo, pero el proceso de este no finalizó.
closed El proceso del generador finalizó porque se produjo un error, un retorno o una iteración de todos los valores.

Delegación de yield

Además del operador regular yield, los generadores también pueden usar la expresión yield* para delegar más valores a otros generadores. Cuando se encuentre yield* dentro de un generador, se ubicará dentro del generador delegado y empezarán a iterarse todos los yield hasta que se cierre ese generador. Esto se puede utilizar para separar diferentes funciones generadoras con el fin de organizar su código de forma semántica y, al mismo tiempo, iterar todos los yield en el orden correcto.

Para demostrar esto, podemos crear dos funciones generadoras, una de las cuales operará mediante yield* en la otra:

// Generator function that will be delegated to
function* delegate() {
  yield 3
  yield 4
}

// Outer generator function
function* begin() {
  yield 1
  yield 2
  yield* delegate()
}

A continuación, iteraremos la función generadora begin():

// Iterate through the outer generator
const generator = begin()

for (const value of generator) {
  console.log(value)
}

Esto dará los siguientes valores en el orden en que se generan:

Output
1 2 3 4

El generador externo produjo los valores 1 y 2, y luego se delegó al otro generador con yield*, que mostró 3 y 4.

yield* también puede hacer delegaciones a cualquier objeto que sea iterable, como una matriz o un mapa. La delegación de Yield puede ser útil para organizar código, ya que cualquier función de un generador que intente usar yield tendría que ser también un generador.

Flujos de datos infinitos

Uno de los aspectos útiles de los generadores es la capacidad de trabajar con flujos de datos infinitos y grupos. Esto puede demostrarse creando un bucle infinito dentro de una función generadora que incremente un número en una unidad.

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

// Define a generator function that increments by one
function* incrementer() {
  let i = 0

  while (true) {
    yield i++
  }
}

// Initiate the generator
const counter = incrementer()

Ahora, itere los valores usando next():

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

Esto generará el siguiente resultado:

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

En la función se muestran los valores sucesivos en el bucle infinito mientras que el valor de la propiedad done se mantiene en false, lo cual garantiza que no finalizará.

Con los generadores, no tiene que preocuparse por crear un bucle infinito, porque puede detener y reanudar la ejecución cuando lo desee. Sin embargo, de todos modos debe tener cuidado con la forma en la que invoca el generador. Si utiliza la propagación o for...of en un flujo de datos infinito, seguirá iterando un bucle infinito todo a la vez, lo cual hará que el entorno se bloquee.

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, puede escribirse usando un bucle infinito en un generador, como se muestra a continuación:

// Create a fibonacci generator function
function* fibonacci() {
  let prev = 0
  let next = 1

  yield prev
  yield next

  // Add previous and next values and yield them forever
  while (true) {
    const newVal = next + prev

    yield newVal

    prev = next
    next = newVal
  }
}

Para probar esto, podemos usar un bucle un número finito de veces e imprimir la secuencia de Fibonacci en la consola.

// Print the first 10 values of fibonacci
const fib = fibonacci()

for (let i = 0; i < 10; i++) {
  console.log(fib.next().value)
}

Esto dará el siguiente resultado:

Output
0 1 1 2 3 5 8 13 21 34

La capacidad de trabajar con conjuntos de datos infinitos es una de las razones por la cuales los generadores son tan poderosos. Esto puede ser útil para ejemplos como el caso de la implementación de desplazamiento infinito en el frontend de una aplicación web.

Pasar los valores en los generadores

A lo largo de este artículo, usamos generadores como iteradores y produjimos 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 observar que con el primer next() que se invoqué no se pasará un valor, sino que solo se iniciará el generador. Para demostrar esto, podemos registrar el valor de yield e invocar next() unas cuantas veces con algunos valores.

function* generatorFunction() {
  console.log(yield)
  console.log(yield)

  return 'The end'
}

const generator = generatorFunction()

generator.next()
generator.next(100)
generator.next(200)

Esto generará el siguiente resultado:

Output
100 200 {value: "The end", done: true}

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

function* generatorFunction(value) {
  while (true) {
    value = yield value * 10
  }
}

// Initiate a generator and seed it with an initial value
const generator = generatorFunction(0)

for (let i = 0; i < 5; i++) {
  console.log(generator.next(i).value)
}

Recuperaremos el valor de next() y produciremos un nuevo valor para la siguiente iteración, que es igual a diez veces el valor anterior. Esto dará el siguiente resultado:

Output
0 10 20 30 40

Otra forma de abordar la iniciación de un generador es ajustarlo en una función que siempre invocará next() una vez antes de hacer cualquier otra acción.

async y await con generadores

Una función asíncrona es un tipo de función disponible en ES6+ de JavaScript que facilita el trabajo con datos asíncronos al hacer que parezcan síncronos. Los generadores tienen una matriz de capacidades más amplia que las funciones asíncronas, pero son capaces de replicar un comportamiento similar. Implementar de esta manera una programación asíncrona puede aumentar la flexibilidad de su código.

En esta sección, mostraremos un ejemplo de la reproducción de async y await con generadores.

Crearemos una función asíncrona que utiliza la API de Fetch para obtener datos de la API JSONPlaceholder (que proporciona datos JSON de ejemplo para pruebas) y registrar la respuesta en la consola.

Comience definiendo una función asíncrona llamada getUsers, que obtiene datos de la API y muestra una matriz de objetos, y luego invoque getUsers:

const getUsers = async function() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users')
  const json = await response.json()

  return json
}

// Call the getUsers function and log the response
getUsers().then(response => console.log(response))

Esto proporcionará datos JSON similares a los siguientes:

Output
[ {id: 1, name: "Leanne Graham" ...}, {id: 2, name: "Ervin Howell" ...}, {id: 3, name": "Clementine Bauch" ...}, {id: 4, name: "Patricia Lebsack"...}, {id: 5, name: "Chelsey Dietrich"...}, ...]

Usando generadores, podemos crear algo casi idéntico que no utilice las palabras claves async y await. En su lugar, se usarán una nueva función que creamos y los valores yield en vez de las 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 a async y await.

const getUsers = asyncAlt(function*() {
  const response = yield fetch('https://jsonplaceholder.typicode.com/users')
  const json = yield response.json()

  return json
})

// Invoking the function
getUsers().then(response => console.log(response))

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

Ahora podemos crear una función asyncAlt que se asemeje a una función asíncrona. asyncAlt cuenta con una función generadora como un parámetro, que es nuestra función productora de promesas que muestra fetch. asyncAlt muestra una función por sí misma y resuelve cada promesa que encuentra hasta la última:

// Define a function named asyncAlt that takes a generator function as an argument
function asyncAlt(generatorFunction) {
  // Return a function
  return function() {
    // Create and assign the generator object
    const generator = generatorFunction()

    // Define a function that accepts the next iteration of the generator
    function resolve(next) {
      // If the generator is closed and there are no more values to yield,
      // resolve the last value
      if (next.done) {
        return Promise.resolve(next.value)
      }

      // If there are still values to yield, they are promises and
      // must be resolved.
      return Promise.resolve(next.value).then(response => {
        return resolve(generator.next(response))
      })
    }

    // Begin resolving promises
    return resolve(generator.next())
  }
}

Esto dará el mismo resultado que la versión de async y await:

Output
[ {id: 1, name: "Leanne Graham" ...}, {id: 2, name: "Ervin Howell" ...}, {id: 3, name": "Clementine Bauch" ...}, {id: 4, name: "Patricia Lebsack"...}, {id: 5, name: "Chelsey Dietrich"...}, ...]

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

Conclusión

Los generadores son procesos que cuya ejecución puede detenerse y reanudarse. Son una potente y versátil característica de JavaScript, aunque no se utilizan comúnmente. En este tutorial, aprendió acerca de 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 manera de implementar código asíncrono sin devoluciones de llamada anidadas ni largas cadenas de promesas.

Si desea aprender más sobre la sintaxis de JavaScript, consulte nuestros tutoriales Información sobre This, Bind, Call y Apply en JavaScript e Información sobre los objetos Map y Set en JavaScript.

Creative Commons License