wandres.dev
RENDIMIENTO · el runtime de Elm

El DOM virtual de Elm: qué lo hace rápido y qué optimizaciones habilita la pureza

Recalcular la vista entera en cada cambio del modelo parece un derroche y no lo es, pero la razón de que no lo sea suele explicarse mal. No basta con decir que el árbol virtual es barato: hace falta entender qué es exactamente un nodo virtual, por qué construirlo cuesta poco más que reservar un objeto plano, cómo se separa la fase que decide qué cambió de la fase que toca el documento real, y qué atajos concretos puede tomar el algoritmo gracias a que trabaja sobre valores inmutables producidos por funciones puras. Esta lección desarma la implementación de elm/virtual-dom en sus piezas: la representación de nodos, atributos, propiedades y manejadores; el cortocircuito por identidad de referencia que ahorra ramas enteras y que en un entorno con mutación sería sencillamente incorrecto; el truco de mantener una sola escucha por evento cuyo decodificador se sustituye en su sitio en lugar de dar de baja y de alta al manejador; y la lista de parches como valor intermedio que evita intercalar lecturas y escrituras sobre el documento. El objetivo no es admirar una implementación ajena sino extraer qué garantías del lenguaje se convierten en qué optimizaciones concretas.

⏱ 17 min

La expresión DOM virtual describe dos cosas distintas que conviene no confundir. La primera es una representación en memoria de la interfaz deseada, hecha con estructuras de datos ordinarias en lugar de con nodos del documento; la segunda es el procedimiento que convierte dos de esas representaciones consecutivas en la lista mínima de operaciones que llevan al documento del primer estado al segundo. Casi todos los entornos modernos tienen ambas, y sin embargo sus implementaciones difieren enormemente en tamaño y en velocidad. La diferencia no está en la astucia de los algoritmos, que son variaciones de la misma idea, sino en cuántas cosas tiene que dar por posibles cada implementación. Un algoritmo que debe contemplar que el árbol contenga nodos con estado propio, que sus entradas hayan sido mutadas desde fuera, que una actualización pueda interrumpirse a la mitad o que un nodo esconda un ciclo de vida con efectos, gasta buena parte de su presupuesto en defenderse de todo eso. El de Elm no contempla ninguna de esas posibilidades porque el lenguaje las ha vuelto inexpresables, y lo que queda es un procedimiento corto que puede permitirse atajos que en otros sitios serían incorrectos. Esta lección examina esos atajos uno a uno y muestra de qué garantía nace cada uno.

🎯 Al terminar esta lección sabrás
  • Describir cómo se representa un nodo virtual y por qué su construcción es una operación de coste casi nulo.
  • Distinguir la fase de comparación de la fase de aplicación y explicar qué se gana separándolas.
  • Identificar los atajos que la inmutabilidad y la pureza habilitan, empezando por el cortocircuito de identidad.
  • Explicar el tratamiento de atributos, propiedades y manejadores de eventos, y por qué cambiar un manejador no cuesta casi nada.

Un nodo virtual es un objeto plano y nada más

Cuando escribes una llamada a div no se crea nada parecido a un elemento del documento: se reserva un objeto con una etiqueta, una lista de hechos —atributos, propiedades, estilos y manejadores ya organizados por categoría— y una lista de hijos. No hay constructor con lógica, no hay identidad, no hay ciclo de vida, no hay referencia al padre ni al documento. Construir un árbol de mil nodos es reservar mil objetos pequeños e inmutables, una operación que los recolectores generacionales modernos manejan con eficiencia porque casi todos morirán jóvenes. Ese es el fundamento de que recalcular la vista entera sea aceptable: no se recalcula la pantalla, se recalcula una descripción cuyo coste unitario es el de una asignación.

La familia de nodos es pequeña y merece conocerse porque cada variante se compara de forma distinta. Están los nodos de texto, los nodos de elemento normales, los nodos con claves para listas, los nodos que fijan un espacio de nombres para gráficos vectoriales, los nodos de transformación que aplican una función a los mensajes que salen de su interior, y los nodos perezosos que guardan una función junto con sus argumentos en vez de un resultado. Seis formas, y el algoritmo de comparación es en esencia un análisis de casos sobre ellas.

-- Lo que describe la vista es un valor, no una accion sobre la pantalla
fila : Tarea -> Html Msg
fila tarea =
    div [ class "fila", onClick (Alterno tarea.id) ]
        [ span [ class "titulo" ] [ text tarea.titulo ]
        , boton "Borrar" (Borro tarea.id)
        ]


-- Seis formas de nodo y una comparacion distinta para cada una:
-- texto, elemento, elemento con claves, espacio de nombres, mapeado, perezoso
🧱

Objetos planos

Etiqueta, hechos e hijos. Sin identidad ni ciclo de vida. Construir el árbol cuesta lo que cuesta reservar memoria efímera.

🪞

Dos fases

Primero se calcula una lista de parches sin tocar nada; después se aplica de una pasada. Leer y escribir nunca se intercalan.

Atajo por referencia

Si dos subárboles son el mismo objeto, no hay nada que comparar. Con inmutabilidad esa deducción es siempre válida.

🎧

Escucha estable

Cambiar un manejador sustituye el decodificador guardado en su sitio. No hay bajas ni altas de escuchas en el documento.

Comparar sin tocar y tocar sin comparar

El procedimiento tiene dos fases estrictamente separadas. La primera recorre en paralelo el árbol anterior y el nuevo y va acumulando parches: en tal posición, cambia este texto; en tal otra, añade esta propiedad; aquí, sustituye la rama entera. Durante toda esa fase no se lee ni se escribe una sola vez en el documento real. La segunda fase recorre el documento una vez, localiza los nodos afectados y aplica los parches acumulados. Esta separación es lo que evita el patrón más caro que existe en un navegador, que consiste en alternar escrituras con lecturas de geometría y forzar así recálculos de disposición repetidos dentro del mismo fotograma.

La comparación en sí es deliberadamente aproximada. Avanza por posición y, en cuanto encuentra dos nodos que no coinciden en su forma o en su etiqueta, deja de mirar hacia abajo y programa la sustitución de la rama completa. No intenta averiguar si el subárbol que estaba aquí aparece más adelante, porque resolver ese emparejamiento en general cuesta más que rehacer el trabajo. Lo que la vuelve extraordinariamente eficaz en la práctica es que la primera comprobación de todas es la más barata imaginable.

-- Idea del algoritmo, escrita como si fuera Elm
diff : Nodo -> Nodo -> List Parche
diff anterior nuevo =
    if identicos anterior nuevo then
        []
        -- Mismo objeto en memoria: nada puede haber cambiado

    else
        case ( anterior, nuevo ) of
            ( Elemento a, Elemento b ) ->
                if a.etiqueta /= b.etiqueta then
                    [ Reemplazar nuevo ]

                else
                    diffHechos a.hechos b.hechos ++ diffHijos a.hijos b.hijos

            _ ->
                [ Reemplazar nuevo ]

Ese identicos es una comparación de identidad de referencia, no de contenido, y por eso es instantánea. En un lenguaje con mutación sería una deducción inválida: dos referencias iguales no garantizan nada, porque el objeto pudo cambiar por debajo entre un dibujado y el siguiente, así que una implementación prudente tiene que comparar estructura o exigir al programador que prometa por su cuenta que no ha mutado nada. En Elm el valor no puede haber cambiado, punto, y esa certeza convierte una heurística arriesgada en una regla exacta. De ahí sale el ahorro más grande del sistema, porque cualquier parte del árbol que se reutilice literalmente se salta entera.

Los hechos, los manejadores y la pureza que lo sostiene

Los atributos merecen párrafo aparte porque su tratamiento revela el mismo estilo de razonamiento. Los hechos de un nodo llegan ya clasificados en cuatro grupos —atributos verdaderos, propiedades del objeto, estilos y escuchas— y cada grupo se compara por separado con diccionarios pequeños. Comparar por separado evita el error clásico de tratar una propiedad como un atributo, que produce discrepancias sutiles en campos de formulario, y permite además que cada grupo tenga su propia forma de aplicar el parche.

El caso de las escuchas es el más instructivo. Dar de baja una escucha y darla de alta otra vez es una operación relativamente cara en un navegador, y en una interfaz donde el manejador captura datos del modelo cambiaría en cada dibujado. La solución consiste en registrar una sola escucha por tipo de evento y por nodo, cuya devolución de llamada consulta una ranura donde vive el decodificador actual; cuando la comparación detecta que el manejador cambió, no toca el documento, sustituye el decodificador de esa ranura. El coste de cambiar un manejador pasa así de una operación del navegador a una asignación en memoria.

ℹ️
Por qué los eventos se declaran como decodificadores y no como funciones sueltas

Que un manejador sea un decodificador de eventos y no una función arbitraria parece un rodeo, y es la pieza que hace posible lo anterior. Un decodificador es un valor inspeccionable que describe qué se quiere extraer del evento y qué mensaje producir con ello, de modo que el runtime puede guardarlo, sustituirlo y compararlo sin ejecutarlo. Si el manejador fuera un procedimiento opaco con permiso para hacer cualquier cosa, el runtime no podría razonar sobre él ni saber si el efecto de sustituirlo es seguro. Es el mismo patrón que gobierna todo el lenguaje —describir en lugar de ordenar— aplicado al punto donde el mundo exterior entra en el programa.

flowchart LR
V1[Arbol anterior] --> C[Comparacion sin tocar el documento]
V2[Arbol nuevo] --> C
C -->|Misma referencia| Z[Cero parches]
C -->|Etiqueta distinta| S[Sustituir la rama]
C -->|Igual forma| H[Parches de hechos e hijos]
H --> P[Lista de parches]
S --> P
P --> A[Aplicacion en una sola pasada]
style C fill:#89b4fa,color:#11111b
style Z fill:#a6e3a1,color:#11111b
style A fill:#cba6f7,color:#11111b
💡
La reutilización literal es una optimización gratuita que casi nadie aprovecha

Como el cortocircuito de identidad opera sobre cualquier nodo, extraer a un valor de nivel superior una parte de la interfaz que no depende del modelo —una cabecera fija, un pie, un icono complejo— hace que ese subárbol sea siempre el mismo objeto y se salte por completo en todas las comparaciones. No hace falta ninguna envoltura ni ninguna anotación: basta con que el valor se construya una vez. Es el caso extremo y gratuito de la misma idea que después se generaliza con la memoización explícita.

Dónde deja de ganar el algoritmo

Nada de lo anterior convierte la comparación en gratuita, y conocer sus límites es tan útil como conocer sus atajos. El coste de un dibujado es proporcional al tamaño del árbol descrito, no al número de cambios reales, de modo que una interfaz que describe diez mil nodos paga diez mil comparaciones aunque solo uno haya cambiado. Mientras el árbol sea moderado eso es imperceptible; a partir de cierto tamaño deja de serlo, y las dos herramientas de las lecciones siguientes existen precisamente para atacar los dos flancos de ese coste, saltándose ramas enteras cuando nada relevante cambió y emparejando por identidad cuando el orden se altera.

Hay además un patrón que provoca reconstrucciones evitables y que conviene reconocer, porque parece inocente. Cuando una condición decide qué etiqueta usar, cada cambio de la condición hace que la comparación encuentre etiquetas distintas en la misma posición y descarte la rama completa con toda su descendencia, con la consiguiente pérdida del estado que el documento guardaba. Cuando la etiqueta se conserva y lo que varía son los hechos, la misma diferencia se resuelve con un parche mínimo aplicado sobre el nodo real, que sobrevive.

-- Reconstruye la rama entera cada vez que cambia la condicion
if model.editando then
    input [ value model.texto, onInput Escribio ] []

else
    span [ class "solo-lectura" ] [ text model.texto ]


-- Conserva el nodo real y aplica un parche de hechos
input
    [ value model.texto
    , onInput Escribio
    , readonly (not model.editando)
    ]
    []
📝
Los nodos que no gobierna Elm merecen un trato aparte

Un elemento personalizado del anfitrión es, desde el punto de vista de la comparación, un nodo corriente con atributos y propiedades, y esa es toda la información que el algoritmo tiene sobre él. Si la rama que lo contiene se descarta, el elemento se destruye y vuelve a crearse con el estado interno que tuviera al nacer, y esa pérdida no es visible en ninguna medición porque no es lentitud sino olvido. Por eso conviene tratar las fronteras con código ajeno con la misma disciplina que se aplica a las listas dinámicas: estabilizar su posición en el árbol y no dejar que una condición cambie la etiqueta del nodo que las envuelve.

La velocidad aquí no es ingenio de implementación, es el interés que pagan las restricciones

Conviene resistir la tentación de leer esta lección como el elogio de una implementación especialmente astuta, porque la conclusión interesante es la contraria: no hay ninguna astucia notable en el algoritmo de Elm, y precisamente por eso es rápido. Cada atajo que hemos visto es la traducción mecánica de una libertad que el lenguaje decidió no conceder. El cortocircuito por identidad de referencia es el interés que paga la inmutabilidad, y en un entorno con mutación sería incorrecto, así que allí no puede aplicarse o hay que aplicarlo bajo promesa del programador, es decir, sin garantía. La ausencia de todo tratamiento de estado local en los nodos es el interés que paga haber centralizado el estado, y por eso no existe la maquinaria de conservar, migrar y descartar el estado de un componente cuando su posición cambia. La posibilidad de guardar un manejador como dato y sustituirlo en su sitio es el interés que paga que los eventos se declaren como decodificadores en vez de como procedimientos. La memoización de una vista, que será el asunto de la lección siguiente, es el interés que paga la pureza, porque solo una función pura puede sustituirse legítimamente por su resultado anterior. Ninguna de estas optimizaciones es transferible a un lenguaje que no haya hecho antes las mismas renuncias; se pueden imitar, con comprobaciones defensivas y con disciplina documentada, pero entonces dejan de ser garantías y se convierten en convenciones cuyo incumplimiento produce fallos que ninguna herramienta detecta. Hay aquí una moraleja que trasciende las interfaces: cuando un sistema es rápido, lo interesante rara vez es qué hace de más y casi siempre es qué ha conseguido no tener que hacer. Y lo que un sistema no tiene que hacer viene determinado, casi siempre, en el momento en que se decidió qué era imposible expresar.

⚔️ Deriva cada optimización de su garantía
  1. Enumera las seis formas de nodo virtual y describe cómo se compara cada una con otra de su misma forma.
  2. Explica por qué el cortocircuito de identidad sería incorrecto en un lenguaje con mutación y qué haría falta para suplirlo.
  3. Extrae a un valor de nivel superior una sección de la interfaz que no dependa del modelo y comprueba el efecto en el perfilador.
  4. Justifica la separación entre calcular parches y aplicarlos apelando a cómo un navegador recalcula la disposición.
  5. Describe qué ocurriría si los manejadores fueran funciones opacas en lugar de decodificadores, tanto al comparar como al aplicar.
  6. Toma tres optimizaciones de esta lección y nombra para cada una la restricción del lenguaje sin la cual dejaría de ser válida.