classMap, styleMap e ifDefined, vistas en la lección anterior, son directivas escritas por el propio equipo de Lit para resolver problemas de uso muy general. Pero el mecanismo que las hace posibles no es exclusivo del equipo de Lit: está disponible públicamente, y cualquier proyecto puede escribir sus propias directivas cuando la lógica de renderizado que necesita repetir en varias plantillas no encaja bien en una simple función auxiliar. Esta lección explica cuándo tiene sentido dar ese paso, cómo se construye una directiva propia con la función directive() y la clase base Directive, y la aplica a un caso real de TaskFlow: una directiva resaltarSiUrgente que ninguna de las herramientas ya vistas en el curso puede resolver igual de bien.

Contenido

  1. Qué puede hacer una directiva que una función auxiliar no puede
  2. La función directive() y la clase base Directive
  3. El ciclo render() / update()
  4. Part: acceso a la parte real del DOM
  5. Construyendo resaltarSiUrgente
  6. Usando la directiva en <task-card>
  7. Limpieza con disconnected()
  8. noChange: evitar trabajo innecesario

  1. Qué puede hacer una directiva que una función auxiliar no puede

Este curso ha usado, desde el módulo 2, funciones auxiliares como renderInsigniaEstado() para encapsular lógica de renderizado reutilizable dentro de un mismo componente. Una función auxiliar corriente basta siempre que su trabajo se limite a decidir qué plantilla devolver, a partir de los datos que recibe como argumento. Es una solución excelente, y de hecho sigue siendo la primera opción por defecto para la inmensa mayoría de la lógica de renderizado de TaskFlow.

Hay, sin embargo, un tipo de necesidad que una función auxiliar no puede cubrir por sí sola: cuando la lógica necesita mantener estado propio entre un renderizado y el siguiente, o necesita acceso directo al nodo real del DOM que ocupa esa posición de la plantilla, más allá del valor que se muestra en pantalla. Una función auxiliar se ejecuta de cero en cada llamada, sin memoria de la llamada anterior, y no recibe ninguna referencia al DOM; simplemente devuelve un valor y termina. Cuando la lógica necesita recordar algo entre renderizados (por ejemplo, "¿esta tarea ya estaba urgente en el renderizado anterior, o acaba de entrar en ese estado ahora mismo?") o necesita tocar el DOM directamente (por ejemplo, añadir una clase temporal y programar su eliminación con un setTimeout), hace falta una pieza con ciclo de vida propio: una directiva personalizada.

  1. La función directive() y la clase base Directive

Una directiva personalizada se construye combinando dos piezas del propio Lit, ambas importadas de lit/directive.js:

import { directive, Directive } from 'lit/directive.js';

class MiDirectiva extends Directive {
  render(...args) {
    // Lógica que decide qué se muestra, similar a una función auxiliar normal
    return 'algo';
  }
}

export const miDirectiva = directive(MiDirectiva);

Directive es la clase base de la que debe heredar cualquier directiva personalizada: define el contrato mínimo (el método render) y el resto de la maquinaria interna que conecta la directiva con el motor de plantillas de Lit. directive() es una función de fábrica que recibe esa clase y devuelve la función que realmente se usa dentro de las plantillas (miDirectiva(...), en el ejemplo); cada llamada a esa función devuelve un objeto directiva nuevo, listo para insertarse en una posición de una plantilla html, de la misma forma en que se ha usado classMap(...) o until(...) en el resto de este módulo.

Un detalle importante, que distingue a las directivas de las funciones auxiliares corrientes: Lit crea y reutiliza una única instancia de la clase Directive por cada posición fija de la plantilla en la que se usa, no una instancia nueva por cada renderizado. Si resaltarSiUrgente(...) se usa dentro de render() de <task-card>, cada instancia de <task-card> tiene su propia instancia de ResaltarSiUrgenteDirective, y esa misma instancia persiste, con su propio estado interno, mientras esa instancia de <task-card> siga existiendo y renderizándose en esa posición concreta de su plantilla. Esta persistencia es exactamente lo que permite recordar algo entre un renderizado y el siguiente, algo imposible con una función auxiliar corriente.

  1. El ciclo render() / update()

Una directiva puede implementar dos métodos, ambos opcionales salvo render, que Lit invoca en cada renderizado de la posición donde la directiva está colocada:

Método Cuándo se ejecuta Para qué sirve
render(...args) Siempre, salvo que update() decida omitirlo Calcular qué valor debe mostrarse, igual que haría una función auxiliar
update(part, args) Antes de render(), con acceso directo a la parte del DOM Leer o modificar el DOM directamente, decidir si hace falta volver a ejecutar render()

Si una directiva no necesita tocar el DOM directamente ni compararlo con el renderizado anterior, basta con implementar render(), e ignorar update() por completo (Lit llamará a update() con su implementación por defecto, que simplemente delega en render()). update() solo hace falta cuando la lógica necesita el segundo elemento de la tabla: acceso a la parte real del DOM antes de decidir qué mostrar, tal como se verá en el apartado 5 con resaltarSiUrgente.

  1. Part: acceso a la parte real del DOM

Cuando se implementa update(part, args), el primer parámetro (part) es un objeto que representa la posición concreta de la plantilla donde la directiva está colocada, con una forma distinta según en qué tipo de posición se use la directiva:

  • Un ChildPart, si la directiva ocupa la posición de un nodo hijo (html\

    ${miDirectiva()}

    ``).
  • Un AttributePart, si ocupa el valor de un atributo (html\
    ``).
  • Un ElementPart, si la directiva se coloca directamente sobre la etiqueta del elemento, sin asociarla a ningún atributo concreto (html\<div ${miDirectiva()}>
``).

Los tres tipos de Part exponen una propiedad element, que da acceso directo al nodo del DOM afectado: el propio elemento en el caso de un ElementPart, o el elemento al que pertenece el atributo o el nodo hijo en los otros dos casos. Esta referencia directa al DOM es exactamente lo que ninguna función auxiliar puede ofrecer por sí sola, y es la pieza que hace posible el ejemplo de la siguiente sección.

  1. Construyendo resaltarSiUrgente

TaskFlow necesita un efecto visual concreto que ni classMap ni ninguna técnica ya vista en el curso resuelve bien: cuando una tarea entra en estado "cerca de vencer" (el cercaDeVencer calculado por ContadorTiempoRestanteController, de la lección 06-03), su tarjeta debería resaltarse brevemente con una animación de un segundo y medio, para atraer la atención, y luego volver a su aspecto normal sin necesidad de que el usuario haga nada. classMap podría aplicar una clase mientras cercaDeVencer sea true, pero eso mantendría la clase activa todo el tiempo que dure ese estado, no solo en el instante de la transición; lo que se necesita es detectar el cambio de false a true, y reaccionar con un efecto temporal que se retire solo, con un setTimeout.

// src/directives/resaltar-si-urgente.js
import { directive, Directive } from 'lit/directive.js';
import { nothing } from 'lit';

class ResaltarSiUrgenteDirective extends Directive {
  constructor(partInfo) {
    super(partInfo);
    this._yaResaltada = false;
    this._idTimeout = null;
  }

  update(part, [urgente]) {
    const elemento = part.element;

    if (urgente && !this._yaResaltada) {
      elemento.classList.add('resaltada');
      this._idTimeout = setTimeout(() => {
        elemento.classList.remove('resaltada');
      }, 1500);
      this._yaResaltada = true;
    }

    if (!urgente) {
      this._yaResaltada = false;
    }

    return this.render(urgente);
  }

  render(urgente) {
    return nothing;
  }

  disconnected() {
    clearTimeout(this._idTimeout);
  }
}

export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective);

Varios detalles merecen explicación uno por uno:

  • El constructor(partInfo) inicializa dos campos propios de cada instancia de la directiva: _yaResaltada, que recuerda si la tarjeta ya está en mitad del efecto de resaltado (para no reiniciarlo en cada renderizado mientras urgente siga siendo true), y _idTimeout, el identificador devuelto por setTimeout, necesario para poder cancelarlo si hiciera falta. partInfo se recibe y se reenvía a super() sin usarlo directamente en este ejemplo; contiene metadatos sobre la posición de la plantilla en la que la directiva se ha instanciado por primera vez.
  • update(part, [urgente]) recibe como segundo argumento un array con los argumentos exactos con los que se llamó a resaltarSiUrgente(...) en la plantilla; como aquí solo se le pasa un argumento (urgente), se desestructura directamente en la firma del método.
  • part.element da acceso al elemento real del DOM sobre el que está colocada la directiva (como se verá en el apartado 6, el propio <article> de <task-card>), y sobre él se llama a classList.add/classList.remove con normalidad, exactamente igual que se haría con JavaScript del DOM sin ningún framework de por medio.
  • La condición urgente && !this._yaResaltada es la que detecta la transición de "no urgente" a "urgente", no simplemente el estado actual: solo entra en esta rama la primera vez que urgente es verdadero después de haber sido falso (o tras la inicialización), evitando reiniciar el setTimeout en cada renderizado posterior mientras la tarea siga siendo urgente.
  • render() se limita a devolver nothing (importado de lit, ya usado en la lección 02-03 para el caso "no renderizar nada" con el operador &&), porque esta directiva no necesita mostrar ningún valor en la posición de la plantilla; su efecto es puramente imperativo, sobre el DOM, a través de update().

  1. Usando la directiva en <task-card>

Con la directiva ya escrita, se aplica sobre <task-card> como un ElementPart, colocada directamente sobre la etiqueta <article> sin asociarla a ningún atributo concreto:

// src/components/task-card.js
import { resaltarSiUrgente } from '../directives/resaltar-si-urgente.js';

render() {
  return html`
    <article ${resaltarSiUrgente(this._contadorTiempo.cercaDeVencer)} @click="${this.alternarExpandida}">
      <h3>${this.titulo}</h3>
      ${this.renderInsigniaEstado()}
      ${this._contadorTiempo.cercaDeVencer ? html`<p class="aviso">⏰ Está a punto de vencer</p>` : ''}
    </article>
  `;
}

La sintaxis ${resaltarSiUrgente(...)} colocada directamente entre el nombre de la etiqueta y sus atributos, sin ir precedida de ningún nombre de atributo ni de =, es justamente lo que convierte a esta directiva en un ElementPart: no está asociada a class, ni a style, ni a ningún otro atributo concreto, sino al propio elemento <article> en su totalidad. En cada renderizado de esta instancia de <task-card>, Lit llama a update() sobre la misma instancia de ResaltarSiUrgenteDirective (recordada desde el primer renderizado, como se explicó en el apartado 2), pasándole el valor actual de this._contadorTiempo.cercaDeVencer; la directiva compara ese valor con su propio recuerdo de la transición anterior y decide, ella misma, si toca añadir la clase resaltada y programar su retirada.

  1. Limpieza con disconnected()

Además de render() y update(), la clase Directive ofrece dos métodos adicionales, disconnected() y reconnected(), que Lit invoca cuando la parte del DOM asociada a la directiva se desconecta o se reconecta del documento (por ejemplo, si <task-card> se elimina del DOM, o si forma parte de una lista gestionada con repeat que decide reutilizar o descartar nodos, como se vio en la lección 02-04). resaltarSiUrgente implementa disconnected() para cancelar cualquier setTimeout pendiente con clearTimeout(this._idTimeout), exactamente por el mismo motivo, ya conocido desde disconnectedCallback en la lección 06-01 y desde hostDisconnected en la lección 06-03, por el que cualquier temporizador activo debe limpiarse cuando la pieza que lo programó deja de estar en uso: sin esta limpieza, si una tarjeta se elimina del DOM justo cuando _idTimeout está pendiente, el setTimeout seguiría ejecutándose igualmente 1500 ms más tarde, intentando modificar classList de un elemento que ya no forma parte de la página, un desperdicio de trabajo que además podría, en casos más complejos con referencias cruzadas, contribuir a una fuga de memoria.

  1. noChange: evitar trabajo innecesario

Lit ofrece, importable también de lit, un valor especial llamado noChange, que una directiva puede devolver desde update() (o desde render()) para indicarle a Lit "no ha cambiado nada en esta posición, no hace falta tocar el DOM en absoluto". Es distinto de devolver nothing (usado en el apartado 5 para "no mostrar ningún valor real", pero que sí actualiza el DOM para reflejar esa ausencia si antes había algo); noChange significa, literalmente, "deja el DOM exactamente como está, sin ninguna comparación ni actualización".

import { noChange } from 'lit';

update(part, [urgente]) {
  if (urgente === this._ultimoValorVisto) {
    return noChange;
  }
  this._ultimoValorVisto = urgente;
  // ... resto de la lógica ...
  return this.render(urgente);
}

En el ejemplo de resaltarSiUrgente, esta optimización no aporta nada relevante porque render() ya devuelve siempre nothing (una operación trivial), así que se ha omitido para no complicar el ejemplo principal; pero conviene conocer noChange para directivas cuyo render() sí produzca un valor costoso de calcular o de aplicar al DOM, donde evitar un trabajo repetido cuando el valor de entrada no ha cambiado puede suponer una diferencia real de rendimiento.

Errores Comunes y Consejos

  • Exportar la clase directamente en lugar del resultado de directive(): export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective) es imprescindible; exportar ResaltarSiUrgenteDirective a secas y usarla como new ResaltarSiUrgenteDirective() dentro de una plantilla no funcionaría, porque el motor de plantillas de Lit reconoce directivas por la forma especial que directive() les da, no por herencia de Directive en sí misma.
  • Olvidar disconnected() cuando la directiva programa temporizadores o suscripciones: exactamente el mismo riesgo señalado en el apartado 7 y ya visto dos veces en el módulo 6; cualquier recurso que la directiva arranque en update() (un intervalo, una promesa pendiente con una acción asociada, un listener añadido manualmente sobre part.element) debería limpiarse en disconnected().
  • Intentar modificar el DOM directamente desde render() en lugar de update(): render() está pensado para devolver un valor que Lit inserte por su cuenta en la posición correspondiente, no para hacer manipulación imperativa del DOM; el acceso a part.element solo está disponible dentro de update() (o de disconnected()/reconnected()), nunca como argumento de render().
  • Crear una directiva personalizada para un caso que classMap, styleMap o una función auxiliar ya resuelven: como se explicó en el apartado 1, el criterio de decisión es concreto: solo hace falta una directiva propia cuando la lógica necesita recordar estado entre renderizados o tocar el DOM directamente; si basta con decidir qué plantilla o qué objeto devolver a partir de los datos actuales, una función auxiliar o una directiva incorporada son siempre la opción más simple.

Ejercicios

  1. Añade un parámetro de configuración a resaltarSiUrgente, de forma que se use como resaltarSiUrgente(this._contadorTiempo.cercaDeVencer, { duracionMs: 3000, clase: 'resaltada-larga' }), sustituyendo los valores fijos 1500 y 'resaltada' del apartado 5 por los valores recibidos (con duracionMs: 1500 y clase: 'resaltada' como valores por defecto si no se especifican).
  2. Explica, basándote en el apartado 2, qué pasaría si resaltarSiUrgente se usara dentro de una lista renderizada con repeat (lección 02-04) y una tarea concreta cambiara de posición en el array: ¿la instancia de ResaltarSiUrgenteDirective asociada a esa tarea se conserva, o se crea una nueva? Apóyate en que repeat, gracias a su clave (key), reutiliza el mismo nodo DOM para el mismo elemento lógico aunque cambie de posición.
  3. Un compañero de equipo propone sustituir resaltarSiUrgente por una función auxiliar corriente, resaltarSiUrgente(urgente), que devuelva la clase 'resaltada' o '' según el valor de urgente, combinada con classMap. Explica por qué esa alternativa no reproduce el comportamiento real de la directiva (el resaltado temporal de 1500 ms que se retira solo), aunque a primera vista parezca una simplificación razonable.

Soluciones

class ResaltarSiUrgenteDirective extends Directive {
  constructor(partInfo) {
    super(partInfo);
    this._yaResaltada = false;
    this._idTimeout = null;
  }

  update(part, [urgente, opciones = {}]) {
    const { duracionMs = 1500, clase = 'resaltada' } = opciones;
    const elemento = part.element;

    if (urgente && !this._yaResaltada) {
      elemento.classList.add(clase);
      this._idTimeout = setTimeout(() => elemento.classList.remove(clase), duracionMs);
      this._yaResaltada = true;
    }

    if (!urgente) {
      this._yaResaltada = false;
    }

    return this.render(urgente);
  }

  render() {
    return nothing;
  }

  disconnected() {
    clearTimeout(this._idTimeout);
  }
}

export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective);
  1. Como se explicó en el apartado 2, Lit asocia una instancia de la directiva a cada posición fija de la plantilla, y repeat (a diferencia de Array.map) identifica cada elemento por su clave lógica (tarea.id), no por su posición en el array; en consecuencia, si una tarea cambia de posición dentro de la lista, repeat mueve el mismo nodo DOM existente a la nueva posición en lugar de destruirlo y recrear uno nuevo, y esa continuidad del nodo DOM se traslada también a la directiva colocada sobre él: la misma instancia de ResaltarSiUrgenteDirective, con su _yaResaltada y su _idTimeout intactos, sigue asociada a esa misma tarea aunque cambie de posición visual en la lista. Si en cambio se usara Array.map para esa misma lista, un reordenamiento podría hacer que Lit reutilizara el nodo de la posición N para representar ahora una tarea lógica distinta, y con él la instancia de la directiva, que podría arrastrar por error el estado (_yaResaltada) de la tarea que antes ocupaba esa posición.
  2. La clase 'resaltada' combinada con classMap, sin más, aplicaría la clase durante todo el tiempo que urgente sea true, no solo durante el primer segundo y medio tras la transición: en cuanto urgente volviera a false en un renderizado posterior (por ejemplo, porque la fecha límite se actualiza), la clase desaparecería, pero mientras urgente siguiera siendo true de forma continuada, la clase seguiría activa indefinidamente, sin el efecto de "destello temporal que se retira solo" que sí logra setTimeout dentro de la directiva. Reproducir el comportamiento real exigiría, como mínimo, que la función auxiliar recordara si ya había mostrado el resaltado antes (exactamente el estado _yaResaltada de la directiva) y programara su propio setTimeout para retirarla, lo cual ya no es posible con una función auxiliar corriente, sin estado propio entre llamadas, tal como se explicó en el apartado 1.

Conclusión

Esta lección ha presentado las directivas personalizadas como la herramienta de Lit para lógica de renderizado que necesita algo más que decidir qué plantilla devolver: estado propio persistente entre renderizados, y acceso directo al nodo del DOM afectado a través de part.element. resaltarSiUrgente, construida con directive() y la clase base Directive, ha resuelto un efecto visual —el destello temporal al entrar en estado urgente— que ninguna de las herramientas ya vistas en el curso, incluidas las directivas incorporadas de la lección anterior, podía cubrir igual de bien, reutilizable además en cualquier otra plantilla de TaskFlow que necesite el mismo aviso.

Con las directivas ya dominadas tanto en su forma incorporada como personalizada, queda un último problema de renderizado por resolver en este módulo, distinto de todo lo visto hasta ahora: qué hacer cuando el valor que hay que mostrar todavía no existe, porque depende de una operación asíncrona en marcha, como cargar las tareas de TaskFlow desde una fuente de datos externa. La siguiente lección presenta la directiva until para exactamente ese caso.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow