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, un limpiaminas versión web moderna. Algo que distingue a PROXX es el enfoque en la accesibilidad (puedes jugarlo con un lector de pantalla) y la capacidad de ejecutarse tan bien en un teléfono con funciones básicas como en un dispositivo de escritorio de alta gama. Los teléfonos de gama media tienen limitaciones de varias maneras:
- CPU débiles
- GPUs débiles o inexistentes
- Pantallas pequeñas sin entrada táctil
- Cantidades muy limitadas de memoria
Sin embargo, ejecutan un navegador moderno y son muy asequibles. Por esta razón, los teléfonos básicos están resurgiendo en los mercados emergentes. Su precio permite que un público totalmente nuevo, que antes no podía costearlo, se conecte y use la Web moderna. En 2019, se proyecta que se venderán alrededor de 400 millones de teléfonos de gama media solo en la India, por lo que los usuarios de estos teléfonos podrían convertirse en una parte importante de tu público. Además, las velocidades de conexión similares a las 2G son la norma en los mercados emergentes. ¿Cómo logramos que PROXX funcione bien en condiciones de teléfonos básicos?
El rendimiento es importante, y eso incluye el rendimiento de carga y el rendimiento del entorno de ejecución. Se demostró que un buen rendimiento se correlaciona con una mayor retención de usuarios, mejores conversiones y, lo 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 parte 1 de una serie de dos partes. La parte 1 se enfoca en el rendimiento de carga y la parte 2 se enfocará en el rendimiento del entorno de ejecución.
Captura el statu quo
Es fundamental probar el rendimiento de carga en un dispositivo real. Si no tienes un dispositivo real a mano, te recomiendo WebPageTest, específicamente la configuración "simple". WPT ejecuta una serie de pruebas de carga en un dispositivo real con una conexión 3G emulada.
3G es una buena velocidad para medir. Si bien es posible que estés acostumbrado a la conexión 4G, LTE o, pronto, incluso 5G, la realidad de la Internet móvil es bastante diferente. Tal vez estés en un tren, en una conferencia, en un concierto o en un vuelo. Es probable que la experiencia que tengas allí sea más cercana a la de 3G y, a veces, incluso peor.
Dicho esto, nos vamos a enfocar en 2G en este artículo porque PROXX se orienta explícitamente a teléfonos de gama media y mercados emergentes en su público objetivo. Una vez que WebPageTest haya ejecutado su prueba, se obtiene 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:
Cuando se carga en 3G, el usuario ve 4 segundos de nada en blanco. En 2G, el usuario no ve absolutamente nada durante más de 8 segundos. Si leíste por qué el rendimiento es importante, sabes que ahora perdimos una buena parte de nuestros usuarios potenciales debido a la impaciencia. El usuario debe descargar los 62 KB de JavaScript para que aparezca algo en la pantalla. El aspecto positivo de este escenario es que apenas aparece algo en la pantalla, también es interactivo. ¿O no?
Después de descargar alrededor de 62 KB de JS comprimidos con gzip y generar el DOM, el usuario puede ver nuestra app. La app es técnicamente interactiva. Sin embargo, observar 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 ningún texto. Si bien este estado califica como una primera pintura significativa (FMP), seguramente no califica como interactivo, ya que el usuario no puede saber de qué se trata ninguna de las entradas. Tarda otro segundo en 3G y 3 segundos en 2G hasta que la app está lista para funcionar. En total, la app tarda 6 segundos en 3G y 11 segundos en 2G en volverse interactiva.
Análisis en cascada
Ahora que sabemos qué 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 registro de 2G para PROXX, podemos ver dos indicadores importantes:
- Hay varias líneas finas multicolores.
- Los archivos JavaScript forman una cadena. Por ejemplo, el segundo recurso solo comienza a cargarse una vez que se termina el primero, y el tercero solo comienza cuando se termina el segundo.
Cómo reducir el recuento de conexiones
Cada línea delgada (dns
, connect
, ssl
) representa la creación de una nueva conexión HTTP. 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 lo siguiente:
- 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 fuente de
fonts.gstatic.com
- Solicitud n° 14: El manifiesto de la app web
No se puede evitar la nueva conexión para index.html
. El navegador debe crear una conexión con nuestro servidor para obtener el contenido. Se podría evitar la nueva conexión de Google Analytics si se incorpora algo como Minimal Analytics, pero Google Analytics no impide que nuestra app se renderice o se vuelva interactiva, por lo que no nos importa la velocidad de carga. Lo ideal es que Google Analytics se cargue durante el tiempo de inactividad, cuando ya se cargó todo lo demás. De esta manera, no ocupará ancho de banda ni potencia de procesamiento durante la carga inicial. La especificación de recuperación prescribe la nueva conexión para el manifiesto de la app web, ya que el manifiesto se debe cargar a través de 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 nos preocupa tanto.
No obstante, 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 se trata de dos reglas @font-face
, una para cada fuente. De hecho, los estilos de la fuente son tan pequeños que decidimos incorporarlos en nuestro código HTML y quitar una conexión innecesaria. Para evitar el costo de configurar la conexión de 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 archivos nuevos comienzan a cargarse de inmediato. Esto es habitual en las dependencias de módulos. Es probable que nuestro módulo principal tenga importaciones estáticas, por lo que JavaScript no se puede ejecutar hasta que se carguen esas importaciones. Es importante tener en cuenta que este tipo de dependencias se conocen en el momento de la compilación. Podemos usar etiquetas <link rel="preload">
para asegurarnos de que todas las dependencias comiencen a cargarse en el momento en que recibimos nuestro HTML.
Resultados
Observemos lo que lograron con nuestros cambios. Es importante no cambiar ninguna otra variable en la 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:
Estos cambios redujeron nuestro TTI de 11 a 8.5, que es aproximadamente los 2.5 segundos de tiempo de configuración de conexión que queríamos quitar. Bien hecho.
Renderización previa
Si bien acabamos de reducir nuestro TTI, no afectamos la pantalla blanca eterna que el usuario debe soportar durante 8.5 segundos. Se podría decir que las mayores mejoras para la FMP se pueden lograr enviando un marcado con diseño en tu index.html
. Las técnicas comunes para lograr esto son la renderización previa y la renderización del servidor, que están estrechamente relacionadas y se explican en 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 en el servidor, mientras que la renderización previa lo hace en el tiempo de compilación y almacena el resultado como tu nuevo index.html
. Dado que PROXX es una app de JAMStack y no tiene un servidor, decidimos implementar la renderización previa.
Existen muchas formas de implementar un renderizador previo. En PROXX, elegimos usar Puppeteer, que inicia Chrome sin ninguna IU y te permite controlar de forma remota esa instancia con una API de Node. Lo usamos para insertar nuestro lenguaje de marcado y JavaScript, y luego volver a leer el DOM como una cadena de HTML. Como usamos módulos de CSS, obtenemos la incorporación de CSS de 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 esto implementado, podemos esperar una mejora en nuestro FMP. Aún debemos cargar y ejecutar la misma cantidad de JavaScript que antes, por lo que no deberíamos esperar que el TTI cambie mucho. En todo caso, nuestro index.html
se hizo más grande y podría retrasar un poco nuestro TTI. Solo hay una manera de averiguarlo: ejecutar WebPageTest.
Nuestro primer procesamiento de imagen significativa pasó de 8.5 segundos a 4.9 segundos, una mejora enorme. Nuestro TTI sigue ocurriendo en alrededor de 8.5 segundos, por lo que no se vio afectado en gran medida por este cambio. Lo que hicimos aquí fue un cambio perceptivo. Algunos incluso lo llamarían un truco de manos. Al renderizar una imagen intermedia del juego, mejoraremos el rendimiento de carga percibido.
Inclusión
Otra métrica que nos proporcionan DevTools y WebPageTest es el tiempo hasta el primer byte (TTFB). Es el tiempo que transcurre desde que se envía el primer byte de la solicitud hasta que se recibe el primer byte de la respuesta. Este tiempo también se suele denominar tiempo de ida y vuelta (RTT), aunque técnicamente hay una diferencia entre estos dos números: el 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.
Si observamos nuestra cascada, podemos ver que todas las solicitudes pasan la mayoría de su tiempo esperando a que llegue el primer byte de la respuesta.
Este problema fue el motivo por el que se concibió originalmente HTTP/2 Push. El desarrollador de la app sabe que se necesitan ciertos recursos y puede enviarlos por el cable. Cuando el cliente se da cuenta de que necesita recuperar recursos adicionales, estos ya están en las cachés del navegador. Resultó demasiado difícil hacerlo bien y se considera desaconsejable. Este espacio de problemas se revisará durante la estandarización de HTTP/3. Por ahora, la solución más fácil es anidar todos los recursos críticos a costa de la eficiencia del almacenamiento en caché.
Nuestro CSS crítico ya está intercalado gracias a los módulos CSS y nuestro prerenderizador basado en Puppeteer. En el caso de JavaScript, necesitamos intercalar nuestros módulos fundamentales y sus dependencias. Esta tarea tiene diferentes dificultades, según el agrupador que uses.
Esto redujo 1 segundo nuestro TTI. Llegamos al punto en que nuestro index.html
contiene todo lo que se necesita para la renderización inicial y la interacción. El HTML se puede renderizar mientras se descarga, lo que crea nuestro FMP. En el momento en que se termina de analizar y ejecutar el código HTML, la app es interactiva.
División de código agresiva
Sí, nuestro index.html
contiene todo lo que se necesita para ser interactivo. Pero, tras una inspección más detallada, 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 principio: tenemos un formulario para configurar el juego que contiene un par de componentes, un botón de inicio y, probablemente, código para conservar y cargar la configuración del usuario. Eso es todo. 43 KB parece mucho.
Para comprender de dónde proviene el tamaño del paquete, podemos usar un explorador de mapas de origen 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 ganadora, la de pérdida y muchas utilidades. Solo se necesita un pequeño subconjunto de estos módulos para la página de destino. Si mueves todo lo que no es estrictamente necesario para la interactividad a un módulo de carga diferida, la TTI disminuirá significativamente.
Lo que debemos hacer es dividir el 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 y alineará todos los módulos que se importen de forma estática. Todo lo que importes de forma dinámica se pondrá 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 debe hacerse si tienes tiempo libre. El lema es importar de forma estática los módulos que se necesitan fundamentalmente en el tiempo de carga y cargar todo lo demás de forma dinámica. Pero no deberías esperar al último momento para usar los módulos de carga diferida que definitivamente se usarán. El estado inactivo hasta que sea urgente de Phil Walton es un excelente patrón para encontrar un punto intermedio saludable entre la carga diferida y la carga anticipada.
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 un poco complicado, ya que Preact no puede controlar los componentes cargados de forma diferida de forma predeterminada. Por este motivo, escribimos un pequeño wrapper de componentes 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, 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 se cargue y esté listo para usarse, <div>
se reemplazará 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 solo 20 KB, menos de la mitad del tamaño original. ¿Qué efecto tiene esto en la FMP y el TTI? WebPageTest te lo dirá.
Nuestra FMP y TTI solo se diferencian en 100 ms, ya que solo es cuestión de analizar y ejecutar el código JavaScript intercalado. Después de 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 trucos de manos
Si observas nuestra lista de módulos críticos anterior, verás que el motor de renderización no forma parte de los módulos críticos. Por supuesto, el juego no puede comenzar hasta que tengamos nuestro motor de renderización para renderizarlo. Podríamos inhabilitar el botón "Iniciar" hasta que nuestro motor de renderización esté listo para iniciar el juego, pero, según nuestra experiencia, el usuario suele tardar lo suficiente en configurar la configuración del juego como 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 frecuente 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 terminen los módulos restantes.
Conclusión
La medición es importante. Para evitar perder tiempo en problemas que no son reales, recomendamos medir siempre primero antes de implementar optimizaciones. Además, las mediciones deben realizarse en dispositivos reales con una conexión 3G o en WebPageTest si no tienes un dispositivo real a mano.
La tira de película puede brindar información sobre cómo se siente el usuario cuando carga tu app. La cascada puede indicarte qué recursos son responsables de los tiempos de carga potencialmente largos. Esta es una lista de tareas que puedes realizar para mejorar el rendimiento de carga:
- Publica tantos recursos como sea posible en una conexión.
- Precarga o incluso recursos intercalados que son necesarios para la primera renderización y la primera interactividad.
- Renderiza previamente tu app para mejorar el rendimiento de carga percibido.
- Usa una división de código agresiva para reducir la cantidad de código necesaria para la interactividad.
No te pierdas la parte 2, en la que hablaremos sobre cómo optimizar el rendimiento del entorno de ejecución en dispositivos con restricciones extremas.