wandres.dev
PORTS E INTEROP · hablar con JavaScript

Custom elements: envolver widgets de JavaScript en la vista

Los ports resuelven bien la comunicación con el exterior cuando lo que cruza son datos, pero fracasan cuando lo que hace falta es un trozo de interfaz gobernado por una biblioteca ajena: un mapa, un editor enriquecido, una gráfica que anima. El problema es que el DOM virtual de Elm reclama la propiedad de todo el árbol que renderiza y destruirá cualquier nodo que otro no le haya pedido. Los custom elements son la salida estándar del navegador a ese conflicto: se define un elemento con nombre propio cuya vida interior pertenece a JavaScript, y Elm lo trata como una hoja opaca que solo conoce por sus atributos y sus eventos. Esta lección desarrolla ese contrato de tres piezas, explica por qué la delimitación de propiedad es el concepto central y no un detalle de implementación, y establece cuándo conviene un custom element y cuándo sigue siendo mejor un port.

⏱ 18 min

Llega un momento en todo proyecto en que lo que se necesita del exterior no es un dato sino un pedazo de pantalla. Un mapa interactivo con capas y gestos, un editor de texto enriquecido con su propia gestión de selección, una gráfica que anima transiciones, un reproductor de vídeo con controles propios. Ninguna de esas piezas se puede reconstruir razonablemente en Elm, y ninguna se puede gobernar por ports, porque un port transporta información y lo que aquí hace falta es ceder territorio: un rectángulo del documento cuyo interior no lo dibuje ni lo toque el DOM virtual. El conflicto es real y no se resuelve con más ports. Se resuelve con la solución que el propio navegador estandarizó para este problema, los custom elements, que permiten declarar un elemento con nombre propio cuya vida interior pertenece a otro y que Elm puede colocar en su vista como si fuera una hoja cualquiera.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el DOM virtual entra en conflicto con cualquier biblioteca que manipule los nodos que Elm renderiza.
  • Definir un custom element en el anfitrión y consumirlo desde la vista con Html.node y attribute.
  • Leer el contrato de tres piezas: atributos hacia dentro, eventos hacia fuera, ciclo de vida en medio.
  • Decidir con criterio cuándo corresponde un custom element y cuándo sigue siendo preferible un port.

El conflicto de propiedad sobre el árbol

El DOM virtual funciona porque asume una premisa fuerte: el árbol real refleja lo que la última vista describió. Sobre esa premisa calcula la diferencia entre el árbol anterior y el nuevo, y aplica solo las operaciones necesarias. Si otro agente modifica el árbol real por su cuenta, la premisa se rompe y el cálculo produce resultados absurdos: nodos que se conservan cuando debían morir, nodos que se destruyen cuando eran ajenos, atributos que se restauran a valores viejos. No es un fallo de Elm ni una peculiaridad suya; le ocurre igual a cualquier biblioteca que reconcilie un árbol contra una descripción.

De ahí se sigue una regla que conviene enunciar sin matices: dentro del subárbol que Elm renderiza, Elm es el único propietario. Una biblioteca externa que inserte nodos ahí verá su trabajo deshecho en la próxima actualización, con una intermitencia especialmente cruel porque el problema no aparece hasta que algo cambia en una parte del modelo aparentemente ajena. Los custom elements no eluden la regla: la respetan y la aprovechan. El nodo del elemento pertenece a Elm, que lo crea, lo actualiza y lo destruye; el interior pertenece a la definición del elemento, y Elm nunca lo mira porque en su descripción ese nodo no tiene hijos.

flowchart TD
V[view de Elm] --> N[nodo mapa-leaflet]
N -->|atributos| D[definicion del custom element]
D --> I[interior gobernado por JavaScript]
I -->|CustomEvent| N
N -->|Msg decodificado| U[update]
style N fill:#89b4fa,color:#11111b
style I fill:#f9e2af,color:#11111b
style U fill:#a6e3a1,color:#11111b
🧭

Frontera de propiedad

Elm posee el nodo; la definición posee el interior. La linde es exacta y ninguna de las dos partes cruza al terreno de la otra.

⬇️

Atributos hacia dentro

Los datos bajan como atributos o propiedades. Cambiar el modelo cambia el atributo y el elemento reacciona en su propio ciclo de vida.

⬆️

Eventos hacia fuera

Lo que el widget quiere comunicar sale como evento personalizado, decodificado en un Msg como cualquier clic.

🧬

Estándar del navegador

No es un truco de Elm. Es una interfaz del navegador que funciona igual con cualquier biblioteca y sobrevive a cambios de tecnología.

Definir el elemento y consumirlo desde la vista

La definición vive por completo en JavaScript y sigue la interfaz estándar: una clase que extiende el elemento base, un método que se ejecuta al conectarse al documento, otro al desconectarse y otro cuando cambia alguno de los atributos observados. Ese ciclo de vida es el que permite crear la instancia de la biblioteca, mantenerla sincronizada y destruirla limpiamente sin fugas.

customElements.define(
  "mapa-leaflet",
  class extends HTMLElement {
    static get observedAttributes() {
      return ["lat", "lon"];
    }

    connectedCallback() {
      this._mapa = L.map(this).setView([this.lat(), this.lon()], 13);
      this._mapa.on("moveend", () => {
        const c = this._mapa.getCenter();
        this.dispatchEvent(
          new CustomEvent("centro-movido", { detail: { lat: c.lat, lon: c.lng } })
        );
      });
    }

    attributeChangedCallback() {
      if (this._mapa) this._mapa.setView([this.lat(), this.lon()]);
    }

    disconnectedCallback() {
      if (this._mapa) this._mapa.remove();
    }

    lat() {
      return parseFloat(this.getAttribute("lat") || "0");
    }
    lon() {
      return parseFloat(this.getAttribute("lon") || "0");
    }
  }
);

Del lado de Elm, el consumo no tiene nada de especial. Se construye el nodo por su nombre con Html.node, se le pasan atributos derivados del modelo y se escucha su evento personalizado con un decodificador que extrae los campos del detalle. La lista de hijos va vacía, y esa lista vacía es la declaración explícita de que Elm no reclama nada de lo que haya dentro.

vistaMapa : Model -> Html Msg
vistaMapa model =
    Html.node "mapa-leaflet"
        [ attribute "lat" (String.fromFloat model.lat)
        , attribute "lon" (String.fromFloat model.lon)
        , Html.Events.on "centro-movido" decodificadorCentro
        ]
        []


decodificadorCentro : Json.Decode.Decoder Msg
decodificadorCentro =
    Json.Decode.map2 CentroMovido
        (Json.Decode.at [ "detail", "lat" ] Json.Decode.float)
        (Json.Decode.at [ "detail", "lon" ] Json.Decode.float)

Cuando el dato que baja no es un número ni una cadena sino una estructura, el camino de los atributos se vuelve incómodo porque obliga a serializar y volver a interpretar en cada actualización. La biblioteca ofrece entonces la vía de las propiedades, que asigna directamente un valor codificado sobre el objeto del elemento y evita ese viaje por texto.

vistaGrafica : Model -> Html Msg
vistaGrafica model =
    Html.node "grafica-lineas"
        [ property "series" (codificarSeries model.series)
        , property "opciones" (codificarOpciones model.opciones)
        , Html.Events.on "punto-elegido" decodificadorPunto
        ]
        []
ℹ️
Atributos son cadenas; las propiedades transportan estructura

Un atributo del documento solo puede contener texto, así que un dato compuesto tendría que serializarse y volver a interpretarse. Cuando lo que baja es una estructura, la vía adecuada es Html.Attributes.property, que asigna una propiedad del objeto con un valor codificado y evita el viaje por texto. La contrapartida es que las propiedades no disparan el método de cambio de atributos observados, de modo que el elemento debe definir un accesor que reaccione a la asignación. Merece la pena conocer las dos vías y elegir según la forma del dato, no por costumbre.

Cuándo un custom element y cuándo un port

Las dos técnicas resuelven problemas distintos y confundirlas produce diseños incómodos. Un port es el mecanismo correcto cuando lo que cruza la frontera es información sin lugar en la pantalla: persistir estado, hablar con un websocket, leer el portapapeles, registrar telemetría. Un custom element es el mecanismo correcto cuando lo que se necesita es que un fragmento del documento tenga vida propia y esa vida esté ligada a la presencia del nodo en el árbol.

Hay una diferencia que suele decidir el caso dudoso y que tiene que ver con el ciclo de vida. Un widget gobernado por ports vive en un espacio paralelo al modelo: si el usuario navega a otra pantalla y el nodo desaparece, nada avisa a la biblioteca de que debe soltar sus recursos, y hay que recordar mandar un mensaje de limpieza que es fácil olvidar. Un custom element no tiene ese problema porque su destrucción está atada a la del nodo: cuando Elm deja de describirlo, el navegador lo desconecta y el método correspondiente se ejecuta sin que nadie tenga que acordarse. La gestión de recursos deja de depender de la disciplina y pasa a depender de la estructura, que es exactamente el tipo de sustitución que persigue toda la arquitectura.

💡
La clave está en la propiedad `key` de las listas

Cuando un custom element aparece dentro de una lista que se reordena, conviene usar nodos con clave mediante Html.Keyed. Sin clave, el reconciliador puede reutilizar un nodo existente para otra entrada, y el elemento no se desconectará ni volverá a conectarse: solo verá cambiar sus atributos, con lo que una instancia costosa quedará asociada a datos que no le corresponden. Con clave, la identidad del nodo sigue a la del dato y el ciclo de vida ocurre cuando debe.

Ceder territorio con una linde clara es más seguro que compartirlo con buena voluntad

Detrás de esta técnica hay una lección de arquitectura que trasciende a Elm y al navegador. Cuando dos sistemas con reglas incompatibles tienen que convivir sobre un mismo recurso, existen dos estrategias y solo una funciona a largo plazo. La primera es compartir el recurso y coordinarse mediante convenciones: tú no toques esto, yo no toco aquello, avísame antes de modificar aquello otro. Es la estrategia que adoptan casi todas las integraciones improvisadas, y falla siempre por el mismo motivo: las convenciones no están representadas en ninguna parte, no las verifica nadie y se erosionan con cada cambio que hace alguien que no las conocía. La segunda estrategia es partir el recurso con una linde explícita y darle a cada parte propiedad exclusiva de la suya, definiendo un protocolo estrecho para cruzar. Es más rígida, exige más trabajo de diseño inicial y produce ocasionales incomodidades cuando algo querría estar a ambos lados. A cambio, elimina de raíz la clase entera de errores que nacen de la propiedad ambigua, y hace que el sistema resultante sea auditable: la linde se ve, el protocolo se lee, las responsabilidades se atribuyen sin discusión. Un custom element es exactamente esa segunda estrategia aplicada al árbol del documento. El nodo es de Elm, el interior es del widget, y el protocolo son tres piezas visibles: atributos que bajan, eventos que suben, ciclo de vida que ata la existencia del uno a la del otro. Nada de eso es una convención que haya que recordar; todo está representado en el código de ambas partes y verificado por el navegador. Y conviene reparar en la elegancia del reparto: al declarar la lista de hijos vacía, Elm no está renunciando a nada por cortesía, está afirmando la verdad de su descripción. Para el reconciliador, ese nodo no tiene interior. Lo que ocurre dentro no es una excepción tolerada a sus reglas, es algo que sus reglas ni siquiera contemplan, y por eso no puede entrar en conflicto con ellas.

⚔️ Cede el rectángulo sin perder el control
  1. Explica con un ejemplo concreto por qué una biblioteca que inserta nodos dentro del árbol de Elm produce fallos intermitentes y difíciles de reproducir.
  2. Define un custom element mínimo con los cuatro métodos del ciclo de vida y comprueba en qué momento se ejecuta cada uno.
  3. Consúmelo desde la vista con Html.node y una lista de hijos vacía, y razona qué le está declarando esa lista al reconciliador.
  4. Haz que el elemento emita un evento personalizado con datos en su detalle y decodifícalo en un Msg con Json.Decode.at.
  5. Sustituye un atributo de texto por una propiedad con estructura y describe qué cambia en la manera en que el elemento detecta la actualización.
  6. Coloca varios de esos elementos en una lista reordenable, primero sin clave y después con Html.Keyed, y compara el comportamiento del ciclo de vida.