Carga instantánea de apps web con una arquitectura de shell de aplicación

Un shell de aplicación es el HTML, CSS y JavaScript mínimos que potencian una interfaz de usuario. El shell de la aplicación debe cumplir con los siguientes requisitos:

  • carga rápida
  • se almacenará en caché
  • mostrar contenido de forma dinámica

Un shell de aplicación es el secreto para obtener un buen rendimiento de manera confiable. Piensa en el shell de tu app como el paquete de código que publicarías en una tienda de aplicaciones si compilaras una app nativa. Es la carga necesaria para comenzar, pero es posible que no sea toda la historia. Mantiene tu IU local y extrae contenido de forma dinámica a través de una API.

Separación de la shell de la app del shell de HTML, JS y CSS, y el contenido HTML

Segundo plano

En el artículo Apps web progresivas de Alex Russell, se describe cómo una app web puede cambiar de forma progresiva a través del uso y el consentimiento del usuario para proporcionar una experiencia más similar a una app nativa, completa con compatibilidad sin conexión, notificaciones push y la capacidad de agregarse a la pantalla principal. Depende en gran medida de los beneficios de funcionalidad y rendimiento del trabajador de servicio y sus capacidades de almacenamiento en caché. Esto te permite enfocarte en la velocidad, lo que les brinda a tus apps web la misma carga instantánea y las actualizaciones periódicas que sueles ver en las aplicaciones nativas.

Para aprovechar al máximo estas capacidades, necesitamos una nueva forma de pensar en los sitios web: la arquitectura de shell de la aplicación.

Analicemos cómo estructurar tu app con una arquitectura de shell de aplicación con service worker. Analizaremos la renderización del cliente y del servidor, y compartiremos una muestra de extremo a extremo que puedes probar hoy mismo.

Para enfatizar el punto, en el siguiente ejemplo, se muestra la primera carga de una app que usa esta arquitectura. Observa el mensaje emergente "La app está lista para usar sin conexión" en la parte inferior de la pantalla. Si más adelante hay una actualización del shell disponible, podemos informarle al usuario que actualice a la nueva versión.

Imagen del trabajador de servicio que se ejecuta en DevTools para el shell de la aplicación

¿Qué son los trabajadores del servicio?

Un service worker es una secuencia de comandos que se ejecuta en segundo plano, independiente de tu página web. Responde a eventos, incluidas las solicitudes de red que se realizan desde las páginas que entrega y los avisos enviados desde tu servidor. Un trabajador de servicio tiene una vida útil intencionalmente corta. Se activa cuando recibe un evento y se ejecuta solo mientras lo necesita para procesarlo.

Los trabajadores de servicio también tienen un conjunto limitado de APIs en comparación con JavaScript en un contexto de navegación normal. Esto es estándar para los trabajadores en la Web. Un trabajador de servicio no puede acceder al DOM, pero puede acceder a elementos como la API de caché y realizar solicitudes de red con la API de recuperación. La API de IndexedDB y postMessage() también están disponibles para usar en la persistencia de datos y el envío de mensajes entre el trabajador de servicio y las páginas que controla. Los eventos push que se envían desde tu servidor pueden invocar la API de notificaciones para aumentar la participación de los usuarios.

Un trabajador de servicio puede interceptar las solicitudes de red que se realizan desde una página (lo que activa un evento de recuperación en el trabajador de servicio) y mostrar una respuesta recuperada de la red, de una caché local o incluso construida de forma programática. En realidad, es un proxy programable en el navegador. Lo mejor es que, independientemente de dónde provenga la respuesta, la página web parece que no tiene participación de un service worker.

Para obtener más información sobre los trabajadores del servicio en profundidad, lee la Introducción a los trabajadores del servicio.

Beneficios de rendimiento

Los trabajadores del servicio son eficaces para la caché sin conexión, pero también ofrecen mejoras significativas en el rendimiento en forma de carga instantánea para las visitas repetidas a tu sitio o aplicación web. Puedes almacenar en caché la shell de tu aplicación para que funcione sin conexión y propagar su contenido con JavaScript.

En las visitas repetidas, esto te permite obtener píxeles significativos en la pantalla sin la red, incluso si tu contenido proviene de allí. Piensa en ello como mostrar barras de herramientas y tarjetas de inmediato y, luego, cargar el resto del contenido de forma progresiva.

Para probar esta arquitectura en dispositivos reales, ejecutamos nuestro ejemplo de shell de la aplicación en WebPageTest.org y mostramos los resultados a continuación.

Prueba 1: Prueba con cable con un Nexus 5 con Chrome Dev

La primera vista de la app debe recuperar todos los recursos de la red y no logra una pintura significativa hasta 1.2 segundos después. Gracias a la caché del trabajador del servicio, nuestra visita repetida logra una pintura significativa y termina de cargarse por completo en 0.5 segundos.

Diagrama de pintura de prueba de página web para conexión de cables

Prueba 2: Prueba en 3G con un Nexus 5 con Chrome Dev

También podemos probar nuestro ejemplo con una conexión 3G un poco más lenta. Esta vez, la primera pintura significativa tarda 2.5 segundos en la primera visita. La página tarda 7.1 segundos en cargarse por completo. Con el almacenamiento en caché del trabajador de servicio, nuestra visita repetida logra una pintura significativa y termina de cargarse por completo en 0.8 segundos.

Diagrama de pintura de prueba de la página web para conexión 3G

Otras vistas cuentan una historia similar. Compara los 3 segundos que se necesitan para lograr la primera pintura significativa en el shell de la aplicación:

Cronograma de renderización para la primera vista de la prueba de página web

a los 0.9 segundos que tarda cuando se carga la misma página desde nuestra caché de service worker. Nuestros usuarios finales ahorran más de 2 segundos.

Cronograma de pintura para la vista repetida de la prueba de página web

Se pueden obtener mejoras de rendimiento similares y confiables para tus propias aplicaciones con la arquitectura de shell de la aplicación.

¿El trabajador de servicio requiere que replanteemos la forma en que estructuramos las apps?

Los service workers implican algunos cambios sutiles en la arquitectura de la aplicación. En lugar de comprimir toda tu aplicación en una cadena HTML, puede ser beneficioso hacer las cosas al estilo de AJAX. Aquí es donde tienes un shell (que siempre se almacena en caché y puede iniciarse sin la red) y contenido que se actualiza con frecuencia y se administra por separado.

Las implicaciones de esta división son importantes. En la primera visita, puedes renderizar contenido en el servidor y, luego, instalar el service worker en el cliente. En las visitas posteriores, solo debes solicitar datos.

¿Qué sucede con la mejora progresiva?

Si bien, en la actualidad, no todos los navegadores admiten el trabajador de servicio, la arquitectura del shell de contenido de la aplicación usa la mejora progresiva para garantizar que todos puedan acceder al contenido. Por ejemplo, nuestro proyecto de ejemplo.

A continuación, puedes ver la versión completa renderizada en Chrome, Firefox Nightly y Safari. A la izquierda, puedes ver la versión de Safari en la que el contenido se renderiza en el servidor sin un trabajador de servicio. A la derecha, vemos las versiones nocturnas de Chrome y Firefox potenciadas por el trabajador de servicio.

Imagen de la shell de la aplicación cargada en Safari, Chrome y Firefox

¿Cuándo tiene sentido usar esta arquitectura?

La arquitectura de shell de la aplicación es más adecuada para las apps y los sitios dinámicos. Si tu sitio es pequeño y estático, es probable que no necesites un shell de la aplicación y que puedas almacenar en caché todo el sitio en un paso oninstall de service worker. Usa el enfoque que tenga más sentido para tu proyecto. Varios frameworks de JavaScript ya fomentan la división de la lógica de la aplicación del contenido, lo que hace que este patrón sea más sencillo de aplicar.

¿Ya hay apps de producción que usen este patrón?

La arquitectura del shell de la aplicación es posible con solo unos pocos cambios en la IU general de la aplicación y ha funcionado bien en sitios a gran escala, como la app web progresiva de I/O 2015 y Recibidos de Google.

Imagen de la carga de Recibidos de Google. Se ilustra Recibidos con un service worker.

Los shells de aplicaciones sin conexión son una gran ventaja de rendimiento y también se demuestran bien en la app sin conexión de Wikipedia de Jake Archibald y en la app web progresiva de Flipkart Lite.

Capturas de pantalla de la demostración de Wikipedia de Jake Archibald.

Explicación de la arquitectura

Durante la primera experiencia de carga, tu objetivo es mostrar contenido significativo en la pantalla del usuario lo más rápido posible.

Primera carga y carga de otras páginas

Diagrama de la primera carga con el shell de la app

En general, la arquitectura del shell de la aplicación hará lo siguiente:

  • Prioriza la carga inicial, pero permite que el service worker almacenen en caché el shell de la aplicación para que las visitas repetidas no requieran que se vuelva a recuperar el shell de la red.

  • Carga diferida o en segundo plano de todo lo demás. Una buena opción es usar el almacenamiento en caché de lectura para el contenido dinámico.

  • Usa herramientas de trabajador de servicio, como sw-precache, por ejemplo, para almacenar en caché y actualizar de forma confiable el trabajador de servicio que administra tu contenido estático. (Más adelante, hablaremos sobre sw-precache).

Para lograrlo, sigue estos pasos:

  • El servidor enviará contenido HTML que el cliente podrá renderizar y usará encabezados de vencimiento de caché HTTP a largo plazo para tener en cuenta los navegadores sin compatibilidad con el trabajador de servicio. Servirá nombres de archivos con valores hash para habilitar el "control de versiones" y las actualizaciones fáciles más adelante en el ciclo de vida de la aplicación.

  • Páginas incluirán estilos de CSS intercalados en una etiqueta <style> dentro del documento <head> para proporcionar una primera pintura rápida de la carcasa de la aplicación. Cada página cargará de forma asíncrona el código JavaScript necesario para la vista actual. Dado que el CSS no se puede cargar de forma asíncrona, podemos solicitar estilos con JavaScript, ya que ES asíncrono en lugar de ser síncrono y dirigido por un analizador. También podemos aprovechar requestAnimationFrame() para evitar casos en los que podamos obtener un acierto de caché rápido y terminar con estilos que se conviertan accidentalmente en parte de la ruta de renderización crítica. requestAnimationFrame() fuerza que se pinte el primer fotograma antes de que se carguen los estilos. Otra opción es usar proyectos como loadCSS de Filament Group para solicitar CSS de forma asíncrona con JavaScript.

  • El trabajador de servicio almacenará una entrada almacenada en caché del shell de la aplicación para que, en las visitas repetidas, el shell se pueda cargar por completo desde la caché del trabajador de servicio, a menos que haya una actualización disponible en la red.

Shell de la app para contenido

Una implementación práctica

Escribimos un ejemplo que funciona completamente con la arquitectura de shell de la aplicación, JavaScript ES2015 sin modificaciones para el cliente y Express.js para el servidor. Por supuesto, nada te impide usar tu propia pila para las partes del cliente o del servidor (p. ej., PHP, Ruby o Python).

Ciclo de vida del trabajador de servicio

Para nuestro proyecto de shell de la aplicación, usamos sw-precache, que ofrece el siguiente ciclo de vida del trabajador de servicio:

Evento Acción
Instalar Almacena en caché el shell de la aplicación y otros recursos de la app de una sola página.
Activar Borra las cachés antiguas.
Recuperar Publica una app web de una sola página para las URLs y usa la caché para los recursos y los parciales predefinidos. Usa la red para otras solicitudes.

Bits del servidor

En esta arquitectura, un componente del servidor (en nuestro caso, escrito en Express) debería poder tratar el contenido y la presentación por separado. El contenido se puede agregar a un diseño HTML que genera una renderización estática de la página, o bien se puede publicar por separado y cargarse de forma dinámica.

Es comprensible que tu configuración del servidor pueda diferir mucho de la que usamos para nuestra app de demostración. La mayoría de las configuraciones de servidores pueden lograr este patrón de apps web, aunque requiere una nueva arquitectura. Descubrimos que el siguiente modelo funciona bastante bien:

Diagrama de la arquitectura de shell de la app
  • Los extremos se definen para tres partes de tu aplicación: las URLs para el usuario (índice o comodín), la shell de la aplicación (trabajador de servicio) y tus parciales HTML.

  • Cada extremo tiene un controlador que extrae un diseño de manillar que, a su vez, puede extraer vistas y partes del manillar. En pocas palabras, los parciales son vistas que son fragmentos de HTML que se copian en la página final. Nota: Los frameworks de JavaScript que realizan una sincronización de datos más avanzada suelen ser mucho más fáciles de portar a una arquitectura de Shell de la aplicación. Suelen usar la vinculación de datos y la sincronización en lugar de los parciales.

  • Inicialmente, se le entrega al usuario una página estática con contenido. Esta página registra un service worker, si es compatible, que almacena en caché la shell de la aplicación y todo lo que depende de ella (CSS, JS, etc.).

  • Luego, la carcasa de la app actuará como una app web de una sola página, que usará JavaScript para XHR en el contenido de una URL específica. Las llamadas XHR se realizan a un extremo /partials* que muestra el pequeño fragmento de HTML, CSS y JS necesario para mostrar ese contenido. Nota: Hay muchas formas de abordar esto, y XHR es solo una de ellas. Algunas aplicaciones intercalarán sus datos (tal vez con JSON) para la renderización inicial y, por lo tanto, no son "estáticos" en el sentido de HTML aplanado.

  • Los navegadores sin compatibilidad con el trabajador de servicio siempre deben recibir una experiencia de resguardo. En nuestra demostración, recurrimos a la renderización estática básica del servidor, pero esta es solo una de las muchas opciones. El aspecto del trabajador de servicio te brinda nuevas oportunidades para mejorar el rendimiento de tu app de estilo de aplicación de una sola página con el shell de la aplicación almacenado en caché.

Control de versiones de archivos

Una pregunta que surge es cómo controlar la versión y la actualización de los archivos. Esto es específico de la aplicación y las opciones son las siguientes:

  • Usa primero la red y, de lo contrario, usa la versión almacenada en caché.

  • Solo en red y falla si no hay conexión.

  • Almacena en caché la versión anterior y actualízala más tarde.

Para el shell de la aplicación, se debe adoptar un enfoque de almacenamiento en caché en primer lugar para la configuración de tu service worker. Si no almacenas en caché el shell de la aplicación, significa que no adoptaste correctamente la arquitectura.

Herramientas

Mantenemos varias bibliotecas de ayuda de trabajadores del servicio que facilitan la configuración del proceso de almacenamiento en caché previo de la shell de tu aplicación o el manejo de patrones de almacenamiento en caché comunes.

Captura de pantalla del sitio de la biblioteca de Service Worker en Web Fundamentals

Usa sw-precache para el shell de tu aplicación

El uso de sw-precache para almacenar en caché el shell de la aplicación debería controlar las inquietudes relacionadas con las revisiones de archivos, las preguntas de instalación o activación y la situación de recuperación del shell de la app. Coloca sw-precache en el proceso de compilación de tu aplicación y usa comodines configurables para recuperar tus recursos estáticos. En lugar de crear tu secuencia de comandos de trabajador del servicio de forma manual, permite que sw-precache genere una que administre tu caché de forma segura y eficiente con un controlador de recuperación que prioriza la caché.

Las visitas iniciales a tu app activan la carga previa del conjunto completo de recursos necesarios. Esto es similar a la experiencia de instalar una app nativa desde una tienda de aplicaciones. Cuando los usuarios regresan a tu app, solo se descargan los recursos actualizados. En nuestra demostración, les informamos a los usuarios cuando hay un nuevo shell disponible con el mensaje "Actualizaciones de la app. Actualizar para obtener la versión nueva". Este patrón es una forma sencilla de informarles a los usuarios que pueden actualizar a la versión más reciente.

Usa sw-toolbox para el almacenamiento en caché del entorno de ejecución

Usa sw-toolbox para la caché del entorno de ejecución con diferentes estrategias según el recurso:

  • cacheFirst para las imágenes, junto con una caché nombrada dedicada que tenga una política de vencimiento personalizada de N maxEntries

  • networkFirst o la más rápida para las solicitudes a la API, según la actualización del contenido deseada. Es posible que la opción más rápida sea adecuada, pero si hay un feed de API específico que se actualiza con frecuencia, usa networkFirst.

Conclusión

Las arquitecturas de shell de aplicaciones tienen varios beneficios, pero solo tienen sentido para algunas clases de aplicaciones. El modelo aún es nuevo y valdrá la pena evaluar el esfuerzo y los beneficios generales de rendimiento de esta arquitectura.

En nuestros experimentos, aprovechamos el uso compartido de plantillas entre el cliente y el servidor para minimizar el trabajo de compilación de dos capas de aplicación. Esto garantiza que la mejora progresiva siga siendo una función principal.

Si ya estás considerando usar service workers en tu app, observa la arquitectura y evalúa si tiene sentido para tus propios proyectos.

Agradecemos a nuestros revisores: Jeff Posnick, Paul Lewis, Alex Russell, Seth Thompson, Rob Dodson, Taylor Savage y Joe Medley.