Enrutamiento moderno del cliente: la API de Navigation

Estandarización del enrutamiento del cliente mediante una API 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 (SPA) se definen a través de una función principal: reescribir dinámicamente su contenido a medida que el usuario interactúa con el sitio, en lugar del método predeterminado de cargar páginas completamente nuevas desde el servidor.

Si bien las SPAs pudieron ofrecer esta función a través de la API de History (o, en casos limitados, ajustando la parte del #hash del sitio), se trata de una API tosca desarrollada mucho antes de que las SPA fueran la norma, y la Web está solicitando 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 irregularidades de la API de History. (Por ejemplo, Scroll Restoration aplicó un parche a la API de History en lugar de intentar reinventarla).

En esta publicación, se describe la API de Navigation de manera general. Si deseas 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. En esencia, este evento está centralizado: se activará para todo tipo de navegaciones, ya sea que el usuario realice una acción (como hacer clic en un vínculo, enviar un formulario o ir y avanzar) o cuando la navegación se active de manera programática (es decir, a través del 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, es probable que esto signifique mantener al usuario en la misma página y cargar o cambiar el contenido del sitio.

Se pasa un objeto 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 administrar la navegación de dos maneras:

  • Llamando a intercept({ handler }) (como se describió anteriormente) para controlar la navegación
  • Llamando a preventDefault(), que puede cancelar la navegación por completo

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

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

Incluso si no puedes detener ni interceptar la navegación, se activará el evento "navigate". Es informativa, 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 el control de los cambios de URL dentro de una SPA. Esta es una propuesta difícil si se usan APIs más antiguas. Si alguna vez escribiste la ruta para tu propia SPA con la API de History, es posible que hayas agregado un código como este:

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 ir y volverse en 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. Tu página podría abordar estas cuestiones, pero hay una larga lista de posibilidades que simplemente podría 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".

En lo personal, la API de History siente que podría ayudar con estas posibilidades. Sin embargo, solo tiene dos áreas de superficie: responder si el usuario presiona Atrás o Avanzar en su navegador, además de enviar y reemplazar URLs. No tiene una analogía con "navigate", excepto si configuras de forma manual objetos de escucha para eventos de clic, como se demostró más arriba.

Decidir cómo manejar una navegación

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

Las propiedades clave son las siguientes:

canIntercept
Si esto es falso, no puedes interceptar la navegación. Las navegaciones de origen cruzado y los recorridos entre documentos no se pueden interceptar.
destination.url
Probablemente sea la información más importante que debes tener en cuenta cuando manipules 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 ser para vincular diferentes partes del documento actual. Por lo tanto, si hashChange es verdadero, es probable que no necesites interceptar esta navegación.
downloadRequest
Si es así, un vínculo con un atributo download inició la navegación. En la mayoría de los casos, no necesitas interceptarla.
formData
Si no es nulo, esta navegación forma parte del envío de un formulario POST. Asegúrate de tener esto en cuenta cuando manipules la navegación. Si solo deseas controlar las navegaciones de GET, evita interceptar navegaciones en las que formData no sea nulo. Consulta el ejemplo sobre cómo manejar los envíos de formularios más adelante en el artículo.
navigationType
Este es uno de "reload", "push", "replace" o "traverse". Si aparece en "traverse", no se puede cancelar esta navegación a través de preventDefault().

Por ejemplo, la función shouldNotIntercept que se usa 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", se informa al navegador que ahora se está preparando la página para el estado nuevo y actualizado, y que la navegación puede demorar un poco.

El navegador comienza capturando la posición de desplazamiento del estado actual, de manera que se puede restablecer opcionalmente más tarde y, luego, llama a tu devolución de llamada handler. Si tu handler muestra una promesa (lo cual sucede automáticamente con async functions), esa promesa le indica al navegador cuánto tiempo tarda la navegación y si se realizó correctamente.

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);
      },
    });
  }
});

Por lo tanto, esta API presenta un concepto semántico que el navegador comprende: se está produciendo una navegación SPA, con el tiempo, que cambia el documento de una URL y un estado anteriores a uno nuevo. Esto tiene una serie de posibles beneficios, incluida la accesibilidad: los navegadores pueden mostrar el principio, el final o una posible falla de una navegación. Chrome, por ejemplo, activa su indicador de carga nativo y permite que el usuario interactúe con el botón de detención. (Actualmente, esto no sucede cuando el usuario navega con los botones atrás/adelante, pero eso se corregirá pronto).

Cuando se intercepten navegaciones, la nueva URL se aplica justo antes de que se llame a la devolución de llamada a handler. Si no actualizas el DOM de inmediato, se crea un período en el que se muestra el contenido antiguo junto con la URL nueva. Esto afecta aspectos como la resolución de URL relativa cuando se recuperan datos o se cargan nuevos subrecursos.

Se debatirá en GitHub una forma de retrasar el cambio de URL, pero, en 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 los problemas de resolución de URL, sino que también se siente rápido, ya que respondes al usuario de forma instantánea.

Anular indicadores

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

  • El usuario hace clic en otro vínculo o un código realiza otra navegación. En este caso, se abandona la navegación anterior y se reemplaza por la nueva.
  • El usuario hace clic en el botón "Detener" en el navegador.

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

La versión corta es básicamente un objeto que activa un evento cuando debes detener tu trabajo. En particular, puedes pasar un AbortSignal a cualquier llamada que realices 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 evita que el siguiente código genere acciones como actualizar el DOM para mostrar una navegación de página que ya no es válida.

Este es el ejemplo anterior, pero con getArticleContent intercalado, que 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.

En el caso de las navegaciones a una nueva entrada del historial (cuando navigationEvent.navigationType es "push" o "replace"), significa que debes intentar desplazarte a la parte indicada por el fragmento de la URL (la parte que le sigue al #) 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 mostró tu handler, pero si tiene sentido desplazarte 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 la administración de desplazamiento automático configurando la opción scroll de intercept() en "manual":

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

Control de enfoque

Una vez que se resuelva la promesa que mostró tu handler, el navegador enfocará el primer elemento con el atributo autofocus establecido, o bien en el elemento <body> si ningún elemento tiene ese atributo.

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

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

Eventos de éxito y fracaso

Cuando se llame a tu controlador intercept(), ocurrirá una de las siguientes dos situaciones:

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

Estos eventos permiten que tu código aborde los casos de éxito o fracaso de forma centralizada. Por ejemplo, podrías lograr el éxito ocultando un indicador de progreso que se mostró anteriormente, como el siguiente:

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

También puedes mostrar un mensaje de error en caso de falla:

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 se garantiza que recibirá cualquier error del código que configura una página nueva. Simplemente puedes await fetch() con la certeza de 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 identificar esta entrada en el tiempo y el estado proporcionado por el desarrollador.

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

Para un desarrollador, key resulta útil porque la API de Navigation te permite dirigir al usuario directamente a una entrada con una clave coincidente. Puedes conservarla, 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 actual del historial, pero que no es visible directamente para el usuario. Es muy similar a history.state en la API de History, aunque se mejoró.

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 guardan con la entrada del historial, por lo tanto:

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 manera correcta de establecer el estado es durante la navegación con la secuencia de comandos:

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

En el ejemplo anterior, newState puede ser cualquier objeto clonable.

Si deseas actualizar el estado de la entrada actual, te recomendamos 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" podrá captar este cambio a través de navigateEvent.destination:

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

Actualiza el estado de forma síncrona

En general, es mejor actualizar el estado de forma asíncrona a través de navigation.reload({state: newState}); luego, tu objeto de escucha "navigate" podrá aplicar ese estado. Sin embargo, a veces, el cambio de estado ya se aplicó por completo cuando tu código se entera, por ejemplo, cuando el usuario activa o desactiva un elemento <details> o cambia el estado de una entrada de formulario. En estos casos, te recomendamos 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 hay un evento para escuchar sobre este cambio:

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

Sin embargo, si observas que reaccionas a los cambios de estado en "currententrychange", es posible que estés dividiendo o incluso duplicando tu código de control del estado 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 frente a URL

Como el estado puede ser un objeto estructurado, resulta tentador usarlo para todo el estado de la aplicación. Sin embargo, en muchos casos, es mejor almacenar ese estado en la URL.

Si esperas que se conserve el estado cuando el usuario comparta 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 entradas de resumen. Esto se podría usar, p.ej., para mostrar una IU diferente en función de cómo el usuario navegó a una página determinada, o solo para revisar las URLs anteriores o sus estados. Esto es imposible con la API de History actual.

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

Ejemplos

El evento "navigate" se activa para todos los tipos de navegación, como se mencionó anteriormente. (en realidad, 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 hace clic en un <a href="...">, hay dos tipos de navegación notables y más complejos que vale la pena cubrir.

Navegación programática

La primera es la navegación programática, en la que una llamada de método dentro del código del cliente causa la navegación.

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

Esto se diseñó como una agregación mejorada de métodos anteriores, 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 se “confirme” la transición (la URL visible haya cambiado y haya un nuevo NavigationHistoryEntry disponible) o “se complete” (todas las promesas que muestra intercept({ handler }) estén completas o se rechacen debido a un error o a que se interrumpió otra navegación).

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, según esté disponible a través del método .getState() en NavigationHistoryEntry.
  • history: Se puede establecer en "replace" para reemplazar la entrada actual del historial.
  • info: Es un objeto que se pasará al evento de navegación por medio de navigateEvent.info.

En particular, info podría ser útil, por ejemplo, para indicar una animación en particular que hace 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 un usuario luego inicia la navegación, p.ej., mediante los botones Atrás y Avanzar. De hecho, siempre será undefined en esos casos.

Demostración de apertura desde la izquierda o la derecha

navigation también tiene otros métodos de navegación, que muestran un objeto que contiene { committed, finished }. Ya mencioné traverseTo() (que acepta un 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 centralizado "navigate" controla todos estos métodos.

Envíos de formularios

En segundo lugar, el envío de HTML <form> 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" sigue controlando la navegación de forma centralizada.

Para detectar el envío de formularios, busca la propiedad formData en NavigateEvent. A continuación, se muestra un ejemplo en el que simplemente se convierte cualquier envío de formulario 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. En el caso de los sitios que usan la renderización del servidor (SSR) en todos los estados, esta opción no es conveniente, ya que tu servidor podría mostrar el estado inicial correcto, que es la forma más rápida de hacer llegar contenido a tus usuarios. Sin embargo, es posible que los sitios que aprovechan el código del cliente para crear sus páginas deban crear una función adicional para inicializar su página.

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

Por último, aún no hay un consenso sobre la modificación o el reordenamiento programática de la lista de entradas por las que el usuario navegó. Esto se está debatiendo actualmente, pero una opción podría ser permitir solo las eliminaciones: entradas históricas o “todas las entradas futuras”. Esta última admitiría el estado temporal. P.ej., como desarrollador, podría hacer lo siguiente:

  • Navega a una nueva URL o estado para hacer una pregunta al usuario.
  • permitir que el usuario complete su trabajo (o ir atrás)
  • Quitar una entrada del historial al completar una tarea

Esto puede ser perfecto para modales temporales o intersticiales: la URL nueva es algo que el usuario puede usar con el gesto atrás para salir, pero no puede avanzar accidentalmente para volver a abrirla (porque se quitó la entrada). Esto no es posible con la API de History actual.

Prueba la API de Navigation

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

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

Referencias

Agradecimientos

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