Técnicas para que una app web se cargue rápido, incluso en teléfonos de gama media

Cómo usamos la división de código, la incorporación de código y la renderización del servidor en PROXX.

En Google I/O 2019, Mariko, Jake y yo enviamos PROXX, una versión moderna del Buscaminas clonada para la Web. Una característica que distingue a PROXX es el enfoque en la accesibilidad (¡se puede reproducir con un lector de pantalla) y la capacidad de ejecutarse tan bien en un teléfono de gama media como en un dispositivo de escritorio de alta gama. Los teléfonos de gama media están restringidos de varias maneras:

  • CPUs débiles
  • GPU débiles o inexistentes
  • Pantallas pequeñas sin entrada táctil
  • Cantidad de memoria muy limitada

Pero ejecutan un navegador moderno y son muy asequibles. Por esta razón, los teléfonos de gama media están resurgiendo en los mercados emergentes. Su precio máximo permite que un público totalmente nuevo, que antes no podía costearlo, se conecte a Internet y use la Web moderna. Se proyecta que en 2019 se venderán alrededor de 400 millones de teléfonos de gama media solo en la India, por lo que los usuarios de teléfonos de gama media podrían convertirse en una parte importante de tu público. Además, las velocidades de conexión similares a las de 2G son la norma en los mercados emergentes. ¿Cómo logramos que PROXX funcionara bien en condiciones de teléfonos de gama media?

Modo de juego de PROXX.

El rendimiento es importante, y eso incluye tanto el rendimiento de carga como el del entorno de ejecución. Está demostrado que un buen rendimiento se correlaciona con un aumento en la retención de usuarios, la mejora de las conversiones y, lo que es más importante, una mayor inclusión. Jeremy Wagner tiene muchos más datos y estadísticas sobre por qué el rendimiento es importante.

Esta es la primera parte de una serie de dos. La parte 1 se centra en el rendimiento de carga y la parte 2 se centra en el rendimiento del entorno de ejecución.

Captura el statu quo

Probar el rendimiento de carga en un dispositivo real es fundamental. Si no tienes un dispositivo real a mano, te recomendamos WebPageTest, específicamente la configuración "sencilla". WPT ejecuta una batería de pruebas de carga en un dispositivo real con una conexión 3G emulada.

3G es una buena velocidad de medición. Si bien es posible que estés acostumbrado a la conectividad 4G, LTE o, incluso, 5G, la realidad de Internet móvil es bastante diferente. Tal vez estés en un tren, en una conferencia, en un concierto o en un vuelo. Lo que experimentarás allí probablemente esté más cerca de la red 3G y, en ocasiones, peor.

Dicho esto, en este artículo nos enfocaremos en la tecnología 2G porque PROXX apunta explícitamente a los teléfonos de gama media y los mercados emergentes en su público objetivo. Una vez que WebPageTest haya ejecutado la prueba, obtendrás una cascada (similar a la que ves en Herramientas para desarrolladores) y una tira de película en la parte superior. La tira de película muestra lo que el usuario ve mientras se carga la app. En 2G, la experiencia de carga de la versión no optimizada de PROXX es bastante mala:

En la tira de película, se muestra lo que ve el usuario cuando PROXX se está cargando en un dispositivo real de gama baja a través de una conexión 2G emulada.

Cuando se carga a través de 3G, el usuario ve 4 segundos de nada en blanco. Cuando se conecta a 2G, el usuario no ve absolutamente nada durante más de 8 segundos. Si lee por qué el rendimiento es importante, sabrás que perdimos una buena parte de nuestros usuarios potenciales debido a la impaciencia. El usuario debe descargar todos los 62 KB de JavaScript para que aparezca algo en la pantalla. El aspecto positivo de esta situación es que lo segundo que aparece en la pantalla también es interactivo. ¿O no?

La [Primera pintura significativa][FMP] en la versión no optimizada de PROXX es _Technically_ [interactive][TTI], pero inútil para el usuario.

Después de que se hayan descargado aproximadamente 62 KB de JS en gzip y se haya generado el DOM, el usuario podrá ver nuestra app. La app es técnicamente interactiva. Sin embargo, mirar el elemento visual muestra una realidad diferente. Las fuentes web aún se están cargando en segundo plano y, hasta que estén listas, el usuario no podrá ver texto. Si bien este estado califica como una Primera pintura significativa (FMP), seguramente no califica como correctamente interactiva, ya que el usuario no puede saber de qué se trata ninguna de las entradas. Espera otro segundo con 3G y 3 segundos en 2G hasta que la app está lista. En resumen, la app tarda 6 segundos en 3G y 11 segundos en 2G para ser interactiva.

Análisis de Waterfall

Ahora que sabemos lo que ve el usuario, debemos averiguar el por qué. Para ello, podemos observar la cascada y analizar por qué los recursos se cargan demasiado tarde. En nuestro seguimiento 2G para PROXX, podemos ver dos señales de alerta importantes:

  1. Hay varias líneas delgadas multicolores.
  2. Los archivos JavaScript forman una cadena. Por ejemplo, el segundo recurso solo comienza a cargarse una vez que el primero está terminado y el tercer recurso solo comienza cuando termina el segundo.
La cascada proporciona estadísticas sobre qué recursos se cargan, cuándo y cuánto tardan.

Reduce el recuento de conexiones

Cada línea delgada (dns, connect, ssl) representa la creación de una conexión HTTP nueva. Configurar una nueva conexión es costoso, ya que tarda alrededor de 1 s en 3G y aproximadamente 2.5 s en 2G. En nuestra cascada, vemos una nueva conexión para:

  • Solicitud n.° 1: Nuestro index.html
  • Solicitud n° 5: Los estilos de fuente de fonts.googleapis.com
  • Solicitud n° 8: Google Analytics
  • Solicitud n° 9: Un archivo de fuentes de fonts.gstatic.com
  • Solicitud n° 14: El manifiesto de la app web

No se puede evitar la nueva conexión de index.html. El navegador tiene que crear una conexión a nuestro servidor para obtener el contenido. La nueva conexión de Google Analytics se puede evitar con la intercalación de Análisis mínimo, pero Google Analytics no impide que nuestra aplicación se renderice ni se vuelva interactiva, por lo que realmente no nos importa la velocidad de carga. Idealmente, Google Analytics debería cargarse en tiempo de inactividad, cuando todo lo demás ya está cargado. De esa manera, no consumirá ancho de banda ni potencia de procesamiento durante la carga inicial. La nueva conexión para el manifiesto de la app web se prescribe según la especificación de recuperación, ya que el manifiesto debe cargarse mediante una conexión sin credenciales. Una vez más, el manifiesto de la aplicación web no impide que la app se renderice ni se vuelva interactiva, por lo que no tenemos que preocuparnos tanto.

Sin embargo, las dos fuentes y sus estilos son un problema, ya que bloquean la representación y también la interactividad. Si observamos el CSS que entrega fonts.googleapis.com, solo hay dos reglas @font-face, una para cada fuente. Los estilos de fuente son tan pequeños que, de hecho, decidimos intercalarlos en nuestro HTML y quitar una conexión innecesaria. Para evitar el costo de configuración de la conexión para los archivos de fuente, podemos copiarlos en nuestro propio servidor.

Paraleliza cargas

Si observamos la cascada, podemos ver que una vez que se termina de cargar el primer archivo JavaScript, los nuevos archivos comienzan a cargarse de inmediato. Esto es típico para las dependencias de módulos. Es probable que nuestro módulo principal tenga importaciones estáticas, por lo que el JavaScript no se puede ejecutar hasta que se carguen esas importaciones. Es importante tener en cuenta que este tipo de dependencias se conocen durante el tiempo de compilación. Podemos usar etiquetas <link rel="preload"> para asegurarnos de que todas las dependencias comiencen a cargarse apenas recibamos nuestro HTML.

Resultados

Veamos los logros de nuestros cambios. Es importante no cambiar ninguna otra variable de nuestra configuración de prueba que pueda sesgar los resultados, por lo que usaremos la configuración simple de WebPageTest para el resto de este artículo y observaremos la tira de película:

Usamos la tira de película de WebPageTest para ver los resultados de nuestros cambios.

Estos cambios redujeron nuestro TTI de 11 a 8.5, que es aproximadamente los 2.5 s del tiempo de configuración de la conexión que teníamos como objetivo quitar. ¡Bien hecho!

Renderización previa

Si bien solo redujimos la TTI, en realidad no afectamos a la pantalla blanca eternamente larga que debe soportar el usuario durante 8.5 segundos. Podría decirse que las mayores mejoras de FMP se pueden lograr si envías lenguaje de marcado con estilo en tu index.html. Las técnicas comunes para lograr esto son la renderización previa y la renderización del servidor, que se explican en estrecha relación con la sección Renderización en la Web. Ambas técnicas ejecutan la app web en Node y serializan el DOM resultante a HTML. La renderización del servidor lo hace por solicitud del lado del servidor, mientras que la renderización previa lo hace durante el tiempo de compilación y almacena el resultado como tu index.html nuevo. Dado que PROXX es una aplicación de JAMStack y no tiene un servidor, decidimos implementar la renderización previa.

Existen muchas formas de implementar una renderización previa. En PROXX, decidimos usar Puppeteer, que inicia Chrome sin ninguna IU y te permite controlar de forma remota esa instancia con una API de Node. Usamos esto para insertar nuestro lenguaje de marcado y JavaScript, y luego leemos el DOM como una cadena de HTML. Debido a que usamos módulos de CSS, obtenemos la incorporación de CSS a los estilos que necesitamos de forma gratuita.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Con esta implementación, esperamos una mejora para nuestra FMP. Aún debemos cargar y ejecutar la misma cantidad de JavaScript que antes, por lo que no debemos esperar que el TTI cambie demasiado. En todo caso, nuestra index.html creció y podría retrasar un poco nuestro TTI. Solo hay una forma de averiguarlo: mediante WebPageTest.

La tira de película muestra una mejora clara en nuestra métrica de FMP. La TTI no se ve afectada en gran medida.

Nuestra Primera pintura significativa pasó de 8.5 a 4.9 segundos, una mejora significativa. Nuestro TTI todavía ocurre alrededor de los 8.5 segundos, por lo que este cambio no se ve afectado en gran medida. Lo que hicimos aquí fue un cambio perceptual. Algunos incluso lo llaman un prestigio. Cuando se renderiza una imagen intermedia del juego, cambiamos el rendimiento de carga percibido para mejor.

En alineación

Otra métrica que brindan Herramientas para desarrolladores y WebPageTest es el tiempo hasta el primer byte (TTFB). Este es el tiempo que transcurre desde el primer byte de la solicitud que se envía hasta el primer byte de la respuesta que se recibe. Este tiempo también se denomina tiempo de ida y vuelta (RTT), aunque técnicamente existe una diferencia entre estos dos números: RTT no incluye el tiempo de procesamiento de la solicitud del servidor. DevTools y WebPageTest visualizan el TTFB con un color claro dentro del bloque de solicitud/respuesta.

La sección clara de una solicitud significa que la solicitud está esperando recibir el primer byte de la respuesta.

Si observas la cascada, podemos ver que todas las solicitudes pasan la mayor parte de su tiempo esperando a que llegue el primer byte de la respuesta.

Este problema era para qué se concibió originalmente el empuje HTTP/2. El desarrollador de la app sabe que se necesitan ciertos recursos y puede impulsarlos. Para cuando el cliente se da cuenta de que necesita recuperar recursos adicionales, ya se encuentran en las cachés del navegador. El envío de HTTP/2 resultó ser demasiado difícil de realizar correctamente, por lo que no se recomienda. Este espacio problemático se revisará durante la estandarización de HTTP/3. Por ahora, la solución más fácil es intercalar todos los recursos críticos a expensas de la eficiencia del almacenamiento en caché.

Nuestro CSS fundamental ya está insertado gracias a los módulos de CSS y a nuestro renderizador previo basado en Puppeteer. Para JavaScript, debemos intercalar nuestros módulos fundamentales y sus dependencias. Esta tarea tiene una dificultad diferente, según el agrupador que uses.

Con la incorporación de JavaScript, redujimos el TTI de 8.5 s a 7.2 s.

Esto redujo 1 segundo nuestro TTI. Llegamos al punto en que nuestro elemento index.html contiene todo lo necesario para la renderización inicial y la posibilidad de ser interactivo. El HTML puede renderizarse mientras se descarga, lo que crea nuestra FMP. En el momento en que el HTML termina de analizarse y ejecutarse, la app se vuelve interactiva.

División agresiva de código

Sí, nuestro index.html contiene todo lo necesario para ser interactivo. Pero, al mirarla más de cerca, resulta que también contiene todo lo demás. Nuestro index.html es de alrededor de 43 KB. Pongamos eso en relación con lo que el usuario puede interactuar al comienzo: tenemos un formulario para configurar el juego que contiene un par de componentes, un botón de inicio y probablemente algo de código para conservar y cargar la configuración del usuario. Eso es casi todo. 43 KB parece mucho.

La página de destino de PROXX. Aquí solo se usan componentes críticos.

Para comprender de dónde proviene el tamaño de nuestro paquete, podemos usar un explorador de mapas de fuentes o una herramienta similar para desglosar en qué consiste el paquete. Como se predijo, nuestro paquete contiene la lógica del juego, el motor de renderización, la pantalla de victoria, la pantalla de pérdida y varias utilidades. Solo se necesita un pequeño subconjunto de esos módulos para la página de destino. Si se traslada todo lo que no es estrictamente necesario para la interactividad a un módulo de carga diferida, el TTI se reducirá de manera significativa.

El análisis del contenido del `index.html` de PROXX muestra muchos recursos innecesarios. Se destacan los recursos críticos.

Lo que debemos hacer es división del código. La división de código divide tu paquete monolítico en partes más pequeñas que se pueden cargar de forma diferida a pedido. Los agrupadores populares como Webpack, Rollup y Parcel admiten la división de código mediante import() dinámico. El agrupador analizará tu código e intercala todos los módulos que se importan de forma estática. Todo lo que importes de forma dinámica se colocará en su propio archivo y solo se recuperará de la red una vez que se ejecute la llamada a import(). Por supuesto, acceder a la red tiene un costo y solo debería hacerse si tienes tiempo de sobra. El lema es importar de forma estática los módulos que son fundamentales necesarios en el tiempo de carga y cargar de forma dinámica todo lo demás. Pero no debes esperar hasta el último momento para hacer una carga diferida de módulos que, sin dudas, se usarán. Idle Until Urgent de Phil Walton es un excelente patrón para establecer un punto medio saludable entre la carga diferida y la carga urgente.

En PROXX, creamos un archivo lazy.js que importa de forma estática todo lo que no necesitamos. En nuestro archivo principal, podemos importar lazy.js de forma dinámica. Sin embargo, algunos de nuestros componentes de Preact terminaron en lazy.js, lo que resultó ser una complicación, ya que Preact no puede controlar componentes que se cargan de forma diferida. Por este motivo, escribimos un pequeño wrapper de componente deferred que nos permite renderizar un marcador de posición hasta que se cargue el componente real.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Con esto implementado, podemos usar una promesa de un componente en nuestras funciones render(). Por ejemplo, el componente <Nebula>, que renderiza la imagen de fondo animada, se reemplazará por un <div> vacío mientras se carga el componente. Una vez que el componente esté cargado y listo para usar, se reemplazará <div> por el componente real.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Con todo esto implementado, redujimos nuestro index.html a tan solo 20 KB, menos de la mitad del tamaño original. ¿Qué efecto tiene esto en FMP y TTI? WebPageTest lo dirá.

La tira de película confirma que nuestro TTI está ahora en 5.4 s. Es una mejora drástica respecto de los años 11 originales.

Nuestras FMP y TTI están a solo 100 ms de distancia, ya que solo se trata de analizar y ejecutar el JavaScript integrado. Con solo 5.4 s en 2G, la app es completamente interactiva. Todos los demás módulos menos esenciales se cargan en segundo plano.

Más prestigio

Si observas la lista de módulos fundamentales que aparece más arriba, verás que el motor de renderización no es parte de los módulos críticos. Por supuesto, el juego no puede iniciarse hasta que tengamos nuestro motor de renderización para renderizarlo. Podríamos inhabilitar el botón "Start" hasta que el motor de renderización esté listo para iniciar el juego. Sin embargo, en nuestra experiencia, el usuario suele tardar el tiempo suficiente en ajustar la configuración del juego para que esto no sea necesario. La mayoría de las veces, el motor de renderización y los otros módulos restantes terminan de cargarse cuando el usuario presiona "Iniciar". En el caso poco probable de que el usuario sea más rápido que su conexión de red, mostramos una pantalla de carga simple que espera a que finalicen los módulos restantes.

Conclusión

Medir es importante. Para evitar dedicar tiempo a problemas que no son reales, te recomendamos que siempre realices mediciones primero antes de implementar optimizaciones. Además, las mediciones se deben realizar en dispositivos reales con una conexión 3G o en WebPageTest si no hay un dispositivo real a mano.

La tira de película puede proporcionar estadísticas sobre cómo la carga de la app se siente para el usuario. La cascada puede indicarte qué recursos son responsables de los tiempos de carga potencialmente largos. A continuación, se incluye una lista de tareas que puedes realizar para mejorar el rendimiento de carga:

  • Envía la mayor cantidad posible de recursos a través de una conexión.
  • Precarga los recursos intercalados que se necesiten para la primera renderización y la primera interactividad.
  • Realiza un procesamiento previo de tu app para mejorar el rendimiento de carga percibido.
  • Usa la división de código agresiva para reducir la cantidad de código necesaria para la interactividad.

No te pierdas la segunda parte, en la que analizamos cómo optimizar el rendimiento del tiempo de ejecución en dispositivos hiperrestringidos.