Keyed y lazy: ayudar al DOM virtual con información que no puede deducir
Una vista pura se recalcula entera en cada cambio del modelo, y el DOM virtual se encarga de que eso no signifique reconstruir la pantalla: compara el árbol nuevo con el anterior y aplica solo la diferencia. Esa comparación es rápida porque es aproximada, y las dos optimizaciones de esta lección existen precisamente para darle la información que su aproximación no puede deducir. Html.Keyed proporciona identidad: al asociar una clave estable a cada hijo de una lista, el algoritmo distingue un elemento que se ha movido de un elemento que ha cambiado de contenido, lo cual evita perder el estado no declarado del documento y elimina reconstrucciones innecesarias al insertar, eliminar u ordenar. Html.lazy proporciona lo contrario, permiso para no mirar: memoriza el árbol producido por una función y lo reutiliza cuando los argumentos no han cambiado, con la condición estricta de que la comparación se hace por identidad de referencia y no por igualdad estructural, lo cual impone requisitos precisos sobre cómo se escribe la llamada. La lección cierra con el criterio para medir antes de optimizar.
La vista pura tiene una consecuencia que parece un problema y que casi siempre no lo es: si la función se evalúa entera cada vez que cambia el modelo, un solo carácter tecleado en un campo de búsqueda produce un árbol completo de la aplicación. Lo que impide que eso sea catastrófico es que ese árbol no es la pantalla sino una descripción de la pantalla, un valor barato de construir que el runtime compara con la descripción anterior para calcular la lista mínima de operaciones que hay que aplicar al documento real. La comparación es rápida porque renuncia a ser exacta: recorre ambos árboles en paralelo, posición contra posición, y en cuanto detecta que dos nodos difieren en su etiqueta deja de mirar y sustituye la rama entera. Esa heurística acierta en la inmensa mayoría de los casos y falla exactamente en dos, que son los que esta lección aborda. El primero es la lista cuyos elementos se insertan, se borran o se reordenan, donde comparar por posición induce al algoritmo a creer que todo ha cambiado. El segundo es la sección costosa de la interfaz que se reconstruye una y otra vez aunque sus datos no se hayan movido. Para cada uno existe una herramienta, y cada una pide a cambio que se cumplan sus condiciones.
- Explicar cómo compara el DOM virtual dos árboles y en qué dos situaciones su heurística posicional resulta insuficiente.
- Usar
Html.Keyedcon claves estables y únicas, y justificar por qué el índice de posición no sirve como clave. - Aplicar
Html.lazyrespetando los requisitos de estabilidad de referencia que exige su memoización. - Diagnosticar por qué una memoización no surte efecto y decidir con mediciones cuándo introducirla.
Cómo compara el árbol y dónde falla la comparación
El algoritmo recorre las dos descripciones a la vez y avanza por posición. Si en el mismo lugar encuentra la misma etiqueta, conserva el nodo real y baja a comparar atributos e hijos; si encuentra etiquetas distintas, descarta el nodo real y construye uno nuevo con toda su descendencia. Nunca busca en otras posiciones si el elemento que estaba aquí se ha ido allí, porque hacerlo obligaría a un emparejamiento general entre dos conjuntos y eso costaría mucho más que rehacer el trabajo. La heurística cambia el coste de un problema difícil por el de un recorrido lineal, y ese cambio es el que hace viable recalcular la vista entera.
El fallo aparece cuando la posición deja de ser un buen indicio de identidad. Si se inserta un elemento al principio de una lista larga, todos los demás se desplazan un lugar, y el algoritmo compara cada uno con el que antes ocupaba su nueva posición. Como suelen tener la misma etiqueta, no reconstruye pero sí reescribe el contenido de todos ellos, y lo que debería haber sido una única inserción se convierte en un trabajo proporcional a la longitud. Peor aún: se pierde el estado que el documento guarda por su cuenta y que el modelo no conoce, como el foco, la selección de texto o la posición del desplazamiento interno.
Conviene subrayar por qué ese segundo efecto es más grave que el primero. La lentitud se mide, se nota en un perfilador y se corrige cuando molesta; la pérdida de estado no declarado es un fallo de comportamiento que aparece de forma intermitente, solo cuando el usuario está haciendo dos cosas a la vez, y que casi nunca se reproduce en un entorno de pruebas donde nadie tiene el cursor puesto en ninguna parte. Es exactamente el género de defecto que llega a producción y sobrevive allí durante meses, porque el informe que lo describe suena inverosímil y los pasos para reproducirlo dependen de un detalle que el usuario no sabe que importa.
Comparación posicional
Se recorren ambos árboles a la vez. Misma etiqueta en la misma posición significa conservar; etiqueta distinta significa sustituir la rama entera.
El punto ciego
El algoritmo no busca un elemento que se ha movido. Insertar al principio parece, desde su punto de vista, que todos han cambiado de contenido.
Estado no declarado
El foco, la selección y el desplazamiento viven en el documento y no en el modelo. Reconstruir un nodo los destruye sin que nada avise.
Rápido por aproximado
Emparejar de verdad dos conjuntos costaría más que rehacer el trabajo. La heurística es la razón de que recalcular la vista sea viable.
Keyed: dar identidad a los hijos de una lista
La solución es proporcionar la información que falta. Un contenedor con clave no recibe una lista de hijos sino una lista de pares formados por una cadena identificadora y el nodo correspondiente, y con eso el algoritmo puede emparejar por identidad en vez de por posición: reconoce que el elemento que estaba en el segundo lugar ahora está en el quinto, lo mueve sin reconstruirlo y aplica solo las inserciones y eliminaciones reales. El resultado no es únicamente más rápido; es más correcto, porque los nodos reales que sobreviven conservan el estado que el modelo nunca declaró.
import Html.Keyed as Keyed
listaTareas : List Tarea -> Html Msg
listaTareas tareas =
Keyed.node "ul" [ class "lista" ] (List.map conClave tareas)
conClave : Tarea -> ( String, Html Msg )
conClave tarea =
( String.fromInt tarea.id, fila tarea )
-- La clave debe ser estable y unica dentro del contenedor
-- Sirve: un identificador del dominio
-- No sirve: la posicion, porque cambia al ordenar o filtrar
Los requisitos de una clave son dos y ambos importan. Estable significa que el mismo elemento conserva su clave a lo largo del tiempo aunque cambie de sitio o de contenido; por eso el índice de posición es la peor elección posible, ya que precisamente cambia en el momento en que la identidad haría falta, y usarlo equivale a no haber puesto claves con el añadido de creer que sí. Única significa que dos hijos del mismo contenedor no comparten clave, porque el emparejamiento se vuelve ambiguo y el comportamiento resultante es difícil de diagnosticar. Cuando el dato no trae identificador propio, generarlo al crearlo y guardarlo en el modelo es preferible a derivarlo del contenido, que puede repetirse.
El módulo ofrece además atajos para los contenedores más frecuentes, de modo que en la práctica basta con sustituir la llamada al elemento por su equivalente con clave y cambiar la lista de hijos por una lista de pares. Un detalle que suele pasarse por alto es el alcance: las claves solo actúan entre los hijos directos del contenedor que las declara, así que anidar listas exige declararlas en cada nivel donde haya reordenación, y no basta con poner claves arriba y confiar en que se propaguen hacia abajo.
-- Atajos para los contenedores habituales
Keyed.ul : List (Attribute msg) -> List ( String, Html msg ) -> Html msg
Keyed.ol : List (Attribute msg) -> List ( String, Html msg ) -> Html msg
-- El alcance de las claves es un solo nivel
grupos : List Grupo -> Html Msg
grupos gs =
Keyed.ul [] (List.map (\g -> ( g.id, li [] [ listaTareas g.tareas ] )) gs)
Antes de que ninguna medición delate el problema, hay una señal inequívoca de que falta identidad en una lista: el usuario está escribiendo en un campo de una fila, llega una actualización que inserta o elimina otra fila, y el cursor salta a otro sitio o el texto seleccionado se pierde. Eso ocurre porque el nodo real donde vivía el foco fue destruido y sustituido por otro con el mismo aspecto. Si observas ese comportamiento, el diagnóstico casi siempre es el mismo y la corrección también.
Lazy: permiso para no volver a mirar
La segunda herramienta ataca el otro problema. Al envolver la llamada a una función de vista, el runtime guarda la función y sus argumentos junto con el árbol que produjeron; en el dibujado siguiente, si función y argumentos son los mismos, devuelve el árbol memorizado sin ejecutar nada y el algoritmo de comparación puede saltarse esa rama completa. El ahorro no es marginal en secciones grandes de la interfaz, porque evita a la vez construir la descripción y compararla.
import Html.Lazy exposing (lazy, lazy2)
view : Model -> Html Msg
view model =
div []
[ lazy cabecera model.usuario
, lazy2 listaTareas model.filtro model.tareas
, lazy pie model.version
]
La condición que hay que entender es cómo se decide que los argumentos son los mismos. La comparación empieza por identidad de referencia y solo cae en una comparación estructural para valores primitivos. Esto significa que dos registros con exactamente el mismo contenido, si se construyeron en momentos distintos, se consideran distintos y la memoización no se aplica. De ahí salen los tres errores que hacen que la optimización no surta ningún efecto mientras el código parece correcto: pasar una función anónima creada en el lugar, pasar un valor construido en la llamada, y aplicar la envoltura a una composición en vez de a una función con nombre definida en el nivel superior del módulo.
La misma exigencia explica una regla de diseño que a primera vista parece caprichosa: conviene envolver funciones que reciban las porciones del modelo estrictamente necesarias en lugar del modelo entero. Si se pasa el modelo completo, cualquier cambio en cualquiera de sus campos produce un registro nuevo y la memoización se descarta aunque la sección envuelta no dependa de lo que cambió. Pasar los dos o tres campos que la función necesita, con las variantes numeradas de la envoltura, hace que la comparación sea a la vez más barata y más certera. Esta disciplina tiene además un efecto colateral que compensa por sí solo: obliga a declarar explícitamente de qué depende cada sección de la interfaz, y esa declaración suele revelar dependencias accidentales que nadie había advertido.
-- Rompe la memoizacion: la lambda es nueva en cada dibujado
lazy (\t -> listaTareas model.filtro t) model.tareas
-- Rompe la memoizacion: el registro se construye aqui mismo
lazy panel { titulo = "Tareas", items = model.items }
-- Funciona: funcion con nombre y argumentos que vienen del modelo
lazy2 listaTareas model.filtro model.tareas
flowchart TD M[Modelo nuevo] --> L[Html lazy comprueba argumentos] L -->|Iguales por referencia| C[Reutiliza el arbol memorizado] L -->|Distintos| E[Evalua la funcion de vista] E --> K[Contenedor con claves] K --> D[Emparejamiento por identidad] C --> P[Menos comparacion] D --> P style L fill:#89b4fa,color:#11111b style K fill:#a6e3a1,color:#11111b style P fill:#cba6f7,color:#11111b
Una lista larga se beneficia de ambas a la vez y en capas distintas. La envoltura memorizada decide si hace falta reconstruir la descripción de la lista, y solo si hace falta se ejecuta la función que produce el contenedor con claves; ese contenedor decide entonces cómo emparejar lo nuevo con lo viejo. La primera evita trabajo cuando nada relevante ha cambiado; la segunda minimiza el trabajo cuando algo sí ha cambiado. Aplicar solo una de las dos deja sin cubrir la mitad del problema.
Conviene además fijar el orden de trabajo, porque estas dos herramientas se prestan al uso supersticioso. El emparejamiento por claves no es una optimización opcional sino una corrección: en cualquier lista cuyos elementos se inserten, se eliminen o se reordenen, debe estar desde el principio, porque su ausencia produce fallos visibles de estado y no solo lentitud. La memoización, en cambio, es una optimización pura, tiene su propio coste en memoria y en comparaciones, y añadirla por todas partes ensucia el código sin ganancia mensurable. La secuencia correcta es medir con el perfilador del navegador, localizar la sección que domina el tiempo, envolverla, y volver a medir para confirmar que la memoización se está aplicando de verdad y no la ha desactivado alguno de los tres errores anteriores.
Estas dos herramientas merecen una lectura que va más allá de su utilidad inmediata, porque ilustran una forma de optimizar que es rara y valiosa. En la mayoría de los entornos, acelerar una interfaz obliga a renunciar a la abstracción que la hacía comprensible: se pasa de describir qué debe verse a instruir cómo actualizar, se introducen comparaciones manuales entre estados anterior y siguiente, se congelan ramas del árbol, se guardan referencias a nodos reales para tocarlos a mano, y el código resultante ya no es una descripción sino un procedimiento, con lo que se pierde justo la propiedad que permitía razonar sobre él. Aquí no ocurre nada de eso. Ni las claves ni la memoización cambian lo que la vista significa: el árbol descrito es idéntico con ellas y sin ellas, y si se eliminan del código la aplicación sigue comportándose igual, solo que más despacio y, en el caso de las listas, perdiendo estado que el documento guardaba por su cuenta. Lo que se añade es información que el algoritmo no podía deducir por sí mismo. Con las claves se le comunica una noción de identidad que no está en la estructura, porque saber que dos filas son la misma fila aunque hayan cambiado de sitio es conocimiento del dominio y no del árbol. Con la memoización se le comunica una garantía sobre la función, a saber, que es pura y que por tanto los mismos argumentos producen el mismo resultado, algo que el runtime no puede verificar pero que el lenguaje sí asegura. Esa es la clave de por qué la técnica funciona: la memoización de una vista solo es segura si la vista es pura, de modo que la pureza que parecía un lujo teórico en la primera lección de este nivel resulta ser el requisito técnico que habilita la optimización de la última. Y hay un corolario metodológico que conviene retener: cuando optimizar consiste en aportar información en vez de en desmontar abstracciones, la optimización se puede añadir tarde, medir, y quitar si no rinde, sin arrastrar consigo ninguna deuda de diseño.
- Describe el recorrido que hace la comparación de dos árboles y explica por qué no busca un elemento que ha cambiado de posición.
- Provoca la pérdida de foco insertando un elemento al principio de una lista sin claves, y corrígela con un contenedor con clave.
- Justifica con un caso concreto por qué el índice de posición como clave equivale a no tener claves, y qué lo hace peor.
- Aplica
lazy2a una sección costosa y confirma con el perfilador que la función deja de evaluarse cuando sus argumentos no cambian. - Reproduce los tres errores que desactivan la memoización y explica en cada uno qué comparación falla y por qué.
- Argumenta por qué las claves son una corrección y la memoización una optimización, y deriva de ahí en qué orden se introducen.