Complejidades de un desplazador infinito

TL;DR: Vuelve a usar tus elementos del DOM y quita los que estén lejos de la ventana gráfica. Usa marcadores de posición para tener en cuenta los datos retrasados. Aquí tienes una demostración y el código del desplazamiento infinito.

Los desplazadores infinitos aparecen en todo Internet. La lista de artistas de Google Music es una, la cronología de Facebook es una y el feed en vivo de Twitter también es uno. Te desplazas hacia abajo y, antes de llegar al final, aparece contenido nuevo mágicamente, como si surgiera de la nada. Es una experiencia fluida para los usuarios y es fácil ver su atractivo.

Sin embargo, el desafío técnico detrás de un desplazamiento infinito es más difícil de lo que parece. La variedad de problemas que encuentras cuando quieres hacer lo correcto™ es enorme. Comienza con cosas simples, como los vínculos en el pie de página que se vuelven prácticamente inalcanzables porque el contenido los desplaza constantemente. Pero los problemas se vuelven más difíciles. ¿Cómo controlas un evento de cambio de tamaño cuando alguien gira su teléfono del modo vertical al horizontal? ¿Cómo evitas que tu teléfono se detenga de forma dolorosa cuando la lista se vuelve demasiado larga?

Lo correcto™

Creímos que era motivo suficiente para crear una implementación de referencia que mostrara una forma de abordar todos estos problemas de manera reutilizable y, al mismo tiempo, mantener los estándares de rendimiento.

Usaremos 3 técnicas para lograr nuestro objetivo: reciclaje del DOM, marcadores de posición y anclaje de desplazamiento.

Nuestro caso de demostración será una ventana de chat similar a Hangouts en la que podremos desplazarnos por los mensajes. Lo primero que necesitamos es una fuente infinita de mensajes de chat. Técnicamente, ninguno de los desplazadores infinitos que existen son verdaderamente infinitos, pero con la cantidad de datos disponibles para incorporarlos, podrían serlo. Para simplificar las cosas, codificaremos un conjunto de mensajes de chat y elegiremos mensajes, autores y archivos adjuntos de imágenes ocasionales de forma aleatoria con una pizca de demora artificial para que se comporte un poco más como la red real.

Captura de pantalla de la app de chat

Reciclaje del DOM

El reciclaje del DOM es una técnica subutilizada para mantener bajo el recuento de nodos del DOM. La idea general es usar elementos del DOM ya creados que estén fuera de la pantalla en lugar de crear elementos nuevos. Sin duda, los nodos del DOM en sí mismos son económicos, pero no son gratuitos, ya que cada uno de ellos agrega un costo adicional en memoria, diseño, estilo y pintura. Los dispositivos de gama baja se volverán notablemente más lentos, o incluso inutilizables, si el sitio web tiene un DOM demasiado grande para administrar. También ten en cuenta que cada nuevo diseño y cada nueva aplicación de tus estilos (un proceso que se activa cada vez que se agrega o quita una clase de un nodo) se vuelve más costoso con un DOM más grande. Reciclar los nodos del DOM significa que mantendremos la cantidad total de nodos del DOM considerablemente más baja, lo que acelerará todos estos procesos.

El primer obstáculo es el desplazamiento en sí. Dado que solo tendremos un pequeño subconjunto de todos los elementos disponibles en el DOM en un momento determinado, necesitamos encontrar otra forma de hacer que la barra de desplazamiento del navegador refleje correctamente la cantidad de contenido que, teóricamente, está allí. Usaremos un elemento centinela de 1 px por 1 px con una transformación para forzar que el elemento que contiene los elementos (la pista) tenga la altura deseada. Promocionaremos cada elemento de la pista a su propia capa para asegurarnos de que la capa de la pista en sí esté completamente vacía. Sin color de fondo, nada. Si la capa de la pista no está vacía, no es apta para las optimizaciones del navegador y tendremos que almacenar una textura en nuestra tarjeta gráfica que tenga una altura de cientos de miles de píxeles. Definitivamente, no es viable en un dispositivo móvil.

Cada vez que nos desplacemos, verificaremos si la ventana gráfica se acercó lo suficiente al final de la pista. Si es así, extenderemos la pista moviendo el elemento centinela y los elementos que salieron de la ventana gráfica a la parte inferior de la pista, y los completaremos con contenido nuevo.

Runway Sentinel Viewport

Lo mismo sucede con el desplazamiento en la otra dirección. Sin embargo, nunca reduciremos la pista en nuestra implementación, de modo que la posición de la barra de desplazamiento se mantenga coherente.

Tombstones

Como mencionamos anteriormente, intentamos que nuestra fuente de datos se comporte como algo del mundo real. Con latencia de red y todo. Esto significa que, si nuestros usuarios usan el desplazamiento rápido, pueden desplazarse fácilmente más allá del último elemento del que tenemos datos. Si eso sucede, colocaremos un elemento de lápida (un marcador de posición) que se reemplazará por el elemento con contenido real una vez que lleguen los datos. Los marcadores también se reciclan y tienen un grupo separado para los elementos del DOM reutilizables. Necesitamos eso para poder hacer una transición agradable desde un marcador de posición hasta el elemento completado con contenido, lo que de otro modo sería muy molesto para el usuario y podría hacer que pierda la pista de lo que estaba viendo.

Tal tumba. Mucha piedra. ¡Vaya!

Un desafío interesante aquí es que los elementos reales pueden tener una altura mayor que el elemento de lápida debido a las diferentes cantidades de texto por elemento o a una imagen adjunta. Para resolver este problema, ajustaremos la posición de desplazamiento actual cada vez que lleguen datos y se reemplace una lápida sobre la ventana gráfica, anclando la posición de desplazamiento a un elemento en lugar de a un valor de píxel. Este concepto se denomina anclaje de desplazamiento.

Anclaje de desplazamiento

Nuestra función de anclaje de desplazamiento se invocará cuando se reemplacen los marcadores de posición y cuando se cambie el tamaño de la ventana (lo que también sucede cuando se gira el dispositivo). Tendremos que determinar cuál es el elemento visible más superior en el viewport. Como ese elemento solo podría ser parcialmente visible, también almacenaremos el desplazamiento desde la parte superior del elemento donde comienza el viewport.

Diagrama de anclaje de desplazamiento.

Si se cambia el tamaño de la ventana gráfica y la pista tiene cambios, podemos restablecer una situación que se siente visualmente idéntica para el usuario. ¡Ganaste! Excepto que una ventana redimensionada significa que cada elemento posiblemente cambió su altura, entonces, ¿cómo sabemos qué tan abajo se debe colocar el contenido anclado? ¡No lo hacemos! Para averiguarlo, tendríamos que diseñar cada elemento sobre el elemento anclado y sumar todas sus alturas, lo que podría causar una pausa significativa después de un cambio de tamaño, y no queremos eso. En cambio, suponemos que todos los elementos anteriores tienen el mismo tamaño que una lápida y ajustamos nuestra posición de desplazamiento en consecuencia. A medida que los elementos se desplazan hacia la pista, ajustamos nuestra posición de desplazamiento, lo que difiere de manera efectiva el trabajo de diseño hasta que realmente se necesita.

Diseño

Me salté un detalle importante: el diseño. Normalmente, cada reciclaje de un elemento del DOM volvería a diseñar toda la pista, lo que nos dejaría muy por debajo de nuestro objetivo de 60 fotogramas por segundo. Para evitar esto, asumimos la carga del diseño y usamos elementos con posición absoluta con transformaciones. De esta manera, podemos simular que todos los elementos más arriba en la pista siguen ocupando espacio cuando, en realidad, solo hay espacio vacío. Como nosotros mismos realizamos el diseño, podemos almacenar en caché las posiciones en las que termina cada elemento y cargar de inmediato el elemento correcto desde la caché cuando el usuario se desplaza hacia atrás.

Lo ideal sería que los elementos solo se repintaran una vez cuando se adjuntan al DOM y que no se vieran afectados por las adiciones o eliminaciones de otros elementos en la pista. Esto es posible, pero solo con navegadores modernos.

Ajustes de vanguardia

Recientemente, Chrome agregó compatibilidad con CSS Containment, una función que permite a los desarrolladores indicarle al navegador que un elemento es un límite para el trabajo de diseño y pintura. Dado que aquí realizamos el diseño nosotros mismos, es una aplicación principal para la contención. Cada vez que agregamos un elemento a la pista, sabemos que los demás elementos no necesitan verse afectados por el nuevo diseño. Por lo tanto, cada elemento debe obtener contain: layout. Tampoco queremos afectar el resto de nuestro sitio web, por lo que la pista también debería recibir esta directiva de diseño.

Otra cosa que consideramos es usar IntersectionObservers como mecanismo para detectar cuándo el usuario se desplazó lo suficiente para que comencemos a reciclar elementos y cargar datos nuevos. Sin embargo, se especifica que los IntersectionObserver tienen una latencia alta (como si se usara requestIdleCallback), por lo que es posible que sintamos menos capacidad de respuesta con los IntersectionObserver que sin ellos. Incluso nuestra implementación actual con el evento scroll sufre este problema, ya que los eventos de desplazamiento se envían según el principio de "mejor esfuerzo". Con el tiempo, el worklet del compositor de Houdini sería la solución de alta fidelidad para este problema.

Aún no es perfecto

Nuestra implementación actual del reciclaje del DOM no es ideal, ya que agrega todos los elementos que pasan por la ventana gráfica, en lugar de solo preocuparse por los que están en la pantalla. Esto significa que, cuando te desplazas muuuuy rápido, Chrome debe realizar tanto trabajo de diseño y pintura que no puede seguir el ritmo. Solo verás el fondo. No es el fin del mundo, pero sin duda es algo que se debe mejorar.

Esperamos que veas lo desafiantes que pueden ser los problemas simples cuando deseas combinar una excelente experiencia del usuario con altos estándares de rendimiento. Dado que las apps web progresivas se están convirtiendo en experiencias centrales en los teléfonos celulares, esto será cada vez más importante, y los desarrolladores web deberán seguir invirtiendo en el uso de patrones que respeten las restricciones de rendimiento.

Todo el código se encuentra en nuestro repositorio. Hicimos todo lo posible para que sea reutilizable, pero no lo publicaremos como una biblioteca real en npm ni como un repo independiente. El uso principal es educativo.