Enrutamiento moderno del cliente: la API de Navigation

Estandariza el enrutamiento del cliente mediante una API completamente nueva que remodela por completo la compilación de aplicaciones de una sola página.

Navegadores compatibles

  • 102
  • 102
  • x
  • x

Origen

Las aplicaciones de una sola página, o SPA, se definen mediante una función central: la reescritura dinámica de su contenido a medida que el usuario interactúa con el sitio, en lugar del método predeterminado de carga de páginas completamente nuevas desde el servidor.

Si bien las SPA han podido ofrecerle esta función a través de la API de History (o, en casos limitados, mediante el ajuste de la parte de #hash del sitio), se trata de una API engorrosa desarrollada mucho antes de que las SPA fueran la norma, y la Web pide un enfoque completamente nuevo. La API de Navigation es una API propuesta que remodela por completo este espacio, en lugar de intentar simplemente aplicar parches a las imperfecciones de la API de History. (Por ejemplo, Restablecimiento de desplazamiento aplicó un parche a la API de History en lugar de intentar reinventarla).

En esta publicación, se describe la API de Navigation en términos generales. Si quieres leer la propuesta técnica, consulta el borrador del informe en el repositorio de WICG.

Ejemplo de uso

Para usar la API de Navigation, primero agrega un objeto de escucha "navigate" en el objeto navigation global. Este evento está centralizado fundamentalmente: se activará para todos los tipos de navegaciones, ya sea que el usuario haya realizado una acción (como hacer clic en un vínculo, enviar un formulario o avanzar y retroceder) o cuando la navegación se active de manera programática (es decir, mediante el código de tu sitio). En la mayoría de los casos, permite que tu código anule el comportamiento predeterminado del navegador para esa acción. En el caso de las SPA, eso significa mantener al usuario en la misma página y cargar o cambiar el contenido del sitio.

Se pasa un NavigateEvent al objeto de escucha "navigate" que contiene información sobre la navegación, como la URL de destino, y te permite responder a la navegación en un lugar centralizado. Un objeto de escucha "navigate" básico podría verse de la siguiente manera:

navigation.addEventListener('navigate', navigateEvent => {
  // Exit early if this navigation shouldn't be intercepted.
  // The properties to look at are discussed later in the article.
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname === '/') {
    navigateEvent.intercept({handler: loadIndexPage});
  } else if (url.pathname === '/cats/') {
    navigateEvent.intercept({handler: loadCatsPage});
  }
});

Puedes controlar la navegación de una de estas dos maneras:

  • Llamar a intercept({ handler }) (como se describió más arriba) para controlar la navegación
  • Llamar a preventDefault(), que puede cancelar la navegación por completo

En este ejemplo, se llama a intercept() en el evento. El navegador llama a tu devolución de llamada handler, que debería configurar el siguiente estado de tu sitio. Esto creará un objeto de transición, navigation.transition, que otro código puede usar para realizar un seguimiento del progreso de la navegación.

Por lo general, se permiten intercept() y preventDefault(), pero hay casos en los que no se los puede llamar. No puedes controlar las navegaciones a través de intercept() si es de origen cruzado. No puedes cancelar una navegación a través de preventDefault() si el usuario presiona los botones Atrás o Adelante en su navegador. Deberías no poder atrapar a los usuarios en tu sitio. (Esto se analiza en GitHub).

Incluso si no puedes detener o interceptar la navegación en sí, se activará el evento "navigate" de todos modos. Es informativo, por lo que tu código podría, por ejemplo, registrar un evento de Analytics para indicar que un usuario está abandonando tu sitio.

¿Por qué agregar otro evento a la plataforma?

Un objeto de escucha de eventos "navigate" centraliza la administración de los cambios de URL dentro de una SPA. Esta es una propuesta difícil cuando se usan APIs más antiguas. Si alguna vez escribiste el enrutamiento para tu propia SPA con la API de History, podrías haber agregado un código como el siguiente:

function updatePage(event) {
  event.preventDefault(); // we're handling this link
  window.history.pushState(null, '', event.target.href);
  // TODO: set up page based on new URL
}
const links = [...document.querySelectorAll('a[href]')];
links.forEach(link => link.addEventListener('click', updatePage));

Esto está bien, pero no es exhaustivo. Los vínculos pueden entrar y salir de tu página, y no son la única forma en que los usuarios pueden navegar por las páginas. p.ej., pueden enviar un formulario o incluso usar un mapa de imágenes. Es posible que tu página se encargue de esto, pero existe una gran cantidad de posibilidades que podrían simplificarse, algo que la nueva API de Navigation logra.

Además, lo anterior no controla la navegación hacia atrás/adelante. Hay otro evento para eso: "popstate".

Personalmente, la API de History a menudo siente que podría ayudar con estas posibilidades. Sin embargo, en realidad solo tiene dos áreas superficiales: responder si el usuario presiona Atrás o Adelante en el navegador, además de enviar y reemplazar URLs. No tiene una analogía con "navigate", excepto si configuras manualmente objetos de escucha para eventos de clic, por ejemplo, como se mostró anteriormente.

Cómo decidir cómo manejar una navegación

navigateEvent contiene mucha información sobre la navegación que puedes usar para decidir cómo administrar una navegación en particular.

Las propiedades clave son las siguientes:

canIntercept
Si esto es falso, no podrás interceptar la navegación. No se pueden interceptar las navegaciones de origen cruzado ni los recorridos entre documentos.
destination.url
Probablemente sea la información más importante que se debe tener en cuenta a la hora de manejar la navegación.
hashChange
Verdadero si la navegación es del mismo documento y el hash es la única parte de la URL que es diferente de la URL actual. En las SPA modernas, el hash debe usarse para vincular a diferentes partes del documento actual. Por lo tanto, si hashChange es verdadero, es probable que no necesites interceptar esta navegación.
downloadRequest
Si esto es así, la navegación se inició mediante un vínculo con un atributo download. En la mayoría de los casos, no es necesario interceptar esto.
formData
Si no es nulo, la navegación será parte de un envío de formulario POST. Asegúrate de tener esto en cuenta cuando manejes la navegación. Si solo deseas controlar las navegaciones GET, evita interceptar navegaciones en las que formData no sea nulo. Consulta el ejemplo sobre cómo manejar los envíos de formularios que se encuentra más adelante en este artículo.
navigationType
Este es uno de los siguientes: "reload", "push", "replace" o "traverse". Si es "traverse", no se puede cancelar esta navegación por medio de preventDefault().

Por ejemplo, la función shouldNotIntercept que se usó en el primer ejemplo podría ser similar a la siguiente:

function shouldNotIntercept(navigationEvent) {
  return (
    !navigationEvent.canIntercept ||
    // If this is just a hashChange,
    // just let the browser handle scrolling to the content.
    navigationEvent.hashChange ||
    // If this is a download,
    // let the browser perform the download.
    navigationEvent.downloadRequest ||
    // If this is a form submission,
    // let that go to the server.
    navigationEvent.formData
  );
}

Interceptación

Cuando tu código llama a intercept({ handler }) desde su objeto de escucha "navigate", le informa al navegador que está preparando la página para el nuevo estado actualizado, y que la navegación puede tardar un poco.

El navegador comienza por capturar la posición de desplazamiento del estado actual, de modo que se pueda restablecer más tarde y, luego, llama a tu devolución de llamada handler. Si tu handler muestra una promesa (que ocurre automáticamente con las async functions), esa promesa le indica al navegador cuánto tiempo tarda la navegación y si es correcta.

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

Como tal, esta API introduce un concepto semántico que el navegador comprende: actualmente se está realizando una navegación SPA, con el tiempo, cambiando el documento de una URL y un estado anteriores a uno nuevo. Esto tiene varios beneficios potenciales, incluida la accesibilidad: los navegadores pueden mostrar el principio, el final o un posible error de una navegación. Por ejemplo, Chrome activa su indicador de carga nativo y permite que el usuario interactúe con el botón de detención. (Por el momento, esto no sucede cuando el usuario navega con los botones Atrás/Adelante, pero se solucionará pronto).

Cuando se interceptan las navegaciones, la URL nueva tendrá efecto justo antes de que se llame a la devolución de llamada handler. Si no actualizas el DOM de inmediato, se crea un punto en el que se muestra el contenido anterior junto con la URL nueva. Esto afecta, por ejemplo, la resolución relativa de la URL cuando se recuperan datos o se cargan subrecursos nuevos.

Se analiza en GitHub una forma de retrasar el cambio de URL, pero, por lo general, se recomienda actualizar la página de inmediato con algún tipo de marcador de posición para el contenido entrante:

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

Esto no solo evita problemas de resolución de URL, sino que también es rápido porque le respondes al usuario al instante.

Anular indicadores

Dado que puedes realizar trabajo asíncrono en un controlador intercept(), es posible que la navegación sea redundante. Esto sucede en los siguientes casos:

  • El usuario hace clic en otro vínculo o algún código realiza otra navegación. En este caso, se abandona la navegación anterior para reemplazarla por la nueva.
  • El usuario hace clic en el botón "detener" del navegador.

Para resolver cualquiera de estas posibilidades, el evento que se pasa al objeto de escucha "navigate" contiene una propiedad signal, que es un AbortSignal. Para obtener más información, consulta Recuperación anulable.

La versión corta es que básicamente proporciona un objeto que activa un evento cuando debes detener tu trabajo. En particular, puedes pasar un AbortSignal a cualquier llamada que hagas a fetch(), lo que cancelará las solicitudes de red en tránsito si se interrumpe la navegación. Esto ahorrará el ancho de banda del usuario y rechazará el Promise que muestra fetch(), lo que evitará que el siguiente código realice acciones, como actualizar el DOM para que muestre una navegación de páginas que ahora no es válida.

Este es el ejemplo anterior, pero con getArticleContent intercalado, en el que se muestra cómo se puede usar AbortSignal con fetch():

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContentURL = new URL(
          '/get-article-content',
          location.href
        );
        articleContentURL.searchParams.set('path', url.pathname);
        const response = await fetch(articleContentURL, {
          signal: navigateEvent.signal,
        });
        const articleContent = await response.json();
        renderArticlePage(articleContent);
      },
    });
  }
});

Control de desplazamiento

Cuando intercept() una navegación, el navegador intentará controlar el desplazamiento automáticamente.

Para las navegaciones a una nueva entrada del historial (cuando navigationEvent.navigationType es "push" o "replace"), esto significa intentar desplazarse a la parte indicada por el fragmento de URL (el bit después de #) o restablecer el desplazamiento a la parte superior de la página.

Para las recargas y los recorridos, esto significa restablecer la posición de desplazamiento a donde estaba la última vez que se mostró esta entrada del historial.

De forma predeterminada, esto sucede una vez que se resuelve la promesa que muestra tu handler. Sin embargo, si tiene sentido desplazarse antes, puedes llamar a navigateEvent.scroll():

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
        navigateEvent.scroll();

        const secondaryContent = await getSecondaryContent(url.pathname);
        addSecondaryContent(secondaryContent);
      },
    });
  }
});

Como alternativa, puedes inhabilitar por completo el control de desplazamiento automático estableciendo la opción scroll de intercept() en "manual":

navigateEvent.intercept({
  scroll: 'manual',
  async handler() {
    // …
  },
});

Control del enfoque

Una vez que se resuelve la promesa que muestra tu handler, el navegador enfoca el primer elemento con el atributo autofocus configurado o el elemento <body> si ningún elemento tiene ese atributo.

Para inhabilitar este comportamiento, establece la opción focusReset de intercept() en "manual":

navigateEvent.intercept({
  focusReset: 'manual',
  async handler() {
    // …
  },
});

Eventos de éxito y falla

Cuando se llame al controlador intercept(), ocurrirá una de estas dos cosas:

  • Si el Promise que se muestra se cumple (o no llamaste a intercept()), la API de Navigation activará "navigatesuccess" con un Event.
  • Si se rechaza la Promise que se muestra, la API activará "navigateerror" con un ErrorEvent.

Estos eventos permiten que tu código enfrente el éxito o el fracaso de una manera centralizada. Por ejemplo, puedes tratar de lograr el éxito ocultando un indicador de progreso que se mostraba previamente de la siguiente manera:

navigation.addEventListener('navigatesuccess', event => {
  loadingIndicator.hidden = true;
});

O puedes mostrar un mensaje de error en caso de error:

navigation.addEventListener('navigateerror', event => {
  loadingIndicator.hidden = true; // also hide indicator
  showMessage(`Failed to load page: ${event.message}`);
});

El objeto de escucha de eventos "navigateerror", que recibe un ErrorEvent, es particularmente útil, ya que garantiza que recibirá cualquier error de tu código cuando se configura una página nueva. Solo tienes que await fetch() sabiendo que, si la red no está disponible, el error se enrutará a "navigateerror".

navigation.currentEntry proporciona acceso a la entrada actual. Este es un objeto que describe dónde se encuentra el usuario en este momento. Esta entrada incluye la URL actual, los metadatos que se pueden usar para identificarla a lo largo del tiempo y el estado proporcionado por el desarrollador.

Los metadatos incluyen key, una propiedad de cadena única de cada entrada que representa la entrada actual y su ranura. Esta clave permanece igual incluso si cambia la URL o el estado de la entrada actual. Sigue en la misma posición. Por el contrario, si un usuario presiona Atrás y luego vuelve a abrir la misma página, key cambiará, ya que esta entrada nueva creará un espacio nuevo.

Para un desarrollador, key es útil porque la API de Navigation te permite dirigir directamente al usuario a una entrada con una clave coincidente. Puedes conservarlo, incluso en los estados de otras entradas, para pasar fácilmente de una página a otra.

// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const {key} = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);

// Navigate away, but the button will always work.
await navigation.navigate('/another_url').finished;

Estado

La API de Navigation muestra una noción de "estado", que es información proporcionada por el desarrollador que se almacena de forma persistente en la entrada del historial actual, pero que el usuario no puede ver directamente. Esto es muy similar a history.state, aunque se mejoró con respecto a, en la API de History.

En la API de Navigation, puedes llamar al método .getState() de la entrada actual (o cualquier entrada) para mostrar una copia de su estado:

console.log(navigation.currentEntry.getState());

De forma predeterminada, será undefined.

Estado del parámetro de configuración

Aunque los objetos de estado se pueden mutar, esos cambios no se vuelven a guardar con la entrada del historial. Por lo tanto, sucede lo siguiente:

const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// But:
console.info(navigation.currentEntry.getState().count); // will still be 1

La forma correcta de establecer el estado es durante la navegación de la secuencia de comandos:

navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});

newState puede ser cualquier objeto clonable.

Si deseas actualizar el estado de la entrada actual, es mejor realizar una navegación que reemplace la entrada actual:

navigation.navigate(location.href, {state: newState, history: 'replace'});

Luego, tu objeto de escucha de eventos "navigate" puede captar este cambio a través de navigateEvent.destination:

navigation.addEventListener('navigate', navigateEvent => {
  console.log(navigateEvent.destination.getState());
});

Actualiza el estado de manera síncrona

Por lo general, es mejor actualizar el estado de forma asíncrona a través de navigation.reload({state: newState}) y, luego, tu objeto de escucha "navigate" puede aplicar ese estado. Sin embargo, a veces, el cambio de estado ya se aplicó por completo cuando tu código lo escucha, por ejemplo, cuando el usuario activa o desactiva un elemento <details> o cambia el estado de la entrada de un formulario. En estos casos, se recomienda actualizar el estado para que estos cambios se conserven a través de recargas y recorridos. Esto es posible con updateCurrentEntry():

navigation.updateCurrentEntry({state: newState});

También organizamos un evento para conocer este cambio:

navigation.addEventListener('currententrychange', () => {
  console.log(navigation.currentEntry.getState());
});

Sin embargo, si descubres que reaccionas a los cambios de estado en "currententrychange", es posible que estés dividiendo o incluso duplicando tu código de administración de estados entre el evento "navigate" y el evento "currententrychange", mientras que navigation.reload({state: newState}) te permitiría controlarlo en un solo lugar.

Parámetros de estado y URL

Debido a que el estado puede ser un objeto estructurado, es tentador usarlo para todos los estados de la aplicación. Sin embargo, en muchos casos, es mejor almacenar ese estado en la URL.

Si esperas que el estado se conserve cuando el usuario comparte la URL con otro usuario, almacénala en la URL. De lo contrario, el objeto de estado es la mejor opción.

Acceder a todas las entradas

Sin embargo, la "entrada actual" no es todo. La API también proporciona una forma de acceder a la lista completa de entradas por las que navegó un usuario mientras usaba tu sitio a través de su llamada a navigation.entries(), que muestra un array de instantáneas de las entradas. Esto se puede usar para, p.ej., mostrar una IU diferente en función de cómo el usuario navegó a una página determinada o solo para mirar las URLs anteriores o sus estados. Esto es imposible con la API de historial actual.

También puedes escuchar un evento "dispose" en NavigationHistoryEntry individuales, que se activa cuando la entrada ya no forma parte del historial del navegador. Esto puede suceder como parte de una limpieza general, pero también durante la navegación. Por ejemplo, si retrocedes 10 lugares y luego navegas hacia adelante, se eliminarán esas 10 entradas del historial.

Ejemplos

El evento "navigate" se activa para todos los tipos de navegación, como se mencionó anteriormente. (De hecho, hay un apéndice largo en la especificación de todos los tipos posibles).

Si bien para muchos sitios el caso más común será cuando el usuario haga clic en un <a href="...">, hay dos tipos de navegación notables y más complejos que vale la pena abarcar.

Navegación programática

El primero es la navegación programática, en la que la navegación se genera a partir de una llamada de método dentro del código del cliente.

Puedes llamar a navigation.navigate('/another_page') desde cualquier parte del código para activar la navegación. Esto lo controlará el objeto de escucha de eventos centralizado registrado en el objeto de escucha "navigate", y se llamará a este de forma síncrona.

Esto está diseñado como una agregación mejorada de métodos más antiguos, como location.assign() y amigos, además de los métodos pushState() y replaceState() de la API de History.

El método navigation.navigate() muestra un objeto que contiene dos instancias de Promise en { committed, finished }. Esto permite que el invocador pueda esperar hasta que la transición sea "confirmada" (la URL visible cambió y haya un nuevo NavigationHistoryEntry disponible) o "finalizada" (todas las promesas que muestra intercept({ handler }) están completas o se rechazan debido a un error o a que otra navegación la interrumpió).

El método navigate también tiene un objeto de opciones, en el que puedes configurar lo siguiente:

  • state: Es el estado de la nueva entrada del historial, como está disponible a través del método .getState() en NavigationHistoryEntry.
  • history: Se puede configurar como "replace" para reemplazar la entrada del historial actual.
  • info: Es un objeto para pasar al evento de navegación a través de navigateEvent.info.

En particular, info podría ser útil para, por ejemplo, denotar una animación en particular que haga que aparezca la página siguiente. La alternativa puede ser establecer una variable global o incluirla como parte del hash. Ambas opciones son un poco incómodas). En particular, este info no se volverá a reproducir si el usuario genera navegación posteriormente, p.ej., a través de los botones Atrás y Adelantar. De hecho, siempre será undefined en esos casos.

Demostración de apertura de izquierda o derecha

navigation también tiene otros métodos de navegación, que muestran un objeto que contiene { committed, finished }. Ya mencioné traverseTo() (que acepta una key que denota una entrada específica en el historial del usuario) y navigate(). También incluye back(), forward() y reload(). Al igual que navigate(), el objeto de escucha de eventos "navigate" centralizado controla estos métodos.

Envíos de formularios

En segundo lugar, el envío de <form> HTML a través de POST es un tipo especial de navegación, y la API de Navigation puede interceptarlo. Si bien incluye una carga útil adicional, el objeto de escucha "navigate" controla la navegación de manera centralizada.

Para detectar el envío de un formulario, busca la propiedad formData en NavigateEvent. A continuación, te mostramos un ejemplo que simplemente convierte cualquier formulario enviado en uno que permanece en la página actual a través de fetch():

navigation.addEventListener('navigate', navigateEvent => {
  if (navigateEvent.formData && navigateEvent.canIntercept) {
    // User submitted a POST form to a same-domain URL
    // (If canIntercept is false, the event is just informative:
    // you can't intercept this request, although you could
    // likely still call .preventDefault() to stop it completely).

    navigateEvent.intercept({
      // Since we don't update the DOM in this navigation,
      // don't allow focus or scrolling to reset:
      focusReset: 'manual',
      scroll: 'manual',
      handler() {
        await fetch(navigateEvent.destination.url, {
          method: 'POST',
          body: navigateEvent.formData,
        });
        // You could navigate again with {history: 'replace'} to change the URL here,
        // which might indicate "done"
      },
    });
  }
});

¿Qué información falta?

A pesar de la naturaleza centralizada del objeto de escucha de eventos "navigate", la especificación actual de la API de Navigation no activa "navigate" en la primera carga de una página. Y en el caso de los sitios que usan la renderización del servidor (SSR) en todos los estados, esto puede estar bien, ya que el servidor podría mostrar el estado inicial correcto, que es la forma más rápida de enviar contenido a los usuarios. Pero los sitios que aprovechan el código del cliente para crear sus páginas podrían necesitar crear una función adicional para inicializar su página.

Otra opción de diseño intencional de la API de Navigation es que funciona solo dentro de un solo marco, es decir, la página de nivel superior o un único <iframe> específico. Esto tiene una serie de implicaciones interesantes que se documentan más en la especificación, pero que, en la práctica, reducirán la confusión de los desarrolladores. La API de History anterior tiene una serie de casos extremos confusos, como la compatibilidad con marcos, y la API de Navigation rediseñada se encarga de estos casos extremos desde el principio.

Por último, aún no hay un consenso respecto de modificar o reorganizar de forma programática la lista de entradas por las que navegó el usuario. Esto está en proceso de debate, pero una opción podría ser permitir solo eliminaciones: ya sea las entradas históricas o "todas las entradas futuras". Esta última permitiría el estado temporal. Por ejemplo, como desarrollador, podría hacer lo siguiente:

  • Haz una pregunta al usuario navegando a una nueva URL o estado
  • permitir que el usuario complete su trabajo (o regresar)
  • Quitar una entrada del historial cuando se completa una tarea

Esto podría ser perfecto para ventanas modales temporales o intersticiales: la nueva URL es algo desde lo que un usuario puede usar el gesto de retroceso, pero no puede avanzar accidentalmente para volver a abrirla (porque se quitó la entrada). Esto simplemente no es posible con la API de historial actual.

Prueba la API de Navigation

La API de Navigation está disponible en Chrome 102 sin marcas. También puedes probar una demostración de Domenic Denicola.

Si bien la versión clásica de la API de History parece sencilla, no está muy bien definida y presenta una gran cantidad de problemas en casos excepcionales y cómo se implementó de manera diferente en los navegadores. Esperamos que consideres proporcionar comentarios sobre la nueva API de Navigation.

Referencias

Agradecimientos

Gracias a Thomas Steiner, Domenic Denicola y Nate Chapin por revisar esta publicación. Hero image de Unsplash, de Jeremy Zero.